summaryrefslogtreecommitdiff
path: root/addons/web/static/src/js/fields/relational_fields.js
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/web/static/src/js/fields/relational_fields.js
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/web/static/src/js/fields/relational_fields.js')
-rw-r--r--addons/web/static/src/js/fields/relational_fields.js3460
1 files changed, 3460 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 00000000..481ba16f
--- /dev/null
+++ b/addons/web/static/src/js/fields/relational_fields.js
@@ -0,0 +1,3460 @@
+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 basicFields = require('web.basic_fields');
+var concurrency = require('web.concurrency');
+const ControlPanelX2Many = require('web.ControlPanelX2Many');
+var core = require('web.core');
+var data = require('web.data');
+var Dialog = require('web.Dialog');
+var dialogs = require('web.view_dialogs');
+var dom = require('web.dom');
+const Domain = require('web.Domain');
+var KanbanRecord = require('web.KanbanRecord');
+var KanbanRenderer = require('web.KanbanRenderer');
+var ListRenderer = require('web.ListRenderer');
+const { ComponentWrapper, WidgetAdapterMixin } = require('web.OwlCompatibility');
+const { sprintf } = require("web.utils");
+
+const { escape } = owl.utils;
+var _t = core._t;
+var _lt = core._lt;
+var qweb = core.qweb;
+
+//------------------------------------------------------------------------------
+// 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("New %s"), this.name),
+ size: 'medium',
+ buttons: [{
+ text: _t('Create'),
+ classes: 'btn-primary',
+ close: true,
+ click: function () {
+ this.trigger_up('quick_create', { value: this.value });
+ },
+ }, {
+ text: _t('Create and edit'),
+ classes: 'btn-primary',
+ close: true,
+ click: function () {
+ this.trigger_up('search_create_popup', {
+ view_type: 'form',
+ value: this.value,
+ });
+ },
+ }, {
+ text: _t('Cancel'),
+ close: true,
+ }],
+ });
+ },
+ /**
+ * @override
+ * @param {boolean} isSet
+ */
+ close: function (isSet) {
+ this.isSet = isSet;
+ this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ if (!this.isSet) {
+ this.trigger_up('closed_unset');
+ }
+ this._super.apply(this, arguments);
+ },
+});
+
+var FieldMany2One = AbstractField.extend({
+ description: _lt("Many2one"),
+ supportedFieldTypes: ['many2one'],
+ template: 'FieldMany2One',
+ custom_events: _.extend({}, AbstractField.prototype.custom_events, {
+ 'closed_unset': '_onDialogClosedUnset',
+ 'field_changed': '_onFieldChanged',
+ 'quick_create': '_onQuickCreate',
+ 'search_create_popup': '_onSearchCreatePopup',
+ }),
+ events: _.extend({}, AbstractField.prototype.events, {
+ 'click input': '_onInputClick',
+ 'focusout input': '_onInputFocusout',
+ 'keyup input': '_onInputKeyup',
+ 'click .o_external_button': '_onExternalButtonClick',
+ 'click': '_onClick',
+ }),
+ AUTOCOMPLETE_DELAY: 200,
+ SEARCH_MORE_LIMIT: 320,
+
+ /**
+ * @override
+ * @param {boolean} [options.noOpen=false] if true, there is no external
+ * button to open the related record in a dialog
+ * @param {boolean} [options.noCreate=false] if true, the many2one does not
+ * allow to create records
+ */
+ init: function (parent, name, record, options) {
+ options = options || {};
+ this._super.apply(this, arguments);
+ this.limit = 7;
+ this.orderer = new concurrency.DropMisordered();
+
+ // should normally be set, except in standalone M20
+ const canCreate = 'can_create' in this.attrs ? JSON.parse(this.attrs.can_create) : true;
+ this.can_create = canCreate && !this.nodeOptions.no_create && !options.noCreate;
+ this.can_write = 'can_write' in this.attrs ? JSON.parse(this.attrs.can_write) : true;
+
+ this.nodeOptions = _.defaults(this.nodeOptions, {
+ quick_create: true,
+ });
+ this.noOpen = 'noOpen' in options ? options.noOpen : this.nodeOptions.no_open;
+ this.m2o_value = this._formatValue(this.value);
+ // 'recordParams' is a dict of params used when calling functions
+ // 'getDomain' and 'getContext' on this.record
+ this.recordParams = {fieldName: this.name, viewType: this.viewType};
+ // We need to know if the widget is dirty (i.e. if the user has changed
+ // the value, and those changes haven't been acknowledged yet by the
+ // environment), to prevent erasing that new value on a reset (e.g.
+ // coming by an onchange on another field)
+ this.isDirty = false;
+ this.lastChangeEvent = undefined;
+
+ // List of autocomplete sources
+ this._autocompleteSources = [];
+ // Add default search method for M20 (name_search)
+ this._addAutocompleteSource(this._search, {placeholder: _t('Loading...'), order: 1});
+
+ // use a DropPrevious to properly handle related record quick creations,
+ // and store a createDef to be able to notify the environment that there
+ // is pending quick create operation
+ this.dp = new concurrency.DropPrevious();
+ this.createDef = undefined;
+ },
+ start: function () {
+ // booleean indicating that the content of the input isn't synchronized
+ // with the current m2o value (for instance, the user is currently
+ // typing something in the input, and hasn't selected a value yet).
+ this.floating = false;
+
+ this.$input = this.$('input');
+ this.$external_button = this.$('.o_external_button');
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ if (this._onScroll) {
+ window.removeEventListener('scroll', this._onScroll, true);
+ }
+ this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Override to make the caller wait for potential ongoing record creation.
+ * This ensures that the correct many2one value is set when the main record
+ * is saved.
+ *
+ * @override
+ * @returns {Promise} resolved as soon as there is no longer record being
+ * (quick) created
+ */
+ commitChanges: function () {
+ return Promise.resolve(this.createDef);
+ },
+ /**
+ * @override
+ * @returns {jQuery}
+ */
+ getFocusableElement: function () {
+ return this.mode === 'edit' && this.$input || this.$el;
+ },
+ /**
+ * TODO
+ */
+ reinitialize: function (value) {
+ this.isDirty = false;
+ this.floating = false;
+ return this._setValue(value);
+ },
+ /**
+ * Re-renders the widget if it isn't dirty. The widget is dirty if the user
+ * changed the value, and that change hasn't been acknowledged yet by the
+ * environment. For example, another field with an onchange has been updated
+ * and this field is updated before the onchange returns. Two '_setValue'
+ * are done (this is sequential), the first one returns and this widget is
+ * reset. However, it has pending changes, so we don't re-render.
+ *
+ * @override
+ */
+ reset: function (record, event) {
+ this._reset(record, event);
+ if (!event || event === this.lastChangeEvent) {
+ this.isDirty = false;
+ }
+ if (this.isDirty) {
+ return Promise.resolve();
+ } else {
+ return this._render();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Add a source to the autocomplete results
+ *
+ * @param {function} method : A function that returns a list of results. If async source, the function should return a promise
+ * @param {Object} params : Parameters containing placeholder/validation/order
+ * @private
+ */
+ _addAutocompleteSource: function (method, params) {
+ this._autocompleteSources.push({
+ method: method,
+ placeholder: (params.placeholder ? _t(params.placeholder) : _t('Loading...')) + '<i class="fa fa-spinner fa-spin pull-right"></i>' ,
+ validation: params.validation,
+ loading: false,
+ order: params.order || 999
+ });
+
+ this._autocompleteSources = _.sortBy(this._autocompleteSources, 'order');
+ },
+ /**
+ * @private
+ */
+ _bindAutoComplete: function () {
+ var self = this;
+ // avoid ignoring autocomplete="off" by obfuscating placeholder, see #30439
+ if ($.browser.chrome && this.$input.attr('placeholder')) {
+ this.$input.attr('placeholder', function (index, val) {
+ return val.split('').join('\ufeff');
+ });
+ }
+ this.$input.autocomplete({
+ source: function (req, resp) {
+ _.each(self._autocompleteSources, function (source) {
+ // Resets the results for this source
+ source.results = [];
+
+ // Check if this source should be used for the searched term
+ const search = req.term.trim();
+ if (!source.validation || source.validation.call(self, search)) {
+ source.loading = true;
+
+ // Wrap the returned value of the source.method with a promise
+ // So event if the returned value is not async, it will work
+ Promise.resolve(source.method.call(self, search)).then(function (results) {
+ source.results = results;
+ source.loading = false;
+ resp(self._concatenateAutocompleteResults());
+ });
+ }
+ });
+ },
+ select: function (event, ui) {
+ // we do not want the select event to trigger any additional
+ // effect, such as navigating to another field.
+ event.stopImmediatePropagation();
+ event.preventDefault();
+
+ 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
+ },
+ open: function (event) {
+ self._onScroll = function (ev) {
+ if (ev.target !== self.$input.get(0) && self.$input.hasClass('ui-autocomplete-input')) {
+ self.$input.autocomplete('close');
+ }
+ };
+ window.addEventListener('scroll', self._onScroll, true);
+ },
+ close: function (event) {
+ // it is necessary to prevent ESC key from propagating to field
+ // root, to prevent unwanted discard operations.
+ if (event.which === $.ui.keyCode.ESCAPE) {
+ event.stopPropagation();
+ }
+ if (self._onScroll) {
+ window.removeEventListener('scroll', self._onScroll, true);
+ }
+ },
+ autoFocus: true,
+ html: true,
+ minLength: 0,
+ delay: this.AUTOCOMPLETE_DELAY,
+ });
+ this.$input.autocomplete("option", "position", { my : "left top", at: "left bottom" });
+ this.autocomplete_bound = true;
+ },
+ /**
+ * Concatenate async results for autocomplete.
+ *
+ * @returns {Array}
+ * @private
+ */
+ _concatenateAutocompleteResults: function () {
+ var results = [];
+ _.each(this._autocompleteSources, function (source) {
+ if (source.results && source.results.length) {
+ results = results.concat(source.results);
+ } else if (source.loading) {
+ results.push({
+ label: source.placeholder
+ });
+ }
+ });
+ return results;
+ },
+ /**
+ * @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
+ * @returns {Array}
+ */
+ _getSearchBlacklist: function () {
+ return [];
+ },
+ /**
+ * Returns the display_name from a string which contains it but was altered
+ * as a result of the show_address option using a horrible hack.
+ *
+ * @private
+ * @param {string} value
+ * @returns {string} display_name without show_address mess
+ */
+ _getDisplayName: function (value) {
+ return value.split('\n')[0];
+ },
+ /**
+ * Prepares and returns options for SelectCreateDialog
+ *
+ * @private
+ */
+ _getSearchCreatePopupOptions: function(view, ids, context, dynamicFilters) {
+ var self = this;
+ return {
+ res_model: this.field.relation,
+ domain: this.record.getDomain({fieldName: this.name}),
+ context: _.extend({}, this.record.getContext(this.recordParams), context || {}),
+ _createContext: this._createContext.bind(this),
+ dynamicFilters: dynamicFilters || [],
+ title: (view === 'search' ? _t("Search: ") : _t("Create: ")) + this.string,
+ initial_ids: ids,
+ initial_view: view,
+ disable_multiple_selection: true,
+ no_create: !self.can_create,
+ kanban_view_ref: this.attrs.kanban_view_ref,
+ on_selected: function (records) {
+ self.reinitialize(records[0]);
+ },
+ on_closed: function () {
+ self.activate();
+ },
+ };
+ },
+ /**
+ * @private
+ * @param {Object} values
+ * @param {string} search_val
+ * @param {Object} domain
+ * @param {Object} context
+ * @returns {Object}
+ */
+ _manageSearchMore: function (values, search_val, domain, context) {
+ var self = this;
+ values = values.slice(0, this.limit);
+ values.push({
+ label: _t("Search More..."),
+ action: function () {
+ var prom;
+ if (search_val !== '') {
+ prom = self._rpc({
+ model: self.field.relation,
+ method: 'name_search',
+ kwargs: {
+ name: search_val,
+ args: domain,
+ operator: "ilike",
+ limit: self.SEARCH_MORE_LIMIT,
+ context: context,
+ },
+ });
+ }
+ Promise.resolve(prom).then(function (results) {
+ var dynamicFilters;
+ if (results) {
+ var ids = _.map(results, function (x) {
+ return x[0];
+ });
+ dynamicFilters = [{
+ description: _.str.sprintf(_t('Quick search: %s'), search_val),
+ domain: [['id', 'in', ids]],
+ }];
+ }
+ self._searchCreatePopup("search", false, {}, dynamicFilters);
+ });
+ },
+ classname: 'o_m2o_dropdown_option',
+ });
+ return values;
+ },
+ /**
+ * Listens to events 'field_changed' to keep track of the last event that
+ * has been trigerred. This allows to detect that all changes have been
+ * acknowledged by the environment.
+ *
+ * @param {OdooEvent} event 'field_changed' event
+ */
+ _onFieldChanged: function (event) {
+ this.lastChangeEvent = event;
+ },
+ /**
+ * @private
+ * @param {string} name
+ * @returns {Promise} resolved after the name_create or when the slowcreate
+ * modal is closed.
+ */
+ _quickCreate: function (name) {
+ var self = this;
+ var createDone;
+
+ var def = new Promise(function (resolve, reject) {
+ self.createDef = new Promise(function (innerResolve) {
+ // called when the record has been quick created, or when the dialog has
+ // been closed (in the case of a 'slow' create), meaning that the job is
+ // done
+ createDone = function () {
+ innerResolve();
+ resolve();
+ self.createDef = undefined;
+ };
+ });
+
+ // called if the quick create is disabled on this many2one, or if the
+ // quick creation failed (probably because there are mandatory fields on
+ // the model)
+ var slowCreate = function () {
+ var dialog = self._searchCreatePopup("form", false, self._createContext(name));
+ dialog.on('closed', self, createDone);
+ };
+ if (self.nodeOptions.quick_create) {
+ const prom = self.reinitialize({id: false, display_name: name});
+ prom.guardedCatch(reason => {
+ reason.event.preventDefault();
+ slowCreate();
+ });
+ self.dp.add(prom).then(createDone).guardedCatch(reject);
+ } else {
+ slowCreate();
+ }
+ });
+
+ return def;
+ },
+ /**
+ * @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 partner, 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 no one tries to uses this mechanism to do
+ // something else.
+ if (this.nodeOptions.always_reload) {
+ value = this._getDisplayName(value);
+ }
+ this.$input.val(value);
+ if (!this.autocomplete_bound) {
+ this._bindAutoComplete();
+ }
+ this._updateExternalButton();
+ },
+ /**
+ * @private
+ */
+ _renderReadonly: function () {
+ var escapedValue = _.escape((this.m2o_value || "").trim());
+ var value = escapedValue.split('\n').map(function (line) {
+ return '<span>' + line + '</span>';
+ }).join('<br/>');
+ this.$el.html(value);
+ if (!this.noOpen && this.value) {
+ this.$el.attr('href', _.str.sprintf('#id=%s&model=%s', this.value.res_id, this.field.relation));
+ this.$el.addClass('o_form_uri');
+ }
+ },
+ /**
+ * @private
+ */
+ _reset: function () {
+ this._super.apply(this, arguments);
+ this.floating = false;
+ this.m2o_value = this._formatValue(this.value);
+ },
+ /**
+ * Executes a 'name_search' and returns a list of formatted objects meant to
+ * be displayed in the autocomplete widget dropdown. These items are either:
+ * - a formatted version of a 'name_search' result
+ * - an option meant to display additional information or perform an action
+ *
+ * @private
+ * @param {string} [searchValue=""]
+ * @returns {Promise<{
+ * label: string,
+ * id?: number,
+ * name?: string,
+ * value?: string,
+ * classname?: string,
+ * action?: () => Promise<any>,
+ * }[]>}
+ */
+ _search: async function (searchValue = "") {
+ const value = searchValue.trim();
+ const domain = this.record.getDomain(this.recordParams);
+ const context = Object.assign(
+ this.record.getContext(this.recordParams),
+ this.additionalContext
+ );
+
+ // Exclude black-listed ids from the domain
+ const blackListedIds = this._getSearchBlacklist();
+ if (blackListedIds.length) {
+ domain.push(['id', 'not in', blackListedIds]);
+ }
+
+ const nameSearch = this._rpc({
+ model: this.field.relation,
+ method: "name_search",
+ kwargs: {
+ name: value,
+ args: domain,
+ operator: "ilike",
+ limit: this.limit + 1,
+ context,
+ }
+ });
+ const results = await this.orderer.add(nameSearch);
+
+ // Format results to fit the options dropdown
+ let values = results.map((result) => {
+ const [id, fullName] = result;
+ const displayName = this._getDisplayName(fullName).trim();
+ result[1] = displayName;
+ return {
+ id,
+ label: escape(displayName) || data.noDisplayContent,
+ value: displayName,
+ name: displayName,
+ };
+ });
+
+ // Add "Search more..." option if results count is higher than the limit
+ if (this.limit < values.length) {
+ values = this._manageSearchMore(values, value, domain, context);
+ }
+ if (!this.can_create) {
+ return values;
+ }
+
+ // Additional options...
+ const canQuickCreate = !this.nodeOptions.no_quick_create;
+ const canCreateEdit = !this.nodeOptions.no_create_edit;
+ if (value.length) {
+ // "Quick create" option
+ const nameExists = results.some((result) => result[1] === value);
+ if (canQuickCreate && !nameExists) {
+ values.push({
+ label: sprintf(
+ _t(`Create "<strong>%s</strong>"`),
+ escape(value)
+ ),
+ action: () => this._quickCreate(value),
+ classname: 'o_m2o_dropdown_option'
+ });
+ }
+ // "Create and Edit" option
+ if (canCreateEdit) {
+ const valueContext = this._createContext(value);
+ values.push({
+ label: _t("Create and Edit..."),
+ action: () => {
+ // Input value is cleared and the form popup opens
+ this.el.querySelector(':scope input').value = "";
+ return this._searchCreatePopup('form', false, valueContext);
+ },
+ classname: 'o_m2o_dropdown_option',
+ });
+ }
+ // "No results" option
+ if (!values.length) {
+ values.push({
+ label: _t("No results to show..."),
+ });
+ }
+ } else if (!this.value && (canQuickCreate || canCreateEdit)) {
+ // "Start typing" option
+ values.push({
+ label: _t("Start typing..."),
+ classname: 'o_m2o_start_typing',
+ });
+ }
+
+ return values;
+ },
+ /**
+ * all search/create popup handling
+ *
+ * TODO: ids argument is no longer used, remove it in master (as well as
+ * initial_ids param of the dialog)
+ *
+ * @private
+ * @param {any} view
+ * @param {any} ids
+ * @param {any} context
+ * @param {Object[]} [dynamicFilters=[]] filters to add to the search view
+ * in the dialog (each filter has keys 'description' and 'domain')
+ */
+ _searchCreatePopup: function (view, ids, context, dynamicFilters) {
+ var options = this._getSearchCreatePopupOptions(view, ids, context, dynamicFilters);
+ return new dialogs.SelectCreateDialog(this, _.extend({}, this.nodeOptions, options)).open();
+ },
+ /**
+ * @private
+ */
+ _updateExternalButton: function () {
+ var has_external_button = !this.noOpen && !this.floating && this.isSet();
+ this.$external_button.toggle(has_external_button);
+ this.$el.toggleClass('o_with_button', has_external_button); // Should not be required anymore but kept for compatibility
+ },
+
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onClick: function (event) {
+ var self = this;
+ if (this.mode === 'readonly' && !this.noOpen) {
+ event.preventDefault();
+ event.stopPropagation();
+ this._rpc({
+ model: this.field.relation,
+ method: 'get_formview_action',
+ args: [[this.value.res_id]],
+ context: this.record.getContext(this.recordParams),
+ })
+ .then(function (action) {
+ self.trigger_up('do_action', {action: action});
+ });
+ }
+ },
+
+ /**
+ * Reset the input as dialog has been closed without m2o creation.
+ *
+ * @private
+ */
+ _onDialogClosedUnset: function () {
+ this.isDirty = false;
+ this.floating = false;
+ this._render();
+ },
+ /**
+ * @private
+ */
+ _onExternalButtonClick: function () {
+ if (!this.value) {
+ this.activate();
+ return;
+ }
+ var self = this;
+ var context = this.record.getContext(this.recordParams);
+ this._rpc({
+ model: this.field.relation,
+ method: 'get_formview_id',
+ args: [[this.value.res_id]],
+ context: context,
+ })
+ .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 (record, changed) {
+ if (changed) {
+ const _setValue = self._setValue.bind(self, self.value.data, {
+ forceChange: true,
+ });
+ self.trigger_up('reload', {
+ db_id: self.value.id,
+ onSuccess: _setValue,
+ onFailure: _setValue,
+ });
+ }
+ },
+ }).open();
+ });
+ },
+ /**
+ * @private
+ */
+ _onInputClick: function () {
+ if (this.$input.autocomplete("widget").is(":visible")) {
+ this.$input.autocomplete("close");
+ } else if (this.floating) {
+ this.$input.autocomplete("search"); // search with the input's content
+ } else {
+ this.$input.autocomplete("search", ''); // search with the empty string
+ }
+ },
+ /**
+ * @private
+ */
+ _onInputFocusout: function () {
+ if (this.can_create && this.floating) {
+ new M2ODialog(this, this.string, this.$input.val()).open();
+ }
+ },
+ /**
+ * @private
+ *
+ * @param {OdooEvent} ev
+ */
+ _onInputKeyup: function (ev) {
+ if (ev.which === $.ui.keyCode.ENTER || ev.which === $.ui.keyCode.TAB) {
+ // If we pressed enter or tab, we want to prevent _onInputFocusout from
+ // executing since it would open a M2O dialog to request
+ // confirmation that the many2one is not properly set.
+ // It's a case that is already handled by the autocomplete lib.
+ return;
+ }
+ this.isDirty = true;
+ if (this.$input.val() === "") {
+ this.reinitialize(false);
+ } else if (this._getDisplayName(this.m2o_value) !== this.$input.val()) {
+ this.floating = true;
+ this._updateExternalButton();
+ }
+ },
+ /**
+ * @override
+ * @private
+ */
+ _onKeydown: function () {
+ this.floating = false;
+ this._super.apply(this, arguments);
+ },
+ /**
+ * Stops the left/right navigation move event if the cursor is not at the
+ * start/end of the input element. Stops any navigation move event if the
+ * user is selecting text.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onNavigationMove: function (ev) {
+ // TODO Maybe this should be done in a mixin or, better, the m2o field
+ // should be an InputField (but this requires some refactoring).
+ basicFields.InputField.prototype._onNavigationMove.apply(this, arguments);
+ if (this.mode === 'edit' && $(this.$input.autocomplete('widget')).is(':visible')) {
+ ev.stopPropagation();
+ }
+ },
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onQuickCreate: function (event) {
+ this._quickCreate(event.data.value);
+ },
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onSearchCreatePopup: function (event) {
+ var data = event.data;
+ this._searchCreatePopup(data.view_type, false, this._createContext(data.value));
+ },
+});
+
+var Many2oneBarcode = FieldMany2One.extend({
+ // We don't require this widget to be displayed in studio sidebar in
+ // non-debug mode hence just extended it from its original widget, so that
+ // description comes from parent and hasOwnProperty based condition fails
+});
+
+var ListFieldMany2One = FieldMany2One.extend({
+ events: _.extend({}, FieldMany2One.prototype.events, {
+ 'focusin input': '_onInputFocusin',
+ }),
+
+ /**
+ * Should never be allowed to be opened while in readonly mode in a list
+ *
+ * @override
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ // when we empty the input, we delay the setValue to prevent from
+ // triggering the 'fieldChanged' event twice when the user wants set
+ // another m2o value ; the following attribute is used to determine when
+ // we skipped the setValue, s.t. we can perform it later on if the user
+ // didn't select another value
+ this.mustSetValue = false;
+ this.m2oDialogFocused = false;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * If in readonly, will never be considered as an active widget.
+ *
+ * @override
+ */
+ activate: function () {
+ if (this.mode === 'readonly') {
+ return false;
+ }
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ reinitialize: function () {
+ this.mustSetValue = false;
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _renderReadonly: function () {
+ this.$el.text(this.m2o_value);
+ },
+ /**
+ * @override
+ * @private
+ */
+ _searchCreatePopup: function () {
+ this.m2oDialogFocused = true;
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onInputFocusin: function () {
+ this.m2oDialogFocused = false;
+ },
+ /**
+ * In case the focus is lost from a mousedown, we want to prevent the click occuring on the
+ * following mouseup since it might trigger some unwanted list functions.
+ * If it's not the case, we want to remove the added handler on the next mousedown.
+ * @see list_editable_renderer._onWindowClicked()
+ *
+ * Also, in list views, we don't want to try to trigger a fieldChange when the field
+ * is being emptied. Instead, it will be triggered as the user leaves the field
+ * while it is empty.
+ *
+ * @override
+ * @private
+ */
+ _onInputFocusout: function () {
+ if (this.can_create && this.floating) {
+ // In case the focus out is due to a mousedown, we want to prevent the next click
+ var attachedEvents = ['click', 'mousedown'];
+ var stopNextClick = (function (ev) {
+ ev.stopPropagation();
+ attachedEvents.forEach(function (eventName) {
+ window.removeEventListener(eventName, stopNextClick, true);
+ });
+ }).bind(this);
+ attachedEvents.forEach(function (eventName) {
+ window.addEventListener(eventName, stopNextClick, true);
+ });
+ }
+ this._super.apply(this, arguments);
+ if (!this.m2oDialogFocused && this.$input.val() === "" && this.mustSetValue) {
+ this.reinitialize(false);
+ }
+ },
+ /**
+ * Prevents the triggering of an immediate _onFieldChanged when emptying the field.
+ *
+ * @override
+ * @private
+ */
+ _onInputKeyup: function () {
+ if (this.$input.val() !== "") {
+ this._super.apply(this, arguments);
+ } else {
+ this.mustSetValue = true;
+ }
+ },
+});
+
+var KanbanFieldMany2One = AbstractField.extend({
+ tagName: 'span',
+ init: function () {
+ this._super.apply(this, arguments);
+ this.m2o_value = this._formatValue(this.value);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _render: function () {
+ this.$el.text(this.m2o_value);
+ },
+});
+
+/**
+ * Widget Many2OneAvatar is only supported on many2one fields pointing to a
+ * model which inherits from 'image.mixin'. In readonly, it displays the
+ * record's image next to the display_name. In edit, it behaves exactly like a
+ * regular many2one widget.
+ */
+const Many2OneAvatar = FieldMany2One.extend({
+ _template: 'web.Many2OneAvatar',
+
+ init() {
+ this._super.apply(this, arguments);
+ if (this.mode === 'readonly') {
+ this.template = null;
+ this.tagName = 'div';
+ this.className = 'o_field_many2one_avatar';
+ // disable the redirection to the related record on click, in readonly
+ this.noOpen = true;
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _renderReadonly() {
+ this.$el.empty();
+ if (this.value) {
+ this.$el.html(qweb.render(this._template, {
+ url: `/web/image/${this.field.relation}/${this.value.res_id}/image_128`,
+ value: this.m2o_value,
+ }));
+ }
+ },
+});
+
+//------------------------------------------------------------------------------
+// X2Many widgets
+//------------------------------------------------------------------------------
+
+var FieldX2Many = AbstractField.extend(WidgetAdapterMixin, {
+ tagName: 'div',
+ custom_events: _.extend({}, AbstractField.prototype.custom_events, {
+ add_record: '_onAddRecord',
+ discard_changes: '_onDiscardChanges',
+ edit_line: '_onEditLine',
+ field_changed: '_onFieldChanged',
+ open_record: '_onOpenRecord',
+ kanban_record_delete: '_onRemoveRecord',
+ list_record_remove: '_onRemoveRecord',
+ resequence_records: '_onResequenceRecords',
+ save_line: '_onSaveLine',
+ toggle_column_order: '_onToggleColumnOrder',
+ activate_next_widget: '_onActiveNextWidget',
+ navigation_move: '_onNavigationMove',
+ save_optional_fields: '_onSaveOrLoadOptionalFields',
+ load_optional_fields: '_onSaveOrLoadOptionalFields',
+ pager_changed: '_onPagerChanged',
+ }),
+
+ // We need to trigger the reset on every changes to be aware of the parent changes
+ // and then evaluate the 'column_invisible' modifier in case a evaluated value
+ // changed.
+ resetOnAnyFieldChange: true,
+
+ /**
+ * useSubview is used in form view to load view of the related model of the x2many field
+ */
+ useSubview: true,
+
+ /**
+ * @override
+ */
+ init: function (parent, name, record, options) {
+ this._super.apply(this, arguments);
+ this.nodeOptions = _.defaults(this.nodeOptions, {
+ create_text: _t('Add'),
+ });
+ this.operations = [];
+ this.isReadonly = this.mode === 'readonly';
+ this.view = this.attrs.views[this.attrs.mode];
+ this.isMany2Many = this.field.type === 'many2many' || this.attrs.widget === 'many2many';
+ this.activeActions = {};
+ this.recordParams = {fieldName: this.name, viewType: this.viewType};
+ // The limit is fixed so it cannot be changed by adding/removing lines in
+ // the widget. It will only change through a hard reload or when manually
+ // changing the pager (see _onPagerChanged).
+ this.pagingState = {
+ currentMinimum: this.value.offset + 1,
+ limit: this.value.limit,
+ size: this.value.count,
+ validate: () => {
+ // TODO: we should have some common method in the basic renderer...
+ return this.view.arch.tag === 'tree' ?
+ this.renderer.unselectRow() :
+ Promise.resolve();
+ },
+ withAccessKey: false,
+ };
+ var arch = this.view && this.view.arch;
+ if (arch) {
+ this.activeActions.create = arch.attrs.create ?
+ !!JSON.parse(arch.attrs.create) :
+ true;
+ this.activeActions.delete = arch.attrs.delete ?
+ !!JSON.parse(arch.attrs.delete) :
+ true;
+ this.editable = arch.attrs.editable;
+ }
+ this._computeAvailableActions(record);
+ if (this.attrs.columnInvisibleFields) {
+ this._processColumnInvisibleFields();
+ }
+ },
+ /**
+ * @override
+ */
+ start: async function () {
+ const _super = this._super.bind(this);
+ if (this.view) {
+ this._renderButtons();
+ this._controlPanelWrapper = new ComponentWrapper(this, ControlPanelX2Many, {
+ cp_content: { $buttons: this.$buttons },
+ pager: this.pagingState,
+ });
+ await this._controlPanelWrapper.mount(this.el, { position: 'first-child' });
+ }
+ return _super(...arguments);
+ },
+ destroy: function () {
+ WidgetAdapterMixin.destroy.call(this);
+ this._super();
+ },
+ /**
+ * For the list renderer to properly work, it must know if it is in the DOM,
+ * and be notified when it is attached to the DOM.
+ */
+ on_attach_callback: function () {
+ this.isInDOM = true;
+ WidgetAdapterMixin.on_attach_callback.call(this);
+ if (this.renderer) {
+ this.renderer.on_attach_callback();
+ }
+ },
+ /**
+ * For the list renderer to properly work, it must know if it is in the DOM.
+ */
+ on_detach_callback: function () {
+ this.isInDOM = false;
+ WidgetAdapterMixin.on_detach_callback.call(this);
+ if (this.renderer) {
+ this.renderer.on_detach_callback();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * A x2m field can only be saved if it finished the edition of all its rows.
+ * On parent view saving, we have to ask the x2m fields to commit their
+ * changes, that is confirming the save of the in-edition row or asking the
+ * user if he wants to discard it if necessary.
+ *
+ * @override
+ * @returns {Promise}
+ */
+ commitChanges: function () {
+ var self = this;
+ var inEditionRecordID =
+ this.renderer &&
+ this.renderer.viewType === "list" &&
+ this.renderer.getEditableRecordID();
+ if (inEditionRecordID) {
+ return this.renderer.commitChanges(inEditionRecordID).then(function () {
+ return self._saveLine(inEditionRecordID);
+ });
+ }
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ isSet: function () {
+ return true;
+ },
+ /**
+ * @override
+ * @param {Object} record
+ * @param {OdooEvent} [ev] an event that triggered the reset action
+ * @param {Boolean} [fieldChanged] if true, the widget field has changed
+ * @returns {Promise}
+ */
+ reset: function (record, ev, fieldChanged) {
+ // re-evaluate available actions
+ const oldCanCreate = this.canCreate;
+ const oldCanDelete = this.canDelete;
+ const oldCanLink = this.canLink;
+ const oldCanUnlink = this.canUnlink;
+ this._computeAvailableActions(record);
+ const actionsChanged =
+ this.canCreate !== oldCanCreate ||
+ this.canDelete !== oldCanDelete ||
+ this.canLink !== oldCanLink ||
+ this.canUnlink !== oldCanUnlink;
+
+ // If 'fieldChanged' is false, it means that the reset was triggered by
+ // the 'resetOnAnyFieldChange' mechanism. If it is the case, if neither
+ // the modifiers (so the visible columns) nor the available actions
+ // changed, the reset is skipped.
+ if (!fieldChanged && !actionsChanged) {
+ var newEval = this._evalColumnInvisibleFields();
+ if (_.isEqual(this.currentColInvisibleFields, newEval)) {
+ this._reset(record, ev); // update the internal state, but do not re-render
+ return Promise.resolve();
+ }
+ } else if (ev && ev.target === this && ev.data.changes && this.view.arch.tag === 'tree') {
+ var command = ev.data.changes[this.name];
+ // Here, we only consider 'UPDATE' commands with data, which occur
+ // with editable list view. In order to keep the current line in
+ // edition, we call confirmUpdate which will try to reset the widgets
+ // of the line being edited, and rerender the rest of the list.
+ // 'UPDATE' commands with no data can be ignored: they occur in
+ // one2manys when the record is updated from a dialog and in this
+ // case, we can re-render the whole subview.
+ if (command && command.operation === 'UPDATE' && command.data) {
+ var state = record.data[this.name];
+ var fieldNames = state.getFieldNames({ viewType: 'list' });
+ this._reset(record, ev);
+ return this.renderer.confirmUpdate(state, command.id, fieldNames, ev.initialEvent);
+ }
+ }
+ return this._super.apply(this, arguments);
+ },
+
+ /**
+ * @override
+ * @returns {jQuery}
+ */
+ getFocusableElement: function () {
+ return (this.mode === 'edit' && this.$input) || this.$el;
+ },
+
+ /**
+ * @override
+ * @param {Object|undefined} [options={}]
+ * @param {boolean} [options.noAutomaticCreate=false]
+ */
+ activate: function (options) {
+ if (!this.activeActions.create || this.isReadonly || !this.$el.is(":visible")) {
+ return false;
+ }
+ if (this.view.type === 'kanban') {
+ this.$buttons.find(".o-kanban-button-new").focus();
+ }
+ if (this.view.arch.tag === 'tree') {
+ if (options && options.noAutomaticCreate) {
+ this.renderer.$('.o_field_x2many_list_row_add a:first').focus();
+ } else {
+ this.renderer.$('.o_field_x2many_list_row_add a:first').click();
+ }
+ }
+ return true;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Object} record
+ */
+ _computeAvailableActions: function (record) {
+ const evalContext = record.evalContext;
+ this.canCreate = 'create' in this.nodeOptions ?
+ new Domain(this.nodeOptions.create, evalContext).compute(evalContext) :
+ true;
+ this.canDelete = 'delete' in this.nodeOptions ?
+ new Domain(this.nodeOptions.delete, evalContext).compute(evalContext) :
+ true;
+ this.canLink = 'link' in this.nodeOptions ?
+ new Domain(this.nodeOptions.link, evalContext).compute(evalContext) :
+ true;
+ this.canUnlink = 'unlink' in this.nodeOptions ?
+ new Domain(this.nodeOptions.unlink, evalContext).compute(evalContext) :
+ true;
+ },
+ /**
+ * Evaluates the 'column_invisible' modifier for the parent record.
+ *
+ * @return {Object} Object containing fieldName as key and the evaluated
+ * column_invisible modifier
+ */
+ _evalColumnInvisibleFields: function () {
+ var self = this;
+ return _.mapObject(this.columnInvisibleFields, function (domains) {
+ return self.record.evalModifiers({
+ column_invisible: domains,
+ }).column_invisible;
+ });
+ },
+ /**
+ * Returns qweb context to render buttons.
+ *
+ * @private
+ * @returns {Object}
+ */
+ _getButtonsRenderingContext() {
+ return {
+ btnClass: 'btn-secondary',
+ create_text: this.nodeOptions.create_text,
+ };
+ },
+ /**
+ * Computes the default renderer to use depending on the view type.
+ * We create this as a method so we can override it if we want to use
+ * another renderer instead (eg. section_and_note_one2many).
+ *
+ * @private
+ * @returns {Object} The renderer to use
+ */
+ _getRenderer: function () {
+ if (this.view.arch.tag === 'tree') {
+ return ListRenderer;
+ }
+ if (this.view.arch.tag === 'kanban') {
+ return KanbanRenderer;
+ }
+ },
+ /**
+ * @private
+ * @returns {boolean} true iff the list should contain a 'create' line.
+ */
+ _hasCreateLine: function () {
+ return !this.isReadonly && (
+ (!this.isMany2Many && this.activeActions.create && this.canCreate) ||
+ (this.isMany2Many && this.canLink)
+ );
+ },
+ /**
+ * @private
+ * @returns {boolean} true iff the list should add a trash icon on each row.
+ */
+ _hasTrashIcon: function () {
+ return !this.isReadonly && (
+ (!this.isMany2Many && this.activeActions.delete && this.canDelete) ||
+ (this.isMany2Many && this.canUnlink)
+ );
+ },
+ /**
+ * Instanciates or updates the adequate renderer.
+ *
+ * @override
+ * @private
+ * @returns {Promise|undefined}
+ */
+ _render: function () {
+ var self = this;
+ if (!this.view) {
+ return this._super();
+ }
+
+ if (this.renderer) {
+ this.currentColInvisibleFields = this._evalColumnInvisibleFields();
+ return this.renderer.updateState(this.value, {
+ addCreateLine: this._hasCreateLine(),
+ addTrashIcon: this._hasTrashIcon(),
+ columnInvisibleFields: this.currentColInvisibleFields,
+ keepWidths: true,
+ }).then(() => {
+ return this._updateControlPanel({ size: this.value.count });
+ });
+ }
+ var arch = this.view.arch;
+ var viewType;
+ var rendererParams = {
+ arch: arch,
+ };
+
+ if (arch.tag === 'tree') {
+ viewType = 'list';
+ this.currentColInvisibleFields = this._evalColumnInvisibleFields();
+ _.extend(rendererParams, {
+ editable: this.mode === 'edit' && arch.attrs.editable,
+ addCreateLine: this._hasCreateLine(),
+ addTrashIcon: this._hasTrashIcon(),
+ isMany2Many: this.isMany2Many,
+ columnInvisibleFields: this.currentColInvisibleFields,
+ });
+ }
+
+ if (arch.tag === 'kanban') {
+ viewType = 'kanban';
+ var record_options = {
+ editable: false,
+ deletable: false,
+ read_only_mode: this.isReadonly,
+ };
+ _.extend(rendererParams, {
+ record_options: record_options,
+ readOnlyMode: this.isReadonly,
+ });
+ }
+
+ _.extend(rendererParams, {
+ viewType: viewType,
+ });
+ var Renderer = this._getRenderer();
+ this.renderer = new Renderer(this, this.value, rendererParams);
+
+ this.$el.addClass('o_field_x2many o_field_x2many_' + viewType);
+ if (this.renderer) {
+ return this.renderer.appendTo(document.createDocumentFragment()).then(function () {
+ dom.append(self.$el, self.renderer.$el, {
+ in_DOM: self.isInDOM,
+ callbacks: [{widget: self.renderer}],
+ });
+ });
+ } else {
+ return this._super();
+ }
+ },
+ /**
+ * Renders the buttons and sets this.$buttons.
+ *
+ * @private
+ */
+ _renderButtons: function () {
+ if (!this.isReadonly && this.view.arch.tag === 'kanban') {
+ const renderingContext = this._getButtonsRenderingContext();
+ this.$buttons = $(qweb.render('KanbanView.buttons', renderingContext));
+ this.$buttons.on('click', 'button.o-kanban-button-new', this._onAddRecord.bind(this));
+ }
+ },
+ /**
+ * Saves the line associated to the given recordID. If the line is valid,
+ * it only has to be switched to readonly mode as all the line changes have
+ * already been notified to the model so that they can be saved in db if the
+ * parent view is actually saved. If the line is not valid, the line is to
+ * be discarded if the user agrees (this behavior is not a list editable
+ * one but a x2m one as it is made to replace the "discard" button which
+ * exists for list editable views).
+ *
+ * @private
+ * @param {string} recordID
+ * @returns {Promise} resolved if the line was properly saved or discarded.
+ * rejected if the line could not be saved and the user
+ * did not agree to discard.
+ */
+ _saveLine: function (recordID) {
+ var self = this;
+ return new Promise(function (resolve, reject) {
+ var fieldNames = self.renderer.canBeSaved(recordID);
+ if (fieldNames.length) {
+ self.trigger_up('discard_changes', {
+ recordID: recordID,
+ onSuccess: resolve,
+ onFailure: reject,
+ });
+ } else {
+ self.renderer.setRowMode(recordID, 'readonly').then(resolve);
+ }
+ }).then(async function () {
+ self._updateControlPanel({ size: self.value.count });
+ var newEval = self._evalColumnInvisibleFields();
+ if (!_.isEqual(self.currentColInvisibleFields, newEval)) {
+ self.currentColInvisibleFields = newEval;
+ self.renderer.updateState(self.value, {
+ columnInvisibleFields: self.currentColInvisibleFields,
+ });
+ }
+ });
+ },
+ /**
+ * Re-renders buttons and updates the control panel. This method is called
+ * when the widget is reset, as the available buttons might have changed.
+ * The only mutable element in X2Many fields will be the pager.
+ *
+ * @private
+ */
+ _updateControlPanel: function (pagingState) {
+ if (this._controlPanelWrapper) {
+ this._renderButtons();
+ const pagerProps = Object.assign(this.pagingState, pagingState, {
+ // sometimes, we temporarily want to increase the pager limit
+ // (for instance, when we add a new record on a page that already
+ // contains the maximum number of records)
+ limit: Math.max(this.value.limit, this.value.data.length),
+ });
+ const newProps = {
+ cp_content: { $buttons: this.$buttons },
+ pager: pagerProps,
+ };
+ return this._controlPanelWrapper.update(newProps);
+ }
+ },
+ /**
+ * Parses the 'columnInvisibleFields' attribute to search for the domains
+ * containing the key 'parent'. If there are such domains, the string
+ * 'parent.field' is replaced with 'field' in order to be evaluated
+ * with the right field name in the parent context.
+ *
+ * @private
+ */
+ _processColumnInvisibleFields: function () {
+ var columnInvisibleFields = {};
+ _.each(this.attrs.columnInvisibleFields, function (domains, fieldName) {
+ if (_.isArray(domains)) {
+ columnInvisibleFields[fieldName] = _.map(domains, function (domain) {
+ // We check if the domain is an array to avoid processing
+ // the '|' and '&' cases
+ if (_.isArray(domain)) {
+ return [domain[0].split('.')[1]].concat(domain.slice(1));
+ }
+ return domain;
+ });
+ }
+ });
+ this.columnInvisibleFields = columnInvisibleFields;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the user clicks on the 'Add a line' 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} ev
+ */
+ _onRemoveRecord: function (ev) {
+ ev.stopPropagation();
+ var operation = this.isMany2Many ? 'FORGET' : 'DELETE';
+ this._setValue({
+ operation: operation,
+ ids: [ev.data.id],
+ });
+ },
+ /**
+ * When the discard_change event go through this field, we can just decorate
+ * the data with the name of the field. The origin field ignore this
+ * information (it is a subfield in a o2m), and the controller will need to
+ * know which field needs to be handled.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onDiscardChanges: function (ev) {
+ if (ev.target !== this) {
+ ev.stopPropagation();
+ this.trigger_up('discard_changes', _.extend({}, ev.data, {fieldName: this.name}));
+ }
+ },
+ /**
+ * Called when the renderer asks to edit a line, in that case simply tells
+ * him back to toggle the mode of this row.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onEditLine: function (ev) {
+ ev.stopPropagation();
+ this.trigger_up('edited_list', { id: this.value.id });
+ this.renderer.setRowMode(ev.data.recordId, 'edit')
+ .then(ev.data.onSuccess);
+ },
+ /**
+ * Updates the given record with the changes.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onFieldChanged: function (ev) {
+ if (ev.target === this) {
+ ev.initialEvent = this.lastInitialEvent;
+ return;
+ }
+ ev.stopPropagation();
+ // changes occured in an editable list
+ var changes = ev.data.changes;
+ // save the initial event triggering the field_changed, as it will be
+ // necessary when the field triggering this event will be reset (to
+ // prevent it from re-rendering itself, formatting its value, loosing
+ // the focus... while still being edited)
+ this.lastInitialEvent = undefined;
+ if (Object.keys(changes).length) {
+ this.lastInitialEvent = ev;
+ this._setValue({
+ operation: 'UPDATE',
+ id: ev.data.dataPointID,
+ data: changes,
+ }).then(function () {
+ if (ev.data.onSuccess) {
+ ev.data.onSuccess();
+ }
+ }).guardedCatch(function (reason) {
+ if (ev.data.onFailure) {
+ ev.data.onFailure(reason);
+ }
+ });
+ }
+ },
+ /**
+ * Override to handle the navigation inside editable list controls
+ *
+ * @override
+ * @private
+ */
+ _onNavigationMove: function (ev) {
+ if (this.view.arch.tag === 'tree') {
+ var $curControl = this.renderer.$('.o_field_x2many_list_row_add a:focus');
+ if ($curControl.length) {
+ var $nextControl;
+ if (ev.data.direction === 'right') {
+ $nextControl = $curControl.next('a');
+ } else if (ev.data.direction === 'left') {
+ $nextControl = $curControl.prev('a');
+ }
+ if ($nextControl && $nextControl.length) {
+ ev.stopPropagation();
+ $nextControl.focus();
+ return;
+ }
+ }
+ }
+ this._super.apply(this, arguments);
+ },
+ /**
+ * Called when the user clicks on a relational record.
+ *
+ * @abstract
+ * @private
+ */
+ _onOpenRecord: function () {
+ // to implement
+ },
+ /**
+ * We re-render the pager immediately with the new event values to allow
+ * it to request another pager change while another one is still ongoing.
+ * @see field_manager_mixin for concurrency handling.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onPagerChanged: function (ev) {
+ ev.stopPropagation();
+ const { currentMinimum, limit } = ev.data;
+ this._updateControlPanel({ currentMinimum, limit });
+ this.trigger_up('load', {
+ id: this.value.id,
+ limit,
+ offset: currentMinimum - 1,
+ on_success: value => {
+ this.value = value;
+ this.pagingState.limit = value.limit;
+ this.pagingState.size = value.count;
+ this._render();
+ },
+ });
+ },
+ /**
+ * Called when the renderer ask to save a line (the user tries to leave it)
+ * -> Nothing is to "save" here, the model was already notified of the line
+ * changes; if the row could be saved, we make the row readonly. Otherwise,
+ * we trigger a new event for the view to tell it to discard the changes
+ * made to that row.
+ * Note that we do that in the controller mutex to ensure that the check on
+ * the row (whether or not it can be saved) is done once all potential
+ * onchange RPCs are done (those RPCs being executed in the same mutex).
+ * This particular handling is done in this handler, instead of in the
+ * _saveLine function directly, because _saveLine is also called from
+ * the controller (via commitChanges), and in this case, it is already
+ * executed in the mutex.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ * @param {string} ev.recordID
+ * @param {function} ev.onSuccess success callback (see '_saveLine')
+ * @param {function} ev.onFailure fail callback (see '_saveLine')
+ */
+ _onSaveLine: function (ev) {
+ var self = this;
+ ev.stopPropagation();
+ this.renderer.commitChanges(ev.data.recordID).then(function () {
+ self.trigger_up('mutexify', {
+ action: function () {
+ return self._saveLine(ev.data.recordID)
+ .then(ev.data.onSuccess)
+ .guardedCatch(ev.data.onFailure);
+ },
+ });
+ });
+ },
+ /**
+ * Add necessary key parts for the basic controller to compute the local
+ * storage key. The event will be properly handled by the basic controller.
+ *
+ * @param {OdooEvent} ev
+ * @private
+ */
+ _onSaveOrLoadOptionalFields: function (ev) {
+ ev.data.keyParts.relationalField = this.name;
+ ev.data.keyParts.subViewId = this.view.view_id;
+ ev.data.keyParts.subViewType = this.view.type;
+ },
+ /**
+ * Forces a resequencing of the records.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ * @param {string[]} ev.data.recordIds
+ * @param {integer} ev.data.offset
+ * @param {string} ev.data.handleField
+ */
+ _onResequenceRecords: function (ev) {
+ ev.stopPropagation();
+ var self = this;
+ if (this.view.arch.tag === 'tree') {
+ this.trigger_up('edited_list', { id: this.value.id });
+ }
+ var handleField = ev.data.handleField;
+ var offset = ev.data.offset;
+ var recordIds = ev.data.recordIds.slice();
+ // trigger an update of all records but the last one with option
+ // 'notifyChanges' set to false, and once all those changes have been
+ // validated by the model, trigger the change on the last record
+ // (without the option, s.t. the potential onchange on parent record
+ // is triggered)
+ var recordId = recordIds.pop();
+ var proms = recordIds.map(function (recordId, index) {
+ var data = {};
+ data[handleField] = offset + index;
+ return self._setValue({
+ operation: 'UPDATE',
+ id: recordId,
+ data: data,
+ }, {
+ notifyChange: false,
+ });
+ });
+ Promise.all(proms).then(function () {
+ function always() {
+ if (self.view.arch.tag === 'tree') {
+ self.trigger_up('toggle_column_order', {
+ id: self.value.id,
+ name: handleField,
+ });
+ }
+ }
+ var data = {};
+ data[handleField] = offset + recordIds.length;
+ self._setValue({
+ operation: 'UPDATE',
+ id: recordId,
+ data: data,
+ }).then(always).guardedCatch(always);
+ });
+ },
+ /**
+ * Adds field name information to the event, so that the view upstream is
+ * aware of which widgets it has to redraw.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onToggleColumnOrder: function (ev) {
+ ev.data.field = this.name;
+ },
+ /*
+ * Move to next widget.
+ *
+ * @private
+ */
+ _onActiveNextWidget: function (e) {
+ e.stopPropagation();
+ this.renderer.unselectRow();
+ this.trigger_up('navigation_move', {
+ direction: e.data.direction || 'next',
+ });
+ },
+});
+
+var One2ManyKanbanRecord = KanbanRecord.extend({
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Apply same logic as in the ListRenderer: buttons with type="object"
+ * are disabled for no saved yet records, as calling the python method
+ * with no id would make no sense.
+ *
+ * To avoid to expose this logic inside all Kanban views, we define
+ * a specific KanbanRecord Class for the One2many case.
+ *
+ * This could be refactored to prevent from duplicating this logic in
+ * list and kanban views.
+ *
+ * @private
+ */
+ _postProcessObjectButtons: function () {
+ var self = this;
+ // if the res_id is defined, it's already correctly handled by the Kanban record global event click
+ if (!this.state.res_id) {
+ this.$('.oe_kanban_action[data-type=object]').each(function (index, button) {
+ var $button = $(button);
+ if ($button.attr('warn')) {
+ $button.on('click', function (e) {
+ e.stopPropagation();
+ self.do_warn(false, _t('Please click on the "save" button first'));
+ });
+ } else {
+ $button.attr('disabled', 'disabled');
+ }
+ });
+ }
+ },
+ /**
+ * @override
+ * @private
+ */
+ _render: function () {
+ var self = this;
+ return this._super.apply(this, arguments).then(function () {
+ self._postProcessObjectButtons();
+ });
+ },
+});
+
+var One2ManyKanbanRenderer = KanbanRenderer.extend({
+ config: _.extend({}, KanbanRenderer.prototype.config, {
+ KanbanRecord: One2ManyKanbanRecord,
+ }),
+});
+
+var FieldOne2Many = FieldX2Many.extend({
+ description: _lt("One2many"),
+ className: 'o_field_one2many',
+ supportedFieldTypes: ['one2many'],
+
+ /**
+ * @override
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+
+ // boolean used to prevent concurrent record creation
+ this.creatingRecord = false;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+ /**
+ * @override
+ * @param {Object} record
+ * @param {OdooEvent} [ev] an event that triggered the reset action
+ * @returns {Promise}
+ */
+ reset: function (record, ev) {
+ var self = this;
+ return this._super.apply(this, arguments).then(() => {
+ if (ev && ev.target === self && ev.data.changes && self.view.arch.tag === 'tree') {
+ if (ev.data.changes[self.name] && ev.data.changes[self.name].operation === 'CREATE') {
+ var index = 0;
+ if (self.editable !== 'top') {
+ index = self.value.data.length - 1;
+ }
+ var newID = self.value.data[index].id;
+ self.renderer.editRecord(newID);
+ }
+ }
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @private
+ */
+ _getButtonsRenderingContext() {
+ const renderingContext = this._super(...arguments);
+ renderingContext.noCreate = !this.canCreate;
+ return renderingContext;
+ },
+ /**
+ * @override
+ * @private
+ */
+ _getRenderer: function () {
+ if (this.view.arch.tag === 'kanban') {
+ return One2ManyKanbanRenderer;
+ }
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * Overrides to only render the buttons if the 'create' action is available.
+ *
+ * @override
+ * @private
+ */
+ _renderButtons: function () {
+ if (this.activeActions.create) {
+ return this._super(...arguments);
+ }
+ },
+ /**
+ * @private
+ * @param {Object} params
+ * @param {Object} [params.context] We allow additional context, this is
+ * used for example to define default values when adding new lines to
+ * a one2many with control/create tags.
+ */
+ _openFormDialog: function (params) {
+ var context = this.record.getContext(_.extend({},
+ this.recordParams,
+ { additionalContext: params.context }
+ ));
+ this.trigger_up('open_one2many_record', _.extend(params, {
+ domain: this.record.getDomain(this.recordParams),
+ context: context,
+ field: this.field,
+ fields_view: this.attrs.views && this.attrs.views.form,
+ parentID: this.value.id,
+ viewInfo: this.view,
+ deletable: this.activeActions.delete && params.deletable && this.canDelete,
+ }));
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Opens a FormViewDialog to allow creating a new record for a one2many.
+ *
+ * @override
+ * @private
+ * @param {OdooEvent|MouseEvent} ev this event comes either from the 'Add
+ * record' link in the list editable renderer, or from the 'Create' button
+ * in the kanban view
+ * @param {Array} ev.data.context additional context for the added records,
+ * if several contexts are provided, multiple records will be added
+ * (form dialog will only use the context at index 0 if provided)
+ * @param {boolean} ev.data.forceEditable this is used to bypass the dialog opening
+ * in case you want to add record(s) to a list
+ * @param {function} ev.data.onSuccess called when the records are correctly created
+ * (not supported by form dialog)
+ * @param {boolean} ev.data.allowWarning defines if the records can be added
+ * to the list even if warnings are triggered (e.g: stock warning for product availability)
+ */
+ _onAddRecord: function (ev) {
+ var self = this;
+ var data = ev.data || {};
+
+ // we don't want interference with the components upstream.
+ ev.stopPropagation();
+
+ if (this.editable || data.forceEditable) {
+ if (!this.activeActions.create) {
+ if (data.onFail) {
+ data.onFail();
+ }
+ } else if (!this.creatingRecord) {
+ this.creatingRecord = true;
+ this.trigger_up('edited_list', { id: this.value.id });
+ this._setValue({
+ operation: 'CREATE',
+ position: this.editable || data.forceEditable,
+ context: data.context,
+ }, {
+ allowWarning: data.allowWarning
+ }).then(function () {
+ self.creatingRecord = false;
+ }).then(function (){
+ if (data.onSuccess){
+ data.onSuccess();
+ }
+ }).guardedCatch(function() {
+ self.creatingRecord = false;
+ })
+ ;
+ }
+ } else {
+ this._openFormDialog({
+ context: data.context && data.context[0],
+ on_saved: function (record) {
+ self._setValue({ operation: 'ADD', id: record.id });
+ },
+ });
+ }
+ },
+ /**
+ * 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.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onOpenRecord: function (ev) {
+ // we don't want interference with the components upstream.
+ var self = this;
+ ev.stopPropagation();
+
+ var id = ev.data.id;
+ var onSaved = function (record) {
+ if (_.some(self.value.data, {id: record.id})) {
+ // the record already exists in the relation, so trigger an
+ // empty 'UPDATE' operation when the user clicks on 'Save' in
+ // the dialog, to notify the main record that a subrecord of
+ // this relational field has changed (those changes will be
+ // already stored on that subrecord, thanks to the 'Save').
+ self._setValue({ operation: 'UPDATE', id: record.id });
+ } else {
+ // the record isn't in the relation yet, so add it ; this can
+ // happen if the user clicks on 'Save & New' in the dialog (the
+ // opened record will be updated, and other records will be
+ // created)
+ self._setValue({ operation: 'ADD', id: record.id });
+ }
+ };
+ this._openFormDialog({
+ id: id,
+ on_saved: onSaved,
+ on_remove: function () {
+ self._setValue({operation: 'DELETE', ids: [id]});
+ },
+ deletable: this.activeActions.delete && this.view.arch.tag !== 'tree' && this.canDelete,
+ readonly: this.mode === 'readonly',
+ });
+ },
+});
+
+var FieldMany2Many = FieldX2Many.extend({
+ description: _lt("Many2many"),
+ className: 'o_field_many2many',
+ supportedFieldTypes: ['many2many'],
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+ /**
+ * Opens a SelectCreateDialog
+ */
+ onAddRecordOpenDialog: function () {
+ var self = this;
+ var domain = this.record.getDomain({fieldName: this.name});
+
+ new dialogs.SelectCreateDialog(this, {
+ res_model: this.field.relation,
+ domain: domain.concat(["!", ["id", "in", this.value.res_ids]]),
+ context: this.record.getContext(this.recordParams),
+ title: _t("Add: ") + this.string,
+ no_create: this.nodeOptions.no_create || !this.activeActions.create || !this.canCreate,
+ fields_view: this.attrs.views.form,
+ kanban_view_ref: this.attrs.kanban_view_ref,
+ on_selected: function (records) {
+ var resIDs = _.pluck(records, 'id');
+ var newIDs = _.difference(resIDs, self.value.res_ids);
+ if (newIDs.length) {
+ var values = _.map(newIDs, function (id) {
+ return {id: id};
+ });
+ self._setValue({
+ operation: 'ADD_M2M',
+ ids: values,
+ });
+ }
+ }
+ }).open();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @private
+ */
+ _getButtonsRenderingContext() {
+ const renderingContext = this._super(...arguments);
+ renderingContext.noCreate = !this.canLink;
+ return renderingContext;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Opens a SelectCreateDialog.
+ *
+ * @override
+ * @private
+ * @param {OdooEvent|MouseEvent} ev 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 (ev) {
+ ev.stopPropagation();
+ this.onAddRecordOpenDialog();
+ },
+
+ /**
+ * Intercepts the 'open_record' event to edit its data and lets it bubble up
+ * to the form view.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onOpenRecord: function (ev) {
+ var self = this;
+ _.extend(ev.data, {
+ context: this.record.getContext(this.recordParams),
+ domain: this.record.getDomain(this.recordParams),
+ fields_view: this.attrs.views && this.attrs.views.form,
+ on_saved: function () {
+ self._setValue({operation: 'TRIGGER_ONCHANGE'}, {forceChange: true})
+ .then(function () {
+ self.trigger_up('reload', {db_id: ev.data.id});
+ });
+ },
+ on_remove: function () {
+ self._setValue({operation: 'FORGET', ids: [ev.data.id]});
+ },
+ readonly: this.mode === 'readonly',
+ deletable: this.activeActions.delete && this.view.arch.tag !== 'tree' && this.canDelete,
+ string: this.string,
+ });
+ },
+});
+
+/**
+ * Widget to upload or delete one or more files at the same time.
+ */
+var FieldMany2ManyBinaryMultiFiles = AbstractField.extend({
+ template: "FieldBinaryFileUploader",
+ template_files: "FieldBinaryFileUploader.files",
+ supportedFieldTypes: ['many2many'],
+ fieldsToFetch: {
+ name: {type: 'char'},
+ mimetype: {type: 'char'},
+ },
+ events: {
+ 'click .o_attach': '_onAttach',
+ 'click .o_attachment_delete': '_onDelete',
+ 'change .o_input_file': '_onFileChanged',
+ },
+ /**
+ * @constructor
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+
+ if (this.field.type !== 'many2many' || this.field.relation !== 'ir.attachment') {
+ var msg = _t("The type of the field '%s' must be a many2many field with a relation to 'ir.attachment' model.");
+ throw _.str.sprintf(msg, this.field.string);
+ }
+
+ this.uploadedFiles = {};
+ this.uploadingFiles = [];
+ this.fileupload_id = _.uniqueId('oe_fileupload_temp');
+ this.accepted_file_extensions = (this.nodeOptions && this.nodeOptions.accepted_file_extensions) || this.accepted_file_extensions || '*';
+ $(window).on(this.fileupload_id, this._onFileLoaded.bind(this));
+
+ this.metadata = {};
+ },
+
+ destroy: function () {
+ this._super();
+ $(window).off(this.fileupload_id);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Compute the URL of an attachment.
+ *
+ * @private
+ * @param {Object} attachment
+ * @returns {string} URL of the attachment
+ */
+ _getFileUrl: function (attachment) {
+ return '/web/content/' + attachment.id + '?download=true';
+ },
+ /**
+ * Process the field data to add some information (url, etc.).
+ *
+ * @private
+ */
+ _generatedMetadata: function () {
+ var self = this;
+ _.each(this.value.data, function (record) {
+ // tagging `allowUnlink` ascertains if the attachment was user
+ // uploaded or was an existing or system generated attachment
+ self.metadata[record.id] = {
+ allowUnlink: self.uploadedFiles[record.data.id] || false,
+ url: self._getFileUrl(record.data),
+ };
+ });
+ },
+ /**
+ * @private
+ * @override
+ */
+ _render: function () {
+ // render the attachments ; as the attachments will changes after each
+ // _setValue, we put the rendering here to ensure they will be updated
+ this._generatedMetadata();
+ this.$('.oe_placeholder_files, .o_attachments')
+ .replaceWith($(qweb.render(this.template_files, {
+ widget: this,
+ })));
+ this.$('.oe_fileupload').show();
+
+ // display image thumbnail
+ this.$('.o_image[data-mimetype^="image"]').each(function () {
+ var $img = $(this);
+ if (/gif|jpe|jpg|png/.test($img.data('mimetype')) && $img.data('src')) {
+ $img.css('background-image', "url('" + $img.data('src') + "')");
+ }
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onAttach: function () {
+ // This widget uses a hidden form to upload files. Clicking on 'Attach'
+ // will simulate a click on the related input.
+ this.$('.o_input_file').click();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onDelete: function (ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ var fileID = $(ev.currentTarget).data('id');
+ var record = _.findWhere(this.value.data, {res_id: fileID});
+ if (record) {
+ this._setValue({
+ operation: 'FORGET',
+ ids: [record.id],
+ });
+ var metadata = this.metadata[record.id];
+ if (!metadata || metadata.allowUnlink) {
+ this._rpc({
+ model: 'ir.attachment',
+ method: 'unlink',
+ args: [record.res_id],
+ });
+ }
+ }
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onFileChanged: function (ev) {
+ var self = this;
+ ev.stopPropagation();
+
+ var files = ev.target.files;
+ var attachment_ids = this.value.res_ids;
+
+ // Don't create an attachment if the upload window is cancelled.
+ if(files.length === 0)
+ return;
+
+ _.each(files, function (file) {
+ var record = _.find(self.value.data, function (attachment) {
+ return attachment.data.name === file.name;
+ });
+ if (record) {
+ var metadata = self.metadata[record.id];
+ if (!metadata || metadata.allowUnlink) {
+ // there is a existing attachment with the same name so we
+ // replace it
+ attachment_ids = _.without(attachment_ids, record.res_id);
+ self._rpc({
+ model: 'ir.attachment',
+ method: 'unlink',
+ args: [record.res_id],
+ });
+ }
+ }
+ self.uploadingFiles.push(file);
+ });
+
+ this._setValue({
+ operation: 'REPLACE_WITH',
+ ids: attachment_ids,
+ });
+
+ this.$('form.o_form_binary_form').submit();
+ this.$('.oe_fileupload').hide();
+ ev.target.value = "";
+ },
+ /**
+ * @private
+ */
+ _onFileLoaded: function () {
+ var self = this;
+ // the first argument isn't a file but the jQuery.Event
+ var files = Array.prototype.slice.call(arguments, 1);
+ // files has been uploaded, clear uploading
+ this.uploadingFiles = [];
+
+ var attachment_ids = this.value.res_ids;
+ _.each(files, function (file) {
+ if (file.error) {
+ self.do_warn(_t('Uploading Error'), file.error);
+ } else {
+ attachment_ids.push(file.id);
+ self.uploadedFiles[file.id] = true;
+ }
+ });
+
+ this._setValue({
+ operation: 'REPLACE_WITH',
+ ids: attachment_ids,
+ });
+ },
+});
+
+var FieldMany2ManyTags = AbstractField.extend({
+ description: _lt("Tags"),
+ tag_template: "FieldMany2ManyTag",
+ className: "o_field_many2manytags",
+ supportedFieldTypes: ['many2many'],
+ custom_events: _.extend({}, AbstractField.prototype.custom_events, {
+ field_changed: '_onFieldChanged',
+ }),
+ events: _.extend({}, AbstractField.prototype.events, {
+ 'click .o_delete': '_onDeleteTag',
+ }),
+ fieldsToFetch: {
+ display_name: {type: 'char'},
+ },
+ limit: 1000,
+
+ /**
+ * @constructor
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+
+ if (this.mode === 'edit') {
+ this.className += ' o_input';
+ }
+
+ this.colorField = this.nodeOptions.color_field;
+ this.hasDropdown = false;
+
+ this._computeAvailableActions(this.record);
+ // have listen to react to other fields changes to re-evaluate 'create' option
+ this.resetOnAnyFieldChange = this.resetOnAnyFieldChange || 'create' in this.nodeOptions;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ activate: function () {
+ return this.many2one ? this.many2one.activate() : false;
+ },
+ /**
+ * @override
+ * @returns {jQuery}
+ */
+ getFocusableElement: function () {
+ return this.many2one ? this.many2one.getFocusableElement() : $();
+ },
+ /**
+ * @override
+ * @returns {boolean}
+ */
+ isSet: function () {
+ return !!this.value && this.value.count;
+ },
+ /**
+ * Reset the focus on this field if it was the origin of the onchange call.
+ *
+ * @override
+ */
+ reset: function (record, event) {
+ var self = this;
+ this._computeAvailableActions(record);
+ return this._super.apply(this, arguments).then(function () {
+ if (event && event.target === self) {
+ self.activate();
+ }
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {any} data
+ * @returns {Promise}
+ */
+ _addTag: function (data) {
+ if (!_.contains(this.value.res_ids, data.id)) {
+ return this._setValue({
+ operation: 'ADD_M2M',
+ ids: data
+ });
+ }
+ return Promise.resolve();
+ },
+ /**
+ * @private
+ * @param {Object} record
+ */
+ _computeAvailableActions: function (record) {
+ const evalContext = record.evalContext;
+ this.canCreate = 'create' in this.nodeOptions ?
+ new Domain(this.nodeOptions.create, evalContext).compute(evalContext) :
+ true;
+ },
+ /**
+ * Get the QWeb rendering context used by the tag template; this computation
+ * is placed in a separate function for other tags to override it.
+ *
+ * @private
+ * @returns {Object}
+ */
+ _getRenderTagsContext: function () {
+ var elements = this.value ? _.pluck(this.value.data, 'data') : [];
+ return {
+ colorField: this.colorField,
+ elements: elements,
+ hasDropdown: this.hasDropdown,
+ readonly: this.mode === "readonly",
+ };
+ },
+ /**
+ * @private
+ * @param {any} id
+ */
+ _removeTag: function (id) {
+ var record = _.findWhere(this.value.data, {res_id: id});
+ this._setValue({
+ operation: 'FORGET',
+ ids: [record.id],
+ });
+ },
+ /**
+ * @private
+ */
+ _renderEdit: function () {
+ var self = this;
+ this._renderTags();
+ if (this.many2one) {
+ this.many2one.destroy();
+ }
+ this.many2one = new FieldMany2One(this, this.name, this.record, {
+ mode: 'edit',
+ noOpen: true,
+ noCreate: !this.canCreate,
+ viewType: this.viewType,
+ attrs: this.attrs,
+ });
+ // 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._getSearchBlacklist = function () {
+ return self.value.res_ids;
+ };
+ var _getSearchCreatePopupOptions = this.many2one._getSearchCreatePopupOptions;
+ this.many2one._getSearchCreatePopupOptions = function (view, ids, context, dynamicFilters) {
+ var options = _getSearchCreatePopupOptions.apply(this, arguments);
+ var domain = this.record.getDomain({fieldName: this.name});
+ var m2mRecords = [];
+ return _.extend({}, options, {
+ domain: domain.concat(["!", ["id", "in", self.value.res_ids]]),
+ disable_multiple_selection: false,
+ on_selected: function (records) {
+ m2mRecords.push(...records);
+ },
+ on_closed: function () {
+ self.many2one.reinitialize(m2mRecords);
+ },
+ });
+ };
+ return this.many2one.appendTo(this.$el);
+ },
+ /**
+ * @private
+ */
+ _renderReadonly: function () {
+ this._renderTags();
+ },
+ /**
+ * @private
+ */
+ _renderTags: function () {
+ this.$el.html(qweb.render(this.tag_template, this._getRenderTagsContext()));
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onDeleteTag: function (event) {
+ event.preventDefault();
+ event.stopPropagation();
+ this._removeTag($(event.target).parent().data('id'));
+ },
+ /**
+ * Controls the changes made in the internal m2o field.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onFieldChanged: function (ev) {
+ if (ev.target !== this.many2one) {
+ return;
+ }
+ ev.stopPropagation();
+ var newValue = ev.data.changes[this.name];
+ if (newValue) {
+ this._addTag(newValue)
+ .then(ev.data.onSuccess || function () {})
+ .guardedCatch(ev.data.onFailure || function () {});
+ this.many2one.reinitialize(false);
+ }
+ },
+ /**
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onKeydown: function (ev) {
+ if (ev.which === $.ui.keyCode.BACKSPACE && this.$('input').val() === "") {
+ var $badges = this.$('.badge');
+ if ($badges.length) {
+ this._removeTag($badges.last().data('id'));
+ return;
+ }
+ }
+ this._super.apply(this, arguments);
+ },
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onQuickCreate: function (event) {
+ this._quickCreate(event.data.value);
+ },
+});
+
+var FieldMany2ManyTagsAvatar = FieldMany2ManyTags.extend({
+ tag_template: 'FieldMany2ManyTagAvatar',
+ className: 'o_field_many2manytags avatar',
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @private
+ */
+ _getRenderTagsContext: function () {
+ var result = this._super.apply(this, arguments);
+ result.avatarModel = this.nodeOptions.avatarModel || this.field.relation;
+ result.avatarField = this.nodeOptions.avatarField || 'image_128';
+ return result;
+ },
+});
+
+var FormFieldMany2ManyTags = FieldMany2ManyTags.extend({
+ events: _.extend({}, FieldMany2ManyTags.prototype.events, {
+ 'click .dropdown-toggle': '_onOpenColorPicker',
+ 'mousedown .o_colorpicker a': '_onUpdateColor',
+ 'mousedown .o_colorpicker .o_hide_in_kanban': '_onUpdateColor',
+ }),
+ /**
+ * @override
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+
+ this.hasDropdown = !!this.colorField;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onOpenColorPicker: function (ev) {
+ ev.preventDefault();
+ if (this.nodeOptions.no_edit_color) {
+ ev.stopPropagation();
+ return;
+ }
+ var tagID = $(ev.currentTarget).parent().data('id');
+ var tagColor = $(ev.currentTarget).parent().data('color');
+ var tag = _.findWhere(this.value.data, { res_id: tagID });
+ if (tag && this.colorField in tag.data) { // if there is a color field on the related model
+ this.$color_picker = $(qweb.render('FieldMany2ManyTag.colorpicker', {
+ 'widget': this,
+ 'tag_id': tagID,
+ }));
+
+ $(ev.currentTarget).after(this.$color_picker);
+ this.$color_picker.dropdown();
+ this.$color_picker.attr("tabindex", 1).focus();
+ if (!tagColor) {
+ this.$('.custom-checkbox input').prop('checked', true);
+ }
+ }
+ },
+ /**
+ * Update color based on target of ev
+ * either by clicking on a color item or
+ * by toggling the 'Hide in Kanban' checkbox.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onUpdateColor: function (ev) {
+ ev.preventDefault();
+ var $target = $(ev.currentTarget);
+ var color = $target.data('color');
+ var id = $target.data('id');
+ var $tag = this.$(".badge[data-id='" + id + "']");
+ var currentColor = $tag.data('color');
+ var changes = {};
+
+ if ($target.is('.o_hide_in_kanban')) {
+ var $checkbox = $('.o_hide_in_kanban .custom-checkbox input');
+ $checkbox.prop('checked', !$checkbox.prop('checked')); // toggle checkbox
+ this.prevColors = this.prevColors ? this.prevColors : {};
+ if ($checkbox.is(':checked')) {
+ this.prevColors[id] = currentColor;
+ } else {
+ color = this.prevColors[id] ? this.prevColors[id] : 1;
+ }
+ } else if ($target.is('[class^="o_tag_color"]')) { // $target.is('o_tag_color_')
+ if (color === currentColor) { return; }
+ }
+
+ changes[this.colorField] = color;
+
+ this.trigger_up('field_changed', {
+ dataPointID: _.findWhere(this.value.data, {res_id: id}).id,
+ changes: changes,
+ force_save: true,
+ });
+ },
+});
+
+var KanbanFieldMany2ManyTags = FieldMany2ManyTags.extend({
+ // Remove event handlers on this widget to ensure that the kanban 'global
+ // click' opens the clicked record, even if the click is done on a tag
+ // This is necessary because of the weird 'global click' logic in
+ // KanbanRecord, which should definitely be cleaned.
+ // Anyway, those handlers are only necessary in Form and List views, so we
+ // can removed them here.
+ events: AbstractField.prototype.events,
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @private
+ */
+ _render: function () {
+ var self = this;
+
+ if (this.$el) {
+ this.$el.empty().addClass('o_field_many2manytags o_kanban_tags');
+ }
+
+ _.each(this.value.data, function (m2m) {
+ if (self.colorField in m2m.data && !m2m.data[self.colorField]) {
+ // When a color field is specified and that color is the default
+ // one, the kanban tag is not rendered.
+ return;
+ }
+
+ $('<span>', {
+ class: 'o_tag o_tag_color_' + (m2m.data[self.colorField] || 0),
+ text: m2m.data.display_name,
+ })
+ .prepend('<span>')
+ .appendTo(self.$el);
+ });
+ },
+});
+
+var FieldMany2ManyCheckBoxes = AbstractField.extend({
+ description: _lt("Checkboxes"),
+ template: 'FieldMany2ManyCheckBoxes',
+ events: _.extend({}, AbstractField.prototype.events, {
+ change: '_onChange',
+ }),
+ specialData: "_fetchSpecialRelation",
+ supportedFieldTypes: ['many2many'],
+ // set an arbitrary high limit to ensure that all data returned by the server
+ // are processed by the BasicModel (otherwise it would be 40)
+ limit: 100000,
+ init: function () {
+ this._super.apply(this, arguments);
+ this.m2mValues = this.record.specialData[this.name];
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ isSet: function () {
+ return true;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _renderCheckboxes: function () {
+ var self = this;
+ this.m2mValues = this.record.specialData[this.name];
+ this.$el.html(qweb.render(this.template, {widget: this}));
+ _.each(this.value.res_ids, function (id) {
+ self.$('input[data-record-id="' + id + '"]').prop('checked', true);
+ });
+ },
+ /**
+ * @override
+ * @private
+ */
+ _renderEdit: function () {
+ this._renderCheckboxes();
+ },
+ /**
+ * @override
+ * @private
+ */
+ _renderReadonly: function () {
+ this._renderCheckboxes();
+ this.$("input").prop("disabled", true);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onChange: function () {
+ // Get the list of selected ids
+ var ids = _.map(this.$('input:checked'), function (input) {
+ return $(input).data("record-id");
+ });
+ // The number of displayed checkboxes is limited to 100 (name_search
+ // limit, server-side), to prevent extreme cases where thousands of
+ // records are fetched/displayed. If not all values are displayed, it may
+ // happen that some values that are in the relation aren't available in the
+ // widget. In this case, when the user (un)selects a value, we don't
+ // want to remove those non displayed values from the relation. For that
+ // reason, we manually add those values to the list of ids.
+ const displayedIds = this.m2mValues.map(v => v[0]);
+ const idsInRelation = this.value.res_ids;
+ ids = ids.concat(idsInRelation.filter(a => !displayedIds.includes(a)));
+ this._setValue({
+ operation: 'REPLACE_WITH',
+ ids: ids,
+ });
+ },
+});
+
+//------------------------------------------------------------------------------
+// Widgets handling both basic and relational fields (selection and Many2one)
+//------------------------------------------------------------------------------
+
+var FieldStatus = AbstractField.extend({
+ className: 'o_statusbar_status',
+ events: {
+ 'click button:not(.dropdown-toggle)': '_onClickStage',
+ },
+ specialData: "_fetchSpecialStatus",
+ supportedFieldTypes: ['selection', 'many2one'],
+ /**
+ * @override init from AbstractField
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ this._setState();
+ this._onClickStage = _.debounce(this._onClickStage, 300, true); // TODO maybe not useful anymore ?
+
+ // Retro-compatibility: clickable used to be defined in the field attrs
+ // instead of options.
+ // If not set, the statusbar is not clickable.
+ try {
+ this.isClickable = !!JSON.parse(this.attrs.clickable);
+ } catch (_) {
+ this.isClickable = !!this.nodeOptions.clickable;
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Returns false to force the statusbar to be always visible (even the field
+ * it not set).
+ *
+ * @override
+ * @returns {boolean} always false
+ */
+ isEmpty: function () {
+ return false;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override _reset from AbstractField
+ * @private
+ */
+ _reset: function () {
+ this._super.apply(this, arguments);
+ this._setState();
+ },
+ /**
+ * Prepares the rendering data from the field and record data.
+ * @private
+ */
+ _setState: function () {
+ var self = this;
+ if (this.field.type === 'many2one') {
+ this.status_information = _.map(this.record.specialData[this.name], function (info) {
+ return _.extend({
+ selected: info.id === self.value.res_id,
+ }, info);
+ });
+ } else {
+ var selection = this.field.selection;
+ if (this.attrs.statusbar_visible) {
+ var restriction = this.attrs.statusbar_visible.split(",");
+ selection = _.filter(selection, function (val) {
+ return _.contains(restriction, val[0]) || val[0] === self.value;
+ });
+ }
+ this.status_information = _.map(selection, function (val) {
+ return { id: val[0], display_name: val[1], selected: val[0] === self.value, fold: false };
+ });
+ }
+ },
+ /**
+ * @override _render from AbstractField
+ * @private
+ */
+ _render: function () {
+ var selections = _.partition(this.status_information, function (info) {
+ return (info.selected || !info.fold);
+ });
+ this.$el.html(qweb.render("FieldStatus.content", {
+ selection_unfolded: selections[0],
+ selection_folded: selections[1],
+ clickable: this.isClickable,
+ }));
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when on status stage is clicked -> sets the field value.
+ *
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onClickStage: function (e) {
+ this._setValue($(e.currentTarget).data("value"));
+ },
+});
+
+/**
+ * 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({
+ description: _lt("Selection"),
+ template: 'FieldSelection',
+ specialData: "_fetchSpecialRelation",
+ supportedFieldTypes: ['selection'],
+ events: _.extend({}, AbstractField.prototype.events, {
+ 'change': '_onChange',
+ }),
+ /**
+ * @override
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ this._setValues();
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @returns {jQuery}
+ */
+ getFocusableElement: function () {
+ return this.$el && this.$el.is('select') ? this.$el : $();
+ },
+ /**
+ * @override
+ */
+ isSet: function () {
+ return this.value !== false;
+ },
+ /**
+ * Listen to modifiers updates to hide/show the falsy value in the dropdown
+ * according to the required modifier.
+ *
+ * @override
+ */
+ updateModifiersValue: function () {
+ this._super.apply(this, arguments);
+ if (!this.attrs.modifiersValue.invisible && this.mode !== 'readonly') {
+ this._setValues();
+ this._renderEdit();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @private
+ */
+ _renderEdit: function () {
+ this.$el.empty();
+ var required = this.attrs.modifiersValue && this.attrs.modifiersValue.required;
+ for (var i = 0 ; i < this.values.length ; i++) {
+ var disabled = required && this.values[i][0] === false;
+
+ this.$el.append($('<option/>', {
+ value: JSON.stringify(this.values[i][0]),
+ text: this.values[i][1],
+ style: disabled ? "display: none" : "",
+ }));
+ }
+ this.$el.val(JSON.stringify(this._getRawValue()));
+ },
+ /**
+ * @override
+ * @private
+ */
+ _renderReadonly: function () {
+ this.$el.empty().text(this._formatValue(this.value));
+ this.$el.attr('raw-value', this._getRawValue());
+ },
+ _getRawValue: function() {
+ var raw_value = this.value;
+ if (this.field.type === 'many2one' && raw_value) {
+ raw_value = raw_value.data.id;
+ }
+ return raw_value;
+ },
+ /**
+ * @override
+ */
+ _reset: function () {
+ this._super.apply(this, arguments);
+ this._setValues();
+ },
+ /**
+ * Sets the possible field values. If the field is a many2one, those values
+ * may change during the lifecycle of the widget if the domain change (an
+ * onchange may change the domain).
+ *
+ * @private
+ */
+ _setValues: function () {
+ if (this.field.type === 'many2one') {
+ this.values = this.record.specialData[this.name];
+ this.formatType = 'many2one';
+ } else {
+ this.values = _.reject(this.field.selection, function (v) {
+ return v[0] === false && v[1] === '';
+ });
+ }
+ this.values = [[false, this.attrs.placeholder || '']].concat(this.values);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * The small slight difficulty is that we have to set the value differently
+ * depending on the field type.
+ *
+ * @private
+ */
+ _onChange: 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({
+ description: _lt("Radio"),
+ template: null,
+ className: 'o_field_radio',
+ tagName: 'span',
+ specialData: "_fetchSpecialMany2ones",
+ supportedFieldTypes: ['selection', 'many2one'],
+ events: _.extend({}, AbstractField.prototype.events, {
+ 'click input': '_onInputClick',
+ }),
+ /**
+ * @constructs FieldRadio
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ if (this.mode === 'edit') {
+ this.tagName = 'div';
+ this.className += this.nodeOptions.horizontal ? ' o_horizontal' : ' o_vertical';
+ }
+ this.unique_id = _.uniqueId("radio");
+ this._setValues();
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Returns the currently-checked radio button, or the first one if no radio
+ * button is checked.
+ *
+ * @override
+ */
+ getFocusableElement: function () {
+ var checked = this.$("[checked='true']");
+ return checked.length ? checked : this.$("[data-index='0']");
+ },
+
+ /**
+ * @override
+ * @returns {boolean} always true
+ */
+ isSet: function () {
+ return true;
+ },
+
+ /**
+ * Associates the 'for' attribute to the radiogroup, instead of the selected
+ * radio button.
+ *
+ * @param {string} id
+ */
+ setIDForLabel: function (id) {
+ this.$el.attr('id', id);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @override
+ */
+ _renderEdit: function () {
+ var self = this;
+ var currentValue;
+ if (this.field.type === 'many2one') {
+ currentValue = this.value && this.value.data.id;
+ } else {
+ currentValue = this.value;
+ }
+ this.$el.empty();
+ this.$el.attr('role', 'radiogroup')
+ .attr('aria-label', this.string);
+ _.each(this.values, function (value, index) {
+ self.$el.append(qweb.render('FieldRadio.button', {
+ checked: value[0] === currentValue,
+ id: self.unique_id + '_' + value[0],
+ index: index,
+ name: self.unique_id,
+ value: value,
+ }));
+ });
+ },
+ /**
+ * @override
+ */
+ _reset: function () {
+ this._super.apply(this, arguments);
+ this._setValues();
+ },
+ /**
+ * Sets the possible field values. If the field is a many2one, those values
+ * may change during the lifecycle of the widget if the domain change (an
+ * onchange may change the domain).
+ *
+ * @private
+ */
+ _setValues: function () {
+ if (this.field.type === 'selection') {
+ this.values = this.field.selection || [];
+ } else if (this.field.type === 'many2one') {
+ this.values = _.map(this.record.specialData[this.name], function (val) {
+ return [val.id, val.display_name];
+ });
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onInputClick: function (event) {
+ var index = $(event.target).data('index');
+ var value = this.values[index];
+ if (this.field.type === 'many2one') {
+ this._setValue({id: value[0], display_name: value[1]});
+ } else {
+ this._setValue(value[0]);
+ }
+ },
+});
+
+
+var FieldSelectionBadge = FieldSelection.extend({
+ description: _lt("Badges"),
+ template: null,
+ className: 'o_field_selection_badge',
+ tagName: 'span',
+ specialData: "_fetchSpecialMany2ones",
+ events: _.extend({}, AbstractField.prototype.events, {
+ 'click span.o_selection_badge': '_onBadgeClicked',
+ }),
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @override
+ */
+ _renderEdit: function () {
+ this.currentValue = this.value;
+
+ if (this.field.type === 'many2one') {
+ this.currentValue = this.value && this.value.data.id;
+ }
+ this.$el.empty();
+ this.$el.html(qweb.render('FieldSelectionBadge', {'values': this.values, 'current_value': this.currentValue}));
+ },
+ /**
+ * Sets the possible field values. If the field is a many2one, those values
+ * may change during the life cycle of the widget if the domain change (an
+ * onchange may change the domain).
+ *
+ * @private
+ * @override
+ */
+ _setValues: function () {
+ // Note: We can make abstract widget for common code in radio and selection badge
+ if (this.field.type === 'selection') {
+ this.values = this.field.selection || [];
+ } else if (this.field.type === 'many2one') {
+ this.values = _.map(this.record.specialData[this.name], function (val) {
+ return [val.id, val.display_name];
+ });
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onBadgeClicked: function (event) {
+ var index = $(event.target).data('index');
+ var value = this.values[index];
+ if (value[0] !== this.currentValue) {
+ if (this.field.type === 'many2one') {
+ this._setValue({id: value[0], display_name: value[1]});
+ } else {
+ this._setValue(value[0]);
+ }
+ } else {
+ this._setValue(false);
+ }
+ },
+});
+
+var FieldSelectionFont = FieldSelection.extend({
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Changes CSS for all options according to their value.
+ * Also removes empty labels.
+ *
+ * @private
+ * @override
+ */
+ _renderEdit: function () {
+ this._super.apply(this, arguments);
+
+ this.$('option').each(function (i, option) {
+ if (! option.label) {
+ $(option).remove();
+ }
+ $(option).css('font-family', option.value);
+ });
+ this.$el.css('font-family', this.value);
+ },
+});
+
+/**
+ * The FieldReference is a combination of a select (for the model) and
+ * a FieldMany2one for its value.
+ * Its intern representation is similar to the many2one (a datapoint with a
+ * `name_get` as data).
+ * Note that there is some logic to support char field because of one use in our
+ * codebase, but this use should be removed along with this note.
+ */
+var FieldReference = FieldMany2One.extend({
+ specialData: "_fetchSpecialReference",
+ supportedFieldTypes: ['reference'],
+ template: 'FieldReference',
+ events: _.extend({}, FieldMany2One.prototype.events, {
+ 'change select': '_onSelectionChange',
+ }),
+ /**
+ * @override
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+
+ // needs to be copied as it is an unmutable object
+ this.field = _.extend({}, this.field);
+
+ this._setState();
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this.$('select').val(this.field.relation);
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @returns {jQuery}
+ */
+ getFocusableElement: function () {
+ if (this.mode === 'edit' && !this.field.relation) {
+ return this.$('select');
+ }
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Get the encompassing record's display_name
+ *
+ * @override
+ */
+ _formatValue: function () {
+ var value;
+ if (this.field.type === 'char') {
+ value = this.record.specialData[this.name];
+ } else {
+ value = this.value;
+ }
+ return value && value.data && value.data.display_name || '';
+ },
+
+ /**
+ * Add a select in edit mode (for the model).
+ *
+ * @override
+ */
+ _renderEdit: function () {
+ this._super.apply(this, arguments);
+
+ if (this.$('select').val()) {
+ this.$('.o_input_dropdown').show();
+ this.$el.addClass('o_row'); // this class is used to display the two
+ // components (select & input) on the same line
+ } else {
+ // hide the many2one if the selection is empty
+ this.$('.o_input_dropdown').hide();
+ }
+
+ },
+ /**
+ * @override
+ * @private
+ */
+ _reset: function () {
+ this._super.apply(this, arguments);
+ var value = this.$('select').val();
+ this._setState();
+ this.$('select').val(this.value && this.value.model || value);
+ },
+ /**
+ * Set `relation` key in field properties.
+ *
+ * @private
+ * @param {string} model
+ */
+ _setRelation: function (model) {
+ // used to generate the search in many2one
+ this.field.relation = model;
+ },
+ /**
+ * @private
+ */
+ _setState: function () {
+ if (this.field.type === 'char') {
+ // in this case, the value is stored in specialData instead
+ this.value = this.record.specialData[this.name];
+ }
+
+ if (this.value) {
+ this._setRelation(this.value.model);
+ }
+ },
+ /**
+ * @override
+ * @private
+ */
+ _setValue: function (value, options) {
+ value = value || {};
+ // we need to specify the model for the change in basic_model
+ // the value is then now a dict with id, display_name and model
+ value.model = this.$('select').val();
+ return this._super(value, options);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * When the selection (model) changes, the many2one is reset.
+ *
+ * @private
+ */
+ _onSelectionChange: function () {
+ var value = this.$('select').val();
+ this.reinitialize(false);
+ this._setRelation(value);
+ },
+});
+
+return {
+ FieldMany2One: FieldMany2One,
+ Many2oneBarcode: Many2oneBarcode,
+ KanbanFieldMany2One: KanbanFieldMany2One,
+ ListFieldMany2One: ListFieldMany2One,
+ Many2OneAvatar: Many2OneAvatar,
+
+ FieldX2Many: FieldX2Many,
+ FieldOne2Many: FieldOne2Many,
+
+ FieldMany2Many: FieldMany2Many,
+ FieldMany2ManyBinaryMultiFiles: FieldMany2ManyBinaryMultiFiles,
+ FieldMany2ManyCheckBoxes: FieldMany2ManyCheckBoxes,
+ FieldMany2ManyTags: FieldMany2ManyTags,
+ FieldMany2ManyTagsAvatar: FieldMany2ManyTagsAvatar,
+ FormFieldMany2ManyTags: FormFieldMany2ManyTags,
+ KanbanFieldMany2ManyTags: KanbanFieldMany2ManyTags,
+
+ FieldRadio: FieldRadio,
+ FieldSelectionBadge: FieldSelectionBadge,
+ FieldSelection: FieldSelection,
+ FieldStatus: FieldStatus,
+ FieldSelectionFont: FieldSelectionFont,
+
+ FieldReference: FieldReference,
+};
+
+});