odoo.define("web.ModelFieldSelector", function (require) { "use strict"; var core = require("web.core"); var Widget = require("web.Widget"); var _t = core._t; /** * Field Selector Cache - TODO Should be improved to use external cache ? * - Stores fields per model used in field selector * @see ModelFieldSelector._getModelFieldsFromCache */ var modelFieldsCache = { cache: {}, cacheDefs: {}, }; core.bus.on('clear_cache', null, function () { modelFieldsCache.cache = {}; modelFieldsCache.cacheDefs = {}; }); /** * The ModelFieldSelector widget can be used to display/select a particular * field chain from a given model. */ var ModelFieldSelector = Widget.extend({ template: "ModelFieldSelector", events: {}, editionEvents: { // Handle popover opening and closing "focusin": "_onFocusIn", "focusout": "_onFocusOut", "click .o_field_selector_close": "_onCloseClick", // Handle popover field navigation "click .o_field_selector_prev_page": "_onPrevPageClick", "click .o_field_selector_next_page": "_onNextPageClick", "click li.o_field_selector_select_button": "_onLastFieldClick", // Handle a direct change in the debug input "change input": "_onInputChange", // Handle keyboard and mouse navigation to build the field chain "mouseover li.o_field_selector_item": "_onItemHover", "keydown": "_onKeydown", }, /** * @constructor * The ModelFieldSelector requires a model and a field chain to work with. * * @param {string} model - the model name (e.g. "res.partner") * @param {string[]} chain - list of the initial field chain parts * @param {Object} [options] - some key-value options * @param {boolean} [options.readonly=true] - true if should be readonly * @param {Object} [options.filters] * some key-value options to filter the fetched fields * @param {boolean} [options.filters.searchable=true] * true if only the searchable fields have to be used * @param {Object[]} [options.fields=null] * the list of fields info to use when no relation has * been followed (null indicates the widget has to request * the fields itself) * @param {boolean} [options.followRelations=true] * true if can follow relation when building the chain * @param {boolean} [options.debugMode=false] * true if the widget is in debug mode, false otherwise */ init: function (parent, model, chain, options) { this._super.apply(this, arguments); this.model = model; this.chain = chain; this.options = _.extend({ readonly: true, filters: {}, fields: null, followRelations: true, debugMode: false, }, options || {}); this.options.filters = _.extend({ searchable: true, }, this.options.filters); this.pages = []; this.dirty = false; if (!this.options.readonly) { _.extend(this.events, this.editionEvents); } }, /** * @see Widget.willStart() * @returns {Deferred} */ willStart: function () { return $.when( this._super.apply(this, arguments), this._prefill() ); }, /** * @see Widget.start * @returns {Deferred} */ start: function () { this.$value = this.$(".o_field_selector_value"); this.$popover = this.$(".o_field_selector_popover"); this.$input = this.$popover.find("input"); this.$valid = this.$(".o_field_selector_warning"); this._render(); return this._super.apply(this, arguments); }, //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- /** * Returns the field information selected by the field chain. * * @returns {Object} */ getSelectedField: function () { return _.findWhere(this.pages[this.chain.length - 1], {name: _.last(this.chain)}); }, /** * Indicates if the field chain is valid. If the field chain has not been * processed yet (the widget is not ready), this method will return * undefined. * * @returns {boolean} */ isValid: function () { return this.valid; }, /** * Saves a new field chain (array) and re-render. * * @param {string[]} chain - the new field chain * @returns {Deferred} resolved once the re-rendering is finished */ setChain: function (chain) { if (_.isEqual(chain, this.chain)) { return $.when(); } this.chain = chain; return this._prefill().then(this._render.bind(this)); }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * Adds a field name to the current field chain and marks it as dirty. * * @private * @param {string} fieldName - the new field name to add at the end of the * current field chain */ _addChainNode: function (fieldName) { this.dirty = true; this.chain = this.chain.slice(0, this.pages.length-1); this.chain.push(fieldName); }, /** * Searches a field in the last page by its name. * * @private * @param {string} name - the name of the field to find * @returns {Object} the field data found in the last popover page thanks * to its name /*/ _getLastPageField: function (name) { return _.findWhere(_.last(this.pages), { name: name, }); }, /** * Searches the cache for the given model fields, according to the given * filter. If the cache does not know about the model, the cache is updated. * * @private * @param {string} model * @param {Object} filters @see ModelFieldSelector.init.options.filters * @returns {Object[]} a list of the model fields info, sorted by field * non-technical names */ _getModelFieldsFromCache: function (model, filters) { var def = modelFieldsCache.cacheDefs[model]; if (!def) { def = modelFieldsCache.cacheDefs[model] = this._rpc({ model: model, method: 'fields_get', args: [ false, ["store", "searchable", "type", "string", "relation", "selection", "related"] ], context: this.getSession().user_context, }) .then((function (fields) { modelFieldsCache.cache[model] = sortFields(fields, model); }).bind(this)); } return def.then((function () { return _.filter(modelFieldsCache.cache[model], function (f) { return !filters.searchable || f.searchable; }); }).bind(this)); }, /** * Adds a new page to the popover following the given field relation and * adapts the chain node according to this given field. * * @private * @param {Object} field - the field to add to the chain node */ _goToNextPage: function (field) { if (!_.isEqual(this._getLastPageField(field.name), field)) return; this._validate(true); this._addChainNode(field.name); this._pushPageData(field.relation).then(this._render.bind(this)); }, /** * Removes the last page, adapts the field chain and displays the new * last page. * * @private */ _goToPrevPage: function () { if (this.pages.length <= 0) return; this._validate(true); this._removeChainNode(); if (this.pages.length > 1) { this.pages.pop(); } this._render(); }, /** * Closes the popover and marks the field as selected. If the field chain * changed, it notifies its parents. If not open, this does nothing. * * @private */ _hidePopover: function () { if (!this._isOpen) return; this._isOpen = false; this.$popover.addClass("hidden"); if (this.dirty) { this.dirty = false; this.pages = this.pages.slice(0, this.chain.length || 1); this.trigger_up("field_chain_changed", {chain: this.chain}); } }, /** * Prepares the popover by filling its pages according to the current field * chain. * * @private * @returns {Deferred} resolved once the whole field chain has been * processed */ _prefill: function () { this.pages = []; return this._pushPageData(this.model).then((function () { this._validate(true); return (this.chain.length ? processChain.call(this, this.chain.slice().reverse()) : $.when()); }).bind(this)); function processChain(chain) { var field = this._getLastPageField(chain.pop()); if (field && field.relation && chain.length > 0) { // Fetch next chain node if any and possible return this._pushPageData(field.relation).then(processChain.bind(this, chain)); } else if (field && chain.length === 0) { // Last node fetched return $.when(); } else { // Wrong node chain this._validate(false); } return $.when(); } }, /** * Gets the fields of a particular model and adds them to a new last * popover page. * * @private * @param {string} model - the model name whose fields have to be fetched * @returns {Deferred} resolved once the fields have been added */ _pushPageData: function (model) { var def; if (this.model === model && this.options.fields) { def = $.when(sortFields(this.options.fields, model)); } else { def = this._getModelFieldsFromCache(model, this.options.filters); } return def.then((function (fields) { this.pages.push(fields); }).bind(this)); }, /** * Removes the last field name at the end of the current field chain and * marks it as dirty. * * @private */ _removeChainNode: function () { this.dirty = true; this.chain = this.chain.slice(0, this.pages.length-1); this.chain.pop(); }, /** * Updates the rendering of the value (the serie of tags separated by * arrows). It also adapts the content of the popover. * * @private */ _render: function () { // Render the chain value this.$value.html(core.qweb.render(this.template + ".value", { chain: this.chain, pages: this.pages, })); // Toggle the warning message this.$valid.toggleClass("hidden", !!this.isValid()); // Adapt the popover content var page = _.last(this.pages); var title = ""; if (this.pages.length > 1) { var prevField = _.findWhere(this.pages[this.pages.length - 2], { name: (this.chain.length === this.pages.length) ? this.chain[this.chain.length - 2] : _.last(this.chain), }); if (prevField) title = prevField.string; } this.$(".o_field_selector_popover_header .o_field_selector_title").text(title); this.$(".o_field_selector_page").replaceWith(core.qweb.render(this.template + ".page", { lines: page, followRelations: this.options.followRelations, debug: this.options.debugMode, })); this.$input.val(this.chain.join(".")); }, /** * Selects the given field and adapts the chain node according to it. * It also closes the popover and so notifies the parents about the change. * * @param {Object} field - the field to select */ _selectField: function (field) { if (!_.isEqual(this._getLastPageField(field.name), field)) return; this._validate(true); this._addChainNode(field.name); this._render(); this._hidePopover(); }, /** * Shows the popover to select the field chain. This assumes that the * popover has finished its rendering (fully rendered widget or resolved * deferred of @see setChain). If already open, this does nothing. * * @private */ _showPopover: function () { if (this._isOpen) return; this._isOpen = true; this.$popover.removeClass("hidden"); }, /** * Toggles the valid status of the widget and display the error message if * it is not valid. * * @private * @param {boolean} valid - true if the widget is valid, false otherwise */ _validate: function (valid) { this.valid = !!valid; if (!this.valid) { this.do_warn( _t("Invalid field chain"), _t("The field chain is not valid. Did you maybe use a non-existing field name or followed a non-relational field?") ); } }, //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- /** * Called when the widget is focused -> opens the popover */ _onFocusIn: function () { clearTimeout(this._hidePopoverTimeout); this._showPopover(); }, /** * Called when the widget is blurred -> closes the popover */ _onFocusOut: function () { this._hidePopoverTimeout = _.defer(this._hidePopover.bind(this)); }, /** * Called when the popover "cross" icon is clicked -> closes the popover */ _onCloseClick: function () { this._hidePopover(); }, /** * Called when the popover "previous" icon is clicked -> removes last chain * node */ _onPrevPageClick: function () { this._goToPrevPage(); }, /** * Called when a popover relation field button is clicked -> adds it to * the chain * * @param {Event} e */ _onNextPageClick: function (e) { e.stopPropagation(); this._goToNextPage(this._getLastPageField($(e.currentTarget).data("name"))); }, /** * Called when a popover non-relation field button is clicked -> adds it to * chain and closes the popover * * @param {Event} e */ _onLastFieldClick: function (e) { this._selectField(this._getLastPageField($(e.currentTarget).data("name"))); }, /** * Called when the debug input value is changed -> adapts the chain */ _onInputChange: function () { var userChainStr = this.$input.val(); var userChain = userChainStr.split("."); if (!this.options.followRelations && userChain.length > 1) { this.do_warn(_t("Relation not allowed"), _t("You cannot follow relations for this field chain construction")); userChain = [userChain[0]]; } this.setChain(userChain).then((function () { this.trigger_up("field_chain_changed", {chain: this.chain}); }).bind(this)); }, /** * Called when a popover field button item is hovered -> toggles its * "active" status * * @param {Event} e */ _onItemHover: function (e) { this.$("li.o_field_selector_item").removeClass("active"); $(e.currentTarget).addClass("active"); }, /** * Called when the user uses the keyboard when the widget is focused * -> handles field keyboard navigation * * @param {Event} e */ _onKeydown: function (e) { if (!this.$popover.is(":visible")) return; var inputHasFocus = this.$input.is(":focus"); switch (e.which) { case $.ui.keyCode.UP: case $.ui.keyCode.DOWN: e.preventDefault(); var $active = this.$("li.o_field_selector_item.active"); var $to = $active[e.which === $.ui.keyCode.DOWN ? "next" : "prev"](".o_field_selector_item"); if ($to.length) { $active.removeClass("active"); $to.addClass("active"); this.$popover.focus(); var $page = $to.closest(".o_field_selector_page"); var full_height = $page.height(); var el_position = $to.position().top; var el_height = $to.outerHeight(); var current_scroll = $page.scrollTop(); if (el_position < 0) { $page.scrollTop(current_scroll - el_height); } else if (full_height < el_position + el_height) { $page.scrollTop(current_scroll + el_height); } } break; case $.ui.keyCode.RIGHT: if (inputHasFocus) break; e.preventDefault(); var name = this.$("li.o_field_selector_item.active").data("name"); if (name) { var field = this._getLastPageField(name); if (field.relation) { this._goToNextPage(field); } } break; case $.ui.keyCode.LEFT: if (inputHasFocus) break; e.preventDefault(); this._goToPrevPage(); break; case $.ui.keyCode.ESCAPE: e.stopPropagation(); this._hidePopover(); break; case $.ui.keyCode.ENTER: if (inputHasFocus) break; e.preventDefault(); this._selectField(this._getLastPageField(this.$("li.o_field_selector_item.active").data("name"))); break; } } }); return ModelFieldSelector; /** * Allows to transform a mapping field name -> field info in an array of the * field infos, sorted by field user name ("string" value). The field infos in * the final array contain an additional key "name" with the field name. * * @param {Object} fields - the mapping field name -> field info * @param {string} model * @returns {Object[]} the field infos sorted by field "string" (field infos * contain additional keys "model" and "name" with the field * name) */ function sortFields(fields, model) { return _.chain(fields) .pairs() .sortBy(function (p) { return p[1].string; }) .map(function (p) { return _.extend({ name: p[0], model: model, }, p[1]); }) .value(); } });