summaryrefslogtreecommitdiff
path: root/addons/web/static/src/js/fields/relational_fields.js
diff options
context:
space:
mode:
Diffstat (limited to 'addons/web/static/src/js/fields/relational_fields.js')
-rw-r--r--addons/web/static/src/js/fields/relational_fields.js1493
1 files changed, 1493 insertions, 0 deletions
diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js
new file mode 100644
index 00000000000..4282d78faf7
--- /dev/null
+++ b/addons/web/static/src/js/fields/relational_fields.js
@@ -0,0 +1,1493 @@
+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 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("<br/>");
+ 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 "<strong>%s</strong>"'),
+ $('<span />').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 data.CompoundContext(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($('<div>'))); // start the pager
+ defs.push(this.control_panel.prependTo(this.$el));
+ return $.when.apply($, defs).then(function () {
+ self.control_panel.update({
+ cp_content: {
+ $buttons: self.$buttons,
+ $pager: self.pager.$el,
+ }
+ });
+ });
+ },
+ /**
+ * Renders the buttons and sets this.$buttons.
+ *
+ * @private
+ */
+ _renderButtons: function () {
+ if (!this.isReadonly && this.view.arch.tag === 'kanban') {
+ var options = { create_text: this.nodeOptions.create_text };
+ this.$buttons = $(qweb.render('KanbanView.buttons', options));
+ this.$buttons.on('click', 'button.o-kanban-button-new', this._onAddRecord.bind(this));
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the user clicks on the 'Add an item' link (list case) or the
+ * 'Add' button (kanban case).
+ *
+ * @abstract
+ * @private
+ */
+ _onAddRecord: function () {
+ // to implement
+ },
+ /**
+ * Removes the given record from the relation.
+ * Stops the propagation of the event to prevent it from being handled again
+ * by the parent controller.
+ *
+ * @private
+ * @param {OdooEvent}
+ */
+ _onDeleteRecord: function (event) {
+ event.stopPropagation();
+ this._setValue({
+ operation: 'REMOVE',
+ id: event.data.id
+ });
+ },
+ /**
+ * Updates the given record with the changes.
+ *
+ * @private
+ * @param {OdooEvent}
+ */
+ _onFieldChanged: function (event) {
+ if (event.target === this) {
+ return;
+ }
+ event.stopPropagation();
+ // changes occured in an editable list
+ var changes = event.data.changes;
+ if (Object.keys(changes).length) {
+ this._setValue({
+ operation: 'UPDATE',
+ id: event.data.dataPointID,
+ data: changes
+ });
+ }
+ },
+ /**
+ * Intercepts the 'open_record' event to edit its data and lets it bubble up
+ * to the form view.
+ *
+ * @private
+ */
+ _onOpenRecord: function (event) {
+ _.extend(event.data, {
+ context: data.build_context(this.record, this.context),
+ domain: data.build_context(this.record, this.domain),
+ form_view: this.field.views && this.field.views.form,
+ readonly: this.mode === 'readonly',
+ string: this.string,
+ });
+ },
+ /**
+ * Adds field name information to the event, so that the view upstream is
+ * aware of which widgets it has to redraw.
+ *
+ * @private
+ * @param {OdooEvent}
+ */
+ _onToggleColumnOrder: function (event) {
+ event.data.field = this.name;
+ },
+});
+
+var FieldOne2Many = FieldX2Many.extend({
+ className: 'o_form_field_one2many',
+ supportedFieldTypes: ['one2many'],
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+ /**
+ * @override
+ * @param {Object} record
+ * @param {OdooEvent} [event] an event that triggered the reset action
+ * @returns {Deferred}
+ */
+ reset: function (record, event) {
+ var self = this;
+ if (event && event.target === this && self.view.arch.tag === 'tree' && this.editable) {
+ var command = event.data.changes[this.name];
+ if (command.operation === 'UPDATE') {
+ var fieldsChanged = _.keys(command.data);
+ this.renderer.confirmChange(record.data[this.name], command.id, fieldsChanged);
+ return $.when();
+ }
+ }
+ return this._super.apply(this, arguments).then(function () {
+ if (event && event.target === self && self.view.arch.tag === 'tree') {
+ if (event.data.changes[self.name].operation === 'CREATE') {
+ // todo: get last record if editable = bottom
+ var newID = self.value.data[0].id;
+ self.renderer.editRecord(newID);
+ }
+ }
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Overrides enable the 'Add an item' feature if the 'create' action is
+ * available.
+ *
+ * @override
+ * @private
+ */
+ _hasCreateLine: function () {
+ return this._super.apply(this, arguments) && this.activeActions.create;
+ },
+ /**
+ * Overrides to only render the buttons if the 'create' action is available.
+ *
+ * @override
+ * @private
+ */
+ _renderButtons: function () {
+ if (this.activeActions.create) {
+ this._super.apply(this, arguments);
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Opens a FormViewDialog to allow creating a new record for a one2many.
+ *
+ * @override
+ * @private
+ * @param {OdooEvent|MouseEvent} event this event comes either from the 'Add
+ * record' link in the list editable renderer, or from the 'Create' button
+ * in the kanban view
+ */
+ _onAddRecord: function (event) {
+ // we don't want interference with the components upstream.
+ event.stopPropagation();
+
+ if (this.editable) {
+ this._setValue({
+ operation: 'CREATE',
+ position: this.editable,
+ });
+ } else {
+ this.trigger_up('add_one2many_record', {
+ domain: data.build_domain(this.record, this.domain),
+ context: data.build_context(this.record, this.context),
+ field: this.field,
+ on_save: function (record) {
+ this._setValue({
+ operation: 'ADD',
+ id: record.id
+ });
+ }.bind(this),
+ });
+ }
+ },
+ /**
+ * Overrides the handler to set a specific 'on_save' callback as the o2m
+ * sub-records aren't saved directly when the user clicks on 'Save' in the
+ * dialog. Instead, the relational record is changed in the local data, and
+ * this change is saved in DB when the user clicks on 'Save' in the main
+ * form view.
+ *
+ * @override
+ * @private
+ * @param {OdooEvent}
+ */
+ _onOpenRecord: function (event) {
+ var self = this;
+ this._super.apply(this, arguments);
+ _.extend(event.data, {
+ on_save: function (record) {
+ // update in a form view opened from the o2m (kanban or non editable list)
+ var relRecord = _.find(self.value.data, function (d) {
+ return d.res_id === record.res_id;
+ });
+ self._setValue({
+ operation: 'UPDATE',
+ id: relRecord.id,
+ data: record.data,
+ });
+ },
+ });
+ },
+});
+
+var FieldMany2Many = FieldX2Many.extend({
+ className: 'o_form_field_many2many',
+ supportedFieldTypes: ['many2many'],
+
+ /**
+ * @override
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ this.nodeOptions = _.defaults(this.nodeOptions, {
+ create_text: _t('Add'),
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Opens a SelectCreateDialog.
+ *
+ * @override
+ * @private
+ * @param {OdooEvent}
+ */
+ _onAddRecord: function () {
+ var self = this;
+ var domain = data.build_domain(this.record, this.domain);
+ new dialogs.SelectCreateDialog(this, {
+ res_model: this.field.relation,
+ domain: new data.CompoundDomain(domain, ["!", ["id", "in", this.value.res_ids]]),
+ context: data.build_context(this.record, this.context),
+ title: _t("Add: ") + this.string,
+ no_create: this.nodeOptions.no_create || !this.activeActions.create,
+ on_selected: function (res_ids) {
+ var new_ids = _.difference(res_ids, self.value.res_ids);
+ if (new_ids.length) {
+ var values = _.map(new_ids, function (id) {
+ return {id: id};
+ });
+ self._setValue({
+ operation: 'ADD_M2M',
+ ids: values
+ });
+ }
+ }
+ }).open();
+ },
+ /**
+ * Overrides the handler to reload the field when the subrecord is saved.
+ *
+ * @override
+ * @private
+ * @param {OdooEvent}
+ */
+ _onOpenRecord: function (event) {
+ this._super.apply(this, arguments);
+ _.extend(event.data, {
+ on_saved: this.trigger_up.bind(this, 'reload', {db_id: event.data.id}),
+ });
+ },
+});
+
+var FieldMany2ManyTags = AbstractRelationalField.extend({
+ tag_template: "FieldMany2ManyTag",
+ className: "o_form_field o_form_field_many2manytags",
+ replace_element: true,
+ supportedFieldTypes: ['many2many'],
+ custom_events: {
+ field_changed: '_onFieldChanged',
+ move_next: '_onMoveNext',
+ },
+ events: _.extend({}, AbstractField.prototype.events, {
+ 'click .o_delete': '_onDeleteTag',
+ 'keydown .o_form_field_many2one input': '_onKeyDown',
+ }),
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ activate: function () {
+ this.many2one.focus();
+ },
+ isSet: function () {
+ return !!this.value && this.value.count;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {any} data
+ */
+ _addTag: function (data) {
+ if (!_.contains(this.value.res_ids, data.id)) {
+ this._setValue({
+ operation: 'ADD_M2M',
+ ids: data
+ });
+ }
+ },
+ /**
+ * @private
+ * @param {any} id
+ */
+ _removeTag: function (id) {
+ var record = _.findWhere(this.value.data, {res_id: id});
+ this._setValue({
+ operation: 'REMOVE',
+ id: record.id
+ });
+ },
+ /**
+ * @private
+ */
+ _renderEdit: function () {
+ var self = this;
+ this._renderTags();
+ this.many2one = new FieldMany2One(this, this.name, this.record, {
+ idForLabel: this.idForLabel,
+ mode: 'edit',
+ });
+ // to prevent the M2O to take the value of the M2M
+ this.many2one.value = false;
+ // to prevent the M2O to take the relational values of the M2M
+ this.many2one.m2o_value = '';
+
+ this.many2one.nodeOptions.no_open = true;
+ this.many2one._getSearchBlacklist = function () {
+ return self.value.res_ids;
+ };
+ this.many2one.appendTo(this.$el);
+ },
+ /**
+ * @private
+ */
+ _renderReadonly: function () {
+ this._renderTags();
+ },
+ /**
+ * @private
+ */
+ _renderTags: function () {
+ var elements = this.value ? _.pluck(this.value.data, 'data') : [];
+ this.$el.html(qweb.render(this.tag_template, {
+ elements: elements,
+ readonly: this.mode === "readonly",
+ }));
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onDeleteTag: function (event) {
+ this._removeTag($(event.target).parent().data('id'));
+ },
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onFieldChanged: function (event) {
+ if (event.target === this) {
+ return;
+ }
+ event.stopPropagation();
+ var new_value = event.data.changes[this.name];
+ if (new_value) {
+ this._addTag(new_value);
+ this.many2one.reinitialize(false);
+ }
+ },
+ /**
+ * @private
+ * @param {KeyboardEvent} event
+ */
+ _onKeyDown: function (event) {
+ if($(event.target).val() === "" && event.which === $.ui.keyCode.BACKSPACE) {
+ var $badges = this.$('.badge');
+ if($badges.length) {
+ this._removeTag($badges.last().data('id'));
+ }
+ }
+ },
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onMoveNext: function (event) {
+ if (event.target === this) {
+ return;
+ }
+ // Intercept event triggered up by the many2one, to prevent triggering it twice
+ event.stopPropagation();
+ },
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onQuickCreate: function (event) {
+ this._quickCreate(event.data.value);
+ },
+});
+
+var FormFieldMany2ManyTags = FieldMany2ManyTags.extend({
+ events: _.extend({}, FieldMany2ManyTags.prototype.events, {
+ 'click .badge': '_onOpenColorPicker',
+ 'mousedown .o_colorpicker span': '_onUpdateColor',
+ 'focusout .o_colorpicker': '_onCloseColorPicker',
+ }),
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onCloseColorPicker: function (){
+ this.$color_picker.remove();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onOpenColorPicker: function (event) {
+ var tag_id = $(event.currentTarget).data('id');
+ var tag = _.findWhere(this.value.data, { res_id: tag_id });
+ if (tag && 'color' in tag.data) { // if there is a color field on the related model
+ this.$color_picker = $(qweb.render('FieldMany2ManyTag.colorpicker', {
+ 'widget': this,
+ 'tag_id': tag_id,
+ }));
+
+ $(event.currentTarget).append(this.$color_picker);
+ this.$color_picker.dropdown('toggle');
+ this.$color_picker.attr("tabindex", 1).focus();
+ }
+ },
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onUpdateColor: function (event) {
+ event.preventDefault();
+ var self = this;
+ var color = $(event.currentTarget).data('color');
+ var id = $(event.currentTarget).data('id');
+ var tag = self.$("span.badge[data-id='" + id + "']");
+ var current_color = tag.data('color');
+
+ if (color === current_color) { return; }
+
+ this.trigger_up('field_changed', {
+ dataPointID: _.findWhere(this.value.data, {res_id: id}).id,
+ changes: {
+ color: color,
+ },
+ force_save: true,
+ });
+ },
+});
+
+var FieldMany2ManyCheckBoxes = AbstractRelationalField.extend({
+ template: 'FieldMany2ManyCheckBoxes',
+ events: _.extend({}, AbstractRelationalField.prototype.events, {
+ change: '_onChange',
+ }),
+ supportedFieldTypes: ['many2many'],
+ init: function () {
+ this._super.apply(this, arguments);
+ this.m2mValues = this.field.__many2many_information;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ isSet: function () {
+ return true;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _render: function () {
+ var self = this;
+ this._super.apply(this, arguments);
+ _.each(this.value, function (id) {
+ self.$('input[data-record-id="' + id + '"]').prop('checked', true);
+ });
+ },
+ /**
+ * @private
+ */
+ _renderReadonly: function () {
+ this.$("input").prop("disabled", true);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onChange: function () {
+ var ids = _.map(this.$('input:checked'), function (input) {
+ return $(input).data("record-id");
+ });
+ this._setValue({
+ operation: 'REPLACE_WITH',
+ ids: ids,
+ });
+ },
+});
+
+//------------------------------------------------------------------------------
+// Widgets handling both basic and relational fields (selection and Many2one)
+//------------------------------------------------------------------------------
+
+var FieldStatus = AbstractField.extend({
+ template: "FieldStatus",
+ className: 'o_statusbar_status',
+ events: {
+ 'click .o_arrow_button': '_onClickStage',
+ },
+ supportedFieldTypes: ['selection', 'many2one'],
+ init: function () {
+ this._super.apply(this, arguments);
+ this._setState();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _reset: function () {
+ this._super.apply(this, arguments);
+ this._setState();
+ },
+ /**
+ * @private
+ */
+ _setState: function () {
+ if (this.field.type === 'many2one') {
+ this.status_information = this.field.__status_information;
+ var status = _.findWhere(this.status_information, {id: this.value.res_id});
+ if (status) {
+ status.selected = true;
+ }
+ } else {
+ var self = this;
+ this.status_information = _.map(this.field.selection, function (val) {
+ return { id: val[0], display_name: val[1], selected: val[0] === self.value };
+ });
+ }
+ },
+ /**
+ * @private
+ */
+ _render: function () {
+ var visible = this.attrs.statusbar_visible;
+ var selection_folded = [];
+ var selection_unfolded = [];
+ _.each(this.status_information, function (info) {
+ if (info.selected && (info.fold || visible && visible.indexOf(info.display_name) === -1)) {
+ selection_folded.push(info);
+ } else {
+ selection_unfolded.push(info);
+ }
+ });
+ var mobile = config.device.size_class <= config.device.SIZES.XS;
+ // grep: FieldStatus.content.mobile, FieldStatus.content.desktop
+ var content = qweb.render("FieldStatus.content." + (mobile ? 'mobile' : 'desktop'), {
+ selection_folded: selection_folded.reverse(),
+ selection_unfolded: selection_unfolded.reverse(),
+ clickable: !!this.attrs.clickable,
+ });
+ this.$el.html(content);
+ this.$('.disabled').prop('disabled', true);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Note that this function is debounced...
+ *
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onClickStage: _.debounce(function (event) {
+ var $stage = $(event.currentTarget);
+ var val;
+ if (this.field.type === "many2one") {
+ val = parseInt($stage.data("value"), 10);
+ } else {
+ val = $stage.data("value");
+ }
+ if (val) {
+ this._setValue(val);
+ }
+ }, 300, true),
+});
+
+/**
+ * The FieldSelection widget is a simple select tag with a dropdown menu to
+ * allow the selection of a range of values. It is designed to work with fields
+ * of type 'selection' and 'many2one'.
+ */
+var FieldSelection = AbstractField.extend({
+ template: 'FieldSelection',
+ supportedFieldTypes: ['selection', 'many2one'],
+ events: _.extend({}, AbstractField.prototype.events, {
+ 'input': '_onInput',
+ }),
+ replace_element: true,
+ /**
+ * @override
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ this.values = [];
+ if (this.field.type === 'many2one') {
+ this.values = this.field.__selection_information;
+ } else {
+ this.values = _.reject(this.field.selection, function (v) {
+ return v[0] === false && v[1] === '';
+ });
+ }
+ this.values = [[false, this.attrs.placeholder || '']].concat(this.values);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Format the value to a valid string
+ *
+ * @override
+ * @private
+ * @param {any} value
+ * @returns {string}
+ */
+ _formatValue: function (value) {
+ if (this.field.type === 'many2one') {
+ var options = _.extend({}, this.nodeOptions);
+ return field_utils.format_many2one(value, this.field, this.recordData, options);
+ } else {
+ return this._super(value);
+ }
+ },
+ /**
+ * @override
+ * @private
+ */
+ _renderEdit: function () {
+ this.$el.empty();
+ for (var i = 0 ; i < this.values.length ; i++) {
+ this.$el.append($('<option/>', {
+ value: JSON.stringify(this.values[i][0]),
+ html: this.values[i][1]
+ }));
+ }
+ this.$el.val(JSON.stringify(this._parseValue(this.value)));
+ },
+ /**
+ * @override
+ * @private
+ */
+ _renderReadonly: function () {
+ this.$el.empty().html(this._formatValue(this.value));
+ },
+ /**
+ * @override
+ * @private
+ */
+ _reset: function () {
+ this._super.apply(this, arguments);
+ if (this.field.type === 'many2one') {
+ this.value = this.value.data.id;
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * The small slight difficulty is that we have to set the value differently
+ * depending on the field type.
+ * @private
+ */
+ _onInput: function () {
+ var res_id = JSON.parse(this.$el.val());
+ if (this.field.type === 'many2one') {
+ var value = _.find(this.values, function (val) {
+ return val[0] === res_id;
+ });
+ this._setValue({id: res_id, display_name: value[1]});
+ } else {
+ this._setValue(res_id);
+ }
+ },
+});
+
+var FieldRadio = FieldSelection.extend({
+ template: 'FieldRadio',
+ supportedFieldTypes: ['selection', 'many2one'],
+ events: _.extend({}, AbstractField.prototype.events, {
+ 'click input': '_onInputClick',
+ }),
+ /**
+ * @constructs FieldRadio
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ if (this.field.type === 'selection') {
+ this.values = this.field.selection || [];
+ } else if (this.field.type === 'many2one') {
+ this.values = _.map(this.field.__status_information, function (val) {
+ return [val.id, val.display_name];
+ });
+ }
+ this.unique_id = _.uniqueId("radio");
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @returns {boolean} always true
+ */
+ isSet: function () {
+ return true;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @override
+ */
+ _renderEdit: function () {
+ this.$("input").prop("checked", false);
+ var key;
+ if (this.field.type === 'many2one') {
+ key = this._parseValue(this.value);
+ } else {
+ key = this.value;
+ }
+ this.$('input[data-value="' + key + '"]').prop('checked', true);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onInputClick: function (event) {
+ var res_id = $(event.target).data('value');
+ if (this.field.type === 'many2one') {
+ var value = _.find(this.values, function (val) {
+ return val[0] === res_id;
+ });
+ this._setValue({id: res_id, display_name: value[1]});
+ } else {
+ this._setValue(res_id);
+ }
+ },
+});
+
+return {
+ FieldMany2One: FieldMany2One,
+ KanbanFieldMany2One: KanbanFieldMany2One,
+ ListFieldMany2One: ListFieldMany2One,
+
+ FieldOne2Many: FieldOne2Many,
+
+ FieldMany2Many: FieldMany2Many,
+ FieldMany2ManyCheckBoxes: FieldMany2ManyCheckBoxes,
+ FieldMany2ManyTags: FieldMany2ManyTags,
+ FormFieldMany2ManyTags: FormFieldMany2ManyTags,
+
+ FieldRadio: FieldRadio,
+ FieldSelection: FieldSelection,
+ FieldStatus: FieldStatus,
+};
+
+});