560 lines
19 KiB
JavaScript
560 lines
19 KiB
JavaScript
flectra.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();
|
|
}
|
|
});
|