odoo.define('web.relational_fields', function (require) {
"use strict";
/**
* Relational Fields
*
* In this file, we have a collection of various relational field widgets.
* Relational field widgets are more difficult to use/manipulate, because the
* relations add a level of complexity: a value is not a basic type, it can be
* a collection of other records.
*
* Also, the way relational fields are edited is more complex. We can change
* the corresponding record(s), or alter some of their fields.
*/
var AbstractField = require('web.AbstractField');
var concurrency = require('web.concurrency');
var config = require('web.config');
var Context = require('web.Context');
var ControlPanel = require('web.ControlPanel');
var dialogs = require('web.view_dialogs');
var core = require('web.core');
var data = require('web.data');
var Dialog = require('web.Dialog');
var field_utils = require('web.field_utils');
var KanbanRenderer = require('web.KanbanRenderer');
var ListRenderer = require('web.ListRenderer');
var mixins = require('web.mixins');
var Pager = require('web.Pager');
var pyeval = require('web.pyeval');
var _t = core._t;
var qweb = core.qweb;
/**
* Abstract widget to handle relational fields
*/
var AbstractRelationalField = AbstractField.extend({
init: function (parent, name, record) {
this._super.apply(this, arguments);
// 'domain' and 'context' that should be used by the field widget. It
// can come from the attrs, and is mostly useful for rpcs
this.domain = this.attrs.domain || this.field.domain || [];
this.context = this.attrs.context || this.field.context || {};
// todo: remove this
this.record = record;
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @private
*/
_reset: function (record) {
this.record = record;
this._super.apply(this, arguments);
},
});
//------------------------------------------------------------------------------
// Many2one widgets
//------------------------------------------------------------------------------
var M2ODialog = Dialog.extend({
template: "M2ODialog",
init: function (parent, name, value) {
this.name = name;
this.value = value;
this._super(parent, {
title: _.str.sprintf(_t("Create a %s"), this.name),
size: 'medium',
buttons: [{
text: _t('Create'),
classes: 'btn-primary',
click: function () {
if (this.$("input").val() !== ''){
this.trigger_up('quick_create', { value: this.$('input').val() });
this.close();
} else {
this.$("input").focus();
}
},
}, {
text: _t('Create and edit'),
classes: 'btn-primary',
close: true,
click: function () {
this.trigger_up('search_create_popup', {
view_type: 'form',
value: this.$('input').val(),
});
},
}, {
text: _t('Cancel'),
close: true,
}],
});
},
start: function () {
this.$("p").text(_.str.sprintf(_t("You are creating a new %s, are you sure it does not exist yet?"), this.name));
this.$("input").val(this.value);
},
});
var FieldMany2One = AbstractRelationalField.extend(mixins.ServicesMixin, {
template: 'FieldMany2One',
custom_events: {
'quick_create': '_onQuickCreate',
'search_create_popup': '_onSearchCreatePopup',
},
events: _.extend({}, AbstractField.prototype.events, {
'click .o_form_input': '_onInputClick',
'focusout .o_form_input': '_onInputFocusout',
'keyup .o_form_input': '_onInputKeyup',
'click .o_external_button': '_onExternalButtonClick',
'click': '_onClick',
}),
init: function () {
this._super.apply(this, arguments);
this.limit = 7;
this.orderer = new concurrency.DropMisordered();
this.can_create = 'can_create' in this.attrs ? this.attrs.can_create : true;
this.can_write = 'can_write' in this.attrs ? this.attrs.can_write : true;
this.nodeOptions = _.defaults(this.nodeOptions, {
quick_create: true,
});
this.m2o_value = field_utils.format_many2one(this.value);
},
start: function () {
// what is 'this.floating' for? add a comment!
this.floating = false;
this.$input = this.$('.o_form_input');
this.$external_button = this.$('.o_external_button');
return this._super.apply(this, arguments);
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
activate: function () {
this.$input.focus();
setTimeout(this.$input.select.bind(this.$input), 0);
},
reinitialize: function (value) {
this.floating = false;
this._setValue(value);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @private
*/
_bindAutoComplete: function () {
var self = this;
this.$input.autocomplete({
source: function (req, resp) {
self._search(req.term).then(function (result) {
resp(result);
});
},
select: function (event, ui) {
var item = ui.item;
self.floating = false;
if (item.id) {
self.reinitialize({id: item.id, display_name: item.name});
} else if (item.action) {
item.action();
}
return false;
},
focus: function (event) {
event.preventDefault(); // don't automatically select values on focus
},
autoFocus: true,
html: true,
minLength: 0,
delay: 200,
});
this.$input.autocomplete("option", "position", { my : "left top", at: "left bottom" });
this.autocomplete_bound = true;
},
/**
* @private
* @param {string} [name]
* @returns {Object}
*/
_createContext: function (name) {
var tmp = {};
var field = this.nodeOptions.create_name_field;
if (field === undefined) {
field = "name";
}
if (field !== false && name && this.nodeOptions.quick_create !== false) {
tmp["default_" + field] = name;
}
return tmp;
},
/**
* @private
*/
_focus: function () {
if (this.mode === "edit") {
this.$input.focus();
}
},
/**
* @private
* @returns {Array}
*/
_getSearchBlacklist: function () {
return [];
},
/**
* @private
* @param {string} name
*/
_quickCreate: function (name) {
var self = this;
var slow_create = this._searchCreatePopup.bind(this, "form", false, this._createContext(name));
if (this.nodeOptions.quick_create) {
this.trigger_up('name_create', {
model: this.field.relation,
name: name,
context: data.build_context(this.record, this.context),
on_success: function (result) {
if (self.mode === "edit") {
self.reinitialize({id: result[0], display_name: result[1]});
}
},
on_fail: slow_create,
});
} else {
slow_create();
}
},
/**
* @private
*/
_renderEdit: function () {
var value = this.m2o_value;
// this is a stupid hack necessary to support the always_reload flag.
// the field value has been reread by the basic model. We use it to
// display the full address of a patner, separated by \n. This is
// really a bad way to do it. Now, we need to remove the extra lines
// and hope for the best that noone tries to uses this mechanism to do
// something else.
if (this.field.__always_reload) {
value = value.split('\n')[0];
}
this.$input.val(value);
if (!this.autocomplete_bound) {
this._bindAutoComplete();
}
this._updateExternalButton();
},
/**
* @private
*/
_renderReadonly: function () {
var value = _.escape((this.m2o_value || "").trim()).split("\n").join("
");
this.$el.html(value);
if (!this.nodeOptions.no_open) {
this.$el.attr('href', '#');
this.$el.addClass('o_form_uri');
}
},
/**
* @private
*/
_reset: function () {
this._super.apply(this, arguments);
this.m2o_value = field_utils.format_many2one(this.value);
},
/**
* @private
* @param {string} search_val
* @returns {Deferred}
*/
_search: function (search_val) {
var self = this;
var def = $.Deferred();
this.orderer.add(def);
var exclusion_domain = [];
var blacklisted_ids = this._getSearchBlacklist();
if (blacklisted_ids.length > 0) {
exclusion_domain.push(['id', 'not in', blacklisted_ids]);
}
var domain = new data.CompoundDomain(data.build_domain(this.record, this.domain), exclusion_domain);
this.trigger_up('name_search', {
model: this.field.relation,
search_val: search_val,
domain: pyeval.eval('domain', domain),
operator: 'ilike',
limit: this.limit + 1,
on_success: function (result) {
// possible selections for the m2o
var values = _.map(result, function (x) {
x[1] = x[1].split("\n")[0];
return {
label: _.str.escapeHTML(x[1].trim()) || data.noDisplayContent,
value: x[1],
name: x[1],
id: x[0],
};
});
// search more... if more results than limit
if (values.length > self.limit) {
values = values.slice(0, self.limit);
values.push({
label: _t("Search More..."),
action: function () {
self.trigger_up('name_search', {
model: self.field.relation,
search_val: search_val,
domain: pyeval.eval('domain', domain),
operator: 'ilike',
limit: 160,
on_success: self._searchCreatePopup.bind(self, "search"),
});
},
classname: 'o_m2o_dropdown_option',
});
}
var create_enabled = self.can_create && !self.nodeOptions.no_create;
// quick create
var raw_result = _.map(result, function (x) { return x[1]; });
if (create_enabled && !self.nodeOptions.no_quick_create &&
search_val.length > 0 && !_.contains(raw_result, search_val)) {
values.push({
label: _.str.sprintf(_t('Create "%s"'),
$('').text(search_val).html()),
action: self._quickCreate.bind(self, search_val),
classname: 'o_m2o_dropdown_option'
});
}
// create and edit ...
if (create_enabled && !self.nodeOptions.no_create_edit) {
values.push({
label: _t("Create and Edit..."),
action: self._searchCreatePopup.bind(self, "form", false, self._createContext(search_val)),
classname: 'o_m2o_dropdown_option',
});
} else if (values.length === 0) {
values.push({
label: _t("No results to show..."),
});
}
def.resolve(values);
},
});
return def;
},
/**
* all search/create popup handling
*
* @private
* @param {any} view
* @param {any} ids
* @param {any} context
*/
_searchCreatePopup: function (view, ids, context) {
var self = this;
new dialogs.SelectCreateDialog(this, _.extend({}, this.nodeOptions, {
res_model: this.field.relation,
domain: data.build_domain(this.record, this.domain),
context: new Context(data.build_context(this.record, this.context), context || {}),
title: (view === 'search' ? _t("Search: ") : _t("Create: ")) + this.string,
initial_ids: ids ? _.map(ids, function (x) { return x[0]; }) : undefined,
initial_view: view,
disable_multiple_selection: true,
on_selected: function (element_ids) {
self.reinitialize({id: element_ids[0]});
self._focus();
}
})).open();
},
/**
* @private
*/
_updateExternalButton: function () {
var has_external_button = !this.nodeOptions.no_open && !this.floating && this.isSet();
this.$external_button.toggle(has_external_button);
this.$el.toggleClass('o_with_button', has_external_button);
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
* @param {MouseEvent} event
*/
_onClick: function (event) {
var self = this;
if (this.mode === 'readonly' && !this.nodeOptions.no_open) {
event.preventDefault();
event.stopPropagation();
var context = data.build_context(this.record, this.context);
var args = [[this.value.res_id], context.eval()];
this.performModelRPC(this.field.relation, 'get_formview_action', args)
.then(function (action) {
self.trigger_up('do_action', {action: action});
});
}
},
/**
* @private
*/
_onExternalButtonClick: function () {
if (!this.value) {
this.focus();
return;
}
var self = this;
var context = data.build_context(this.record, this.context);
var model = this.field.relation;
var method = 'get_formview_id';
var args = [[this.value.res_id], context.eval()];
this.performModelRPC(model, method, args).then(function (view_id) {
new dialogs.FormViewDialog(self, {
res_model: self.field.relation,
res_id: self.value.res_id,
context: context,
title: _t("Open: ") + self.string,
view_id: view_id,
readonly: !self.can_write,
on_saved: function () {
self.trigger_up('reload', {db_id: self.value.id});
},
}).open();
});
},
/**
* @private
*/
_onInputClick: function () {
if (this.$input.autocomplete("widget").is(":visible")) {
this.$input.autocomplete("close");
} else {
this.$input.autocomplete("search", "");
}
},
/**
* @private
*/
_onInputFocusout: function () {
if (this.can_create && this.floating) {
new M2ODialog(this, this.string, this.$input.val()).open();
}
},
/**
* @private
*/
_onInputKeyup: function () {
if (this.$input.val() === "") {
this.reinitialize(false);
} else if (this.m2o_value !== this.$input.val()) {
this.floating = true;
this._updateExternalButton();
}
},
/**
* @private
* @param {OdooEvent} event
*/
_onSearchCreatePopup: function (event) {
var data = event.data;
this._searchCreatePopup(data.view_type, false, this._createContext(data.value));
},
});
var ListFieldMany2One = FieldMany2One.extend({
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @private
*/
_renderReadonly: function () {
this.$el.text(this.m2o_value);
},
});
var KanbanFieldMany2One = AbstractRelationalField.extend({
tagName: 'span',
init: function () {
this._super.apply(this, arguments);
this.m2o_value = field_utils.format_many2one(this.value);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @private
*/
_render: function () {
this.$el.text(this.m2o_value);
},
});
//------------------------------------------------------------------------------
// X2Many widgets
//------------------------------------------------------------------------------
var FieldX2Many = AbstractRelationalField.extend({
tagName: 'div',
custom_events: _.extend({}, AbstractRelationalField.prototype.custom_events, {
field_changed: '_onFieldChanged',
kanban_record_delete: '_onDeleteRecord',
list_record_delete: '_onDeleteRecord',
add_record: '_onAddRecord',
open_record: '_onOpenRecord',
toggle_column_order: '_onToggleColumnOrder',
}),
/**
* @override
*/
init: function (parent, name, record, options) {
this._super.apply(this, arguments);
this.operations = [];
this.isReadonly = this.mode === 'readonly';
this.view = this.field.views.tree || this.field.views.kanban;
this.activeActions = {};
var arch = this.view && this.view.arch;
if (arch) {
this.activeActions.create = arch.attrs.create ?
JSON.parse(arch.attrs.create) :
true;
this.editable = arch.attrs.editable;
}
this.widgets_registry = options && options.widgets_registry;
},
/**
* @override
*/
start: function () {
return this._renderControlPanel().then(this._super.bind(this));
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* @override
*/
isSet: function () {
return true;
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* This function is meant to be overriden by the One2ManyField.
*
* @private
* @returns {Boolean} true if the 'Add an item' row should be available in
* the list renderer, false otherwise
*/
_hasCreateLine: function () {
return !this.isReadonly;
},
/**
* Instanciates or updates the adequate renderer.
*
* @override
* @private
* @returns {Deferred}
*/
_render: function () {
if (!this.view) {
return this._super();
}
if (this.renderer) {
this.renderer.updateState(this.value);
this.pager.updateState({ size: this.value.count });
return $.when();
}
var arch = this.view.arch;
if (arch.tag === 'tree') {
this.renderer = new ListRenderer(this, this.value, {
arch: arch,
mode: this.mode,
addCreateLine: this._hasCreateLine(),
addTrashIcon: !this.isReadonly,
});
}
if (arch.tag === 'kanban') {
var record_options = {
editable: false,
deletable: false,
model: this.value.model, // required by includes of KanbanRecord
read_only_mode: this.isReadonly,
};
this.renderer = new KanbanRenderer(this, this.value, {
arch: arch,
record_options: record_options
});
}
return this.renderer ? this.renderer.appendTo(this.$el) : this._super();
},
/**
* Instanciates a control panel with the appropriate buttons and a pager.
* Prepends the control panel's $el to this widget's $el.
*
* @private
* @returns {Deferred}
*/
_renderControlPanel: function () {
if (!this.view) {
return $.when();
}
var self = this;
var defs = [];
this.control_panel = new ControlPanel(this, "X2ManyControlPanel");
this.pager = new Pager(this, this.value.count, this.value.offset + 1, this.value.limit, {
single_page_hidden: true,
withAccessKey: false,
});
this.pager.on('pager_changed', this, function (new_state) {
this.trigger_up('load', {
id: this.value.id,
limit: new_state.limit,
offset: new_state.current_min - 1,
on_success: function (value) {
self.value = value;
self._render();
},
});
});
this._renderButtons();
defs.push(this.pager.appendTo($('