flectra/addons/web/static/src/js/views/basic/basic_renderer.js

718 lines
28 KiB
JavaScript

flectra.define('web.BasicRenderer', function (require) {
"use strict";
/**
* The BasicRenderer is an abstract class designed to share code between all
* views that uses a BasicModel. The main goal is to keep track of all field
* widgets, and properly destroy them whenever a rerender is done. The widgets
* and modifiers updates mechanism is also shared in the BasicRenderer.
*/
var AbstractRenderer = require('web.AbstractRenderer');
var config = require('web.config');
var core = require('web.core');
var dom = require('web.dom');
var widgetRegistry = require('web.widget_registry');
var qweb = core.qweb;
var BasicRenderer = AbstractRenderer.extend({
custom_events: {
navigation_move: '_onNavigationMove',
},
/**
* Basic renderers implements the concept of "mode", they can either be in
* readonly mode or editable mode.
*
* @override
*/
init: function (parent, state, params) {
this._super.apply(this, arguments);
this.activeActions = params.activeActions;
this.viewType = params.viewType;
this.mode = params.mode || 'readonly';
this.widgets = [];
},
/**
* This method has two responsabilities: find every invalid fields in the
* current view, and making sure that they are displayed as invalid, by
* toggling the o_form_invalid css class. It has to be done both on the
* widget, and on the label, if any.
*
* @param {string} recordID
* @returns {string[]} the list of invalid field names
*/
canBeSaved: function (recordID) {
var self = this;
var invalidFields = [];
_.each(this.allFieldWidgets[recordID], function (widget) {
var canBeSaved = self._canWidgetBeSaved(widget);
if (!canBeSaved) {
invalidFields.push(widget.name);
}
widget.$el.toggleClass('o_field_invalid', !canBeSaved);
});
return invalidFields;
},
/**
* Calls 'commitChanges' on all field widgets, so that they can notify the
* environment with their current value (useful for widgets that can't
* detect when their value changes or that have to validate their changes
* before notifying them).
*
* @param {string} recordID
* @return {Deferred}
*/
commitChanges: function (recordID) {
var defs = _.map(this.allFieldWidgets[recordID], function (widget) {
return widget.commitChanges();
});
return $.when.apply($, defs);
},
/**
* Updates the internal state of the renderer to the new state. By default,
* this also implements the recomputation of the modifiers and their
* application to the DOM and the reset of the field widgets if needed.
*
* In case the given record is not found anymore, a whole re-rendering is
* completed (possible if a change in a record caused an onchange which
* erased the current record).
*
* We could always rerender the view from scratch, but then it would not be
* as efficient, and we might lose some local state, such as the input focus
* cursor, or the scrolling position.
*
* @param {Object} state
* @param {string} id
* @param {string[]} fields
* @param {FlectraEvent} ev
* @returns {Deferred<AbstractField[]>} resolved with the list of widgets
* that have been reset
*/
confirmChange: function (state, id, fields, ev) {
this.state = state;
var record = state.id === id ? state : _.findWhere(state.data, {id: id});
if (!record) {
return this._render().then(_.constant([]));
}
// reset all widgets (from the <widget> tag) if any:
_.invoke(this.widgets, 'updateState', state);
var defs = [];
// Reset all the field widgets that are marked as changed and the ones
// which are configured to always be reset on any change
var resetWidgets = [];
_.each(this.allFieldWidgets[id], function (widget) {
var fieldChanged = _.contains(fields, widget.name);
if (fieldChanged || widget.resetOnAnyFieldChange) {
defs.push(widget.reset(record, ev, fieldChanged));
resetWidgets.push(widget);
}
});
// The modifiers update is done after widget resets as modifiers
// associated callbacks need to have all the widgets with the proper
// state before evaluation
defs.push(this._updateAllModifiers(record));
return $.when.apply($, defs).then(function () {
return resetWidgets;
});
},
/**
* Activates the widget and move the cursor to the given offset
*
* @param {string} id
* @param {string} fieldName
* @param {integer} offset
*/
focusField: function (id, fieldName, offset) {
this.editRecord(id);
if (typeof offset === "number") {
var field = _.findWhere(this.allFieldWidgets[id], {name: fieldName});
dom.setSelectionRange(field.getFocusableElement().get(0), {start: offset, end: offset});
}
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Add a tooltip on a $node, depending on a field description
*
* @param {FieldWidget} widget
* @param {$node} $node
*/
_addFieldTooltip: function (widget, $node) {
// optional argument $node, the jQuery element on which the tooltip
// should be attached if not given, the tooltip is attached on the
// widget's $el
$node = $node.length ? $node : widget.$el;
$node.tooltip({
delay: { show: 1000, hide: 0 },
title: function () {
return qweb.render('WidgetLabel.tooltip', {
debug: config.debug,
widget: widget,
});
}
});
},
/**
* Activates the widget at the given index for the given record if possible
* or the "next" possible one. Usually, a widget can be activated if it is
* in edit mode, and if it is visible.
*
* @private
* @param {Object} record
* @param {integer} currentIndex
* @param {Object} [options]
* @param {integer} [options.inc=1] - the increment to use when searching for the
* "next" possible one
* @param {boolean} [options.wrap=true] if true, when we arrive at the end of the
* list of widget, we wrap around and try to activate widgets starting at
* the beginning. Otherwise, we just stop trying and return -1
* @returns {integer} the index of the widget that was activated or -1 if
* none was possible to activate
*/
_activateFieldWidget: function (record, currentIndex, options) {
options = options || {};
_.defaults(options, {inc: 1, wrap: true});
var recordWidgets = this.allFieldWidgets[record.id] || [];
for (var i = 0 ; i < recordWidgets.length ; i++) {
var activated = recordWidgets[currentIndex].activate({event: options.event});
if (activated) {
return currentIndex;
}
currentIndex += options.inc;
if (currentIndex >= recordWidgets.length) {
if (options.wrap) {
currentIndex -= recordWidgets.length;
} else {
return -1;
}
} else if (currentIndex < 0) {
if (options.wrap) {
currentIndex += recordWidgets.length;
} else {
return -1;
}
}
}
return -1;
},
/**
* This is a wrapper of the {@see _activateFieldWidget} function to select
* the next possible widget instead of the given one.
*
* @private
* @param {Object} record
* @param {integer} currentIndex
* @return {integer}
*/
_activateNextFieldWidget: function (record, currentIndex) {
currentIndex = (currentIndex + 1) % (this.allFieldWidgets[record.id] || []).length;
return this._activateFieldWidget(record, currentIndex, {inc: 1});
},
/**
* This is a wrapper of the {@see _activateFieldWidget} function to select
* the previous possible widget instead of the given one.
*
* @private
* @param {Object} record
* @param {integer} currentIndex
* @return {integer}
*/
_activatePreviousFieldWidget: function (record, currentIndex) {
currentIndex = currentIndex ? (currentIndex - 1) : ((this.allFieldWidgets[record.id] || []).length - 1);
return this._activateFieldWidget(record, currentIndex, {inc:-1});
},
/**
* Does the necessary DOM updates to match the given modifiers data. The
* modifiers data is supposed to contain the properly evaluated modifiers
* associated to the given records and elements.
*
* @param {Object} modifiersData
* @param {Object} record
* @param {Object} [element] - do the update only on this element if given
*/
_applyModifiers: function (modifiersData, record, element) {
var self = this;
var modifiers = modifiersData.evaluatedModifiers[record.id] || {};
if (element) {
_apply(element);
} else {
// Clone is necessary as the list might change during _.each
_.each(_.clone(modifiersData.elementsByRecord[record.id]), _apply);
}
function _apply(element) {
// If the view is in edit mode and that a widget have to switch
// its "readonly" state, we have to re-render it completely
if ('readonly' in modifiers && element.widget) {
var mode = modifiers.readonly ? 'readonly' : modifiersData.baseModeByRecord[record.id];
if (mode !== element.widget.mode) {
self._rerenderFieldWidget(element.widget, record, {
keepBaseMode: true,
mode: mode,
});
return; // Rerendering already applied the modifiers, no need to go further
}
}
// Toggle modifiers CSS classes if necessary
element.$el.toggleClass("o_invisible_modifier", !!modifiers.invisible);
element.$el.toggleClass("o_readonly_modifier", !!modifiers.readonly);
element.$el.toggleClass("o_required_modifier", !!modifiers.required);
// Call associated callback
if (element.callback) {
element.callback(element, modifiers, record);
}
}
},
/**
* Determines if a given field widget value can be saved. For this to be
* true, the widget must be valid (properly parsed value) and have a value
* if the associated view field is required.
*
* @private
* @param {AbstractField} widget
* @returns {boolean|Deferred<boolean>} @see AbstractField.isValid
*/
_canWidgetBeSaved: function (widget) {
var modifiers = this._getEvaluatedModifiers(widget.__node, widget.record);
return widget.isValid() && (widget.isSet() || !modifiers.required);
},
/**
* Destroys a given widget associated to the given record and removes it
* from internal referencing.
*
* @private
* @param {string} recordID id of the local resource
* @param {AbstractField} widget
* @returns {integer} the index of the removed widget
*/
_destroyFieldWidget: function (recordID, widget) {
var recordWidgets = this.allFieldWidgets[recordID];
var index = recordWidgets.indexOf(widget);
if (index >= 0) {
recordWidgets.splice(index, 1);
}
this._unregisterModifiersElement(widget.__node, recordID, widget);
widget.destroy();
return index;
},
/**
* Searches for the last evaluation of the modifiers associated to the given
* data (modifiers evaluation are supposed to always be up-to-date as soon
* as possible).
*
* @private
* @param {Object} node
* @param {Object} record
* @returns {Object} the evaluated modifiers associated to the given node
* and record (not recomputed by the call)
*/
_getEvaluatedModifiers: function (node, record) {
var element = this._getModifiersData(node);
if (!element) {
return {};
}
return element.evaluatedModifiers[record.id] || {};
},
/**
* Searches through the registered modifiers data for the one which is
* related to the given node.
*
* @private
* @param {Object} node
* @returns {Object|undefined} related modifiers data if any
* undefined otherwise
*/
_getModifiersData: function (node) {
return _.findWhere(this.allModifiersData, {node: node});
},
/**
* @private
* @param {jQueryElement} $el
* @param {Object} node
*/
_handleAttributes: function ($el, node) {
if (node.attrs.class) {
$el.addClass(node.attrs.class);
}
if (node.attrs.style) {
$el.attr('style', node.attrs.style);
}
},
/**
* Used by list and kanban renderers to determine whether or not to display
* the no content helper (if there is no data in the state to display)
*
* @private
* @returns {boolean}
*/
_hasContent: function () {
return this.state.count !== 0;
},
/**
* This function is called each time a field widget is created, when it is
* ready (after its willStart and Start methods are complete). This is the
* place where work having to do with $el should be done.
*
* @private
* @param {Widget} widget the field widget instance
* @param {Object} node the attrs coming from the arch
*/
_postProcessField: function (widget, node) {
},
/**
* Registers or updates the modifiers data associated to the given node.
* This method is quiet complex as it handles all the needs of the basic
* renderers:
*
* - On first registration, the modifiers are evaluated thanks to the given
* record. This allows nodes that will produce an AbstractField instance
* to have their modifiers registered before this field creation as we
* need the readonly modifier to be able to instantiate the AbstractField.
*
* - On additional registrations, if the node was already registered but the
* record is different, we evaluate the modifiers for this record and
* saves them in the same object (without reparsing the modifiers).
*
* - On additional registrations, the modifiers are not reparsed (or
* reevaluated for an already seen record) but the given widget or DOM
* element is associated to the node modifiers.
*
* - The new elements are immediately adapted to match the modifiers and the
* given associated callback is called even if there is no modifiers on
* the node (@see _applyModifiers). This is indeed necessary as the
* callback is a description of what to do when a modifier changes. Even
* if there is no modifiers, this action must be performed on first
* rendering to avoid code duplication. If there is no modifiers, they
* will however not be registered for modifiers updates.
*
* - When a new element is given, it does not replace the old one, it is
* added as an additional element. This is indeed useful for nodes that
* will produce multiple DOM (as a list cell and its internal widget or
* a form field and its associated label).
* (@see _unregisterModifiersElement for removing an associated element.)
*
* Note: also on view rerendering, all the modifiers are forgotten so that
* the renderer only keeps the ones associated to the current DOM state.
*
* @private
* @param {Object} node
* @param {Object} record
* @param {jQuery|AbstractField} [element]
* @param {Object} [options]
* @param {Object} [options.callback] the callback to call on registration
* and on modifiers updates
* @param {boolean} [options.keepBaseMode=false] this function registers the
* 'baseMode' of the node on a per record basis;
* this is a field widget specific settings which
* represents the generic mode of the widget, regardless of its modifiers
* (the interesting case is the list view: all widgets are supposed to be
* in the baseMode 'readonly', except the ones that are in the line that
* is currently being edited).
* With option 'keepBaseMode' set to true, the baseMode of the record's
* node isn't overridden (this is particularily useful when a field widget
* is re-rendered because its readonly modifier changed, as in this case,
* we don't want to change its base mode).
* @param {string} [options.mode] the 'baseMode' of the record's node is set to this
* value (if not given, it is set to this.mode, the mode of the renderer)
* @returns {Object} for code efficiency, returns the last evaluated
* modifiers for the given node and record.
*/
_registerModifiers: function (node, record, element, options) {
options = options || {};
// Check if we already registered the modifiers for the given node
// If yes, this is simply an update of the related element
// If not, check the modifiers to see if it needs registration
var modifiersData = this._getModifiersData(node);
if (!modifiersData) {
var modifiers = node.attrs.modifiers || {};
modifiersData = {
node: node,
modifiers: modifiers,
evaluatedModifiers: {},
elementsByRecord: {},
baseModeByRecord : {},
};
if (!_.isEmpty(modifiers)) { // Register only if modifiers might change (TODO condition might be improved here)
this.allModifiersData.push(modifiersData);
}
}
// Compute the record's base mode
if (!modifiersData.baseModeByRecord[record.id] || !options.keepBaseMode) {
modifiersData.baseModeByRecord[record.id] = options.mode || this.mode;
}
// Evaluate if necessary
if (!modifiersData.evaluatedModifiers[record.id]) {
modifiersData.evaluatedModifiers[record.id] = record.evalModifiers(modifiersData.modifiers);
}
// Element might not be given yet (a second call to the function can
// update the registration with the element)
if (element) {
var newElement = {};
if (element instanceof jQuery) {
newElement.$el = element;
} else {
newElement.widget = element;
newElement.$el = element.$el;
}
if (options && options.callback) {
newElement.callback = options.callback;
}
if (!modifiersData.elementsByRecord[record.id]) {
modifiersData.elementsByRecord[record.id] = [];
}
modifiersData.elementsByRecord[record.id].push(newElement);
this._applyModifiers(modifiersData, record, newElement, options);
}
return modifiersData.evaluatedModifiers[record.id];
},
/**
* Render the view
*
* @override
* @returns {Deferred}
*/
_render: function () {
var oldAllFieldWidgets = this.allFieldWidgets;
this.allFieldWidgets = {}; // TODO maybe merging allFieldWidgets and allModifiersData into "nodesData" in some way could be great
this.allModifiersData = [];
return this._renderView().then(function () {
_.each(oldAllFieldWidgets, function (recordWidgets) {
_.each(recordWidgets, function (widget) {
widget.destroy();
});
});
});
},
/**
* Instantiates the appropriate AbstractField specialization for the given
* node and prepares its rendering and addition to the DOM. Indeed, the
* rendering of the widget will be started and the associated deferred will
* be added to the 'defs' attribute. This is supposed to be created and
* deleted by the calling code if necessary.
* Note: for this implementation to work, AbstractField willStart methods
* *must* be synchronous.
*
* @private
* @param {Object} node
* @param {Object} record
* @param {Object} [options] passed to @_registerModifiers
* @param {string} [options.mode] either 'edit' or 'readonly' (defaults to
* this.mode, the mode of the renderer)
* @returns {AbstractField}
*/
_renderFieldWidget: function (node, record, options) {
options = options || {};
var fieldName = node.attrs.name;
// Register the node-associated modifiers
var mode = options.mode || this.mode;
var modifiers = this._registerModifiers(node, record, null, options);
// Initialize and register the widget
// Readonly status is known as the modifiers have just been registered
var Widget = record.fieldsInfo[this.viewType][fieldName].Widget;
var widget = new Widget(this, fieldName, record, {
mode: modifiers.readonly ? 'readonly' : mode,
viewType: this.viewType,
});
// Register the widget so that it can easily be found again
if (this.allFieldWidgets[record.id] === undefined) {
this.allFieldWidgets[record.id] = [];
}
this.allFieldWidgets[record.id].push(widget);
widget.__node = node; // TODO get rid of this if possible one day
// Prepare widget rendering and save the related deferred
var def = widget.__widgetRenderAndInsert(function () {});
if (def.state() === 'pending') {
this.defs.push(def);
}
// Update the modifiers registration by associating the widget and by
// giving the modifiers options now (as the potential callback is
// associated to new widget)
var self = this;
def.then(function () {
self._registerModifiers(node, record, widget, {
callback: function (element, modifiers, record) {
element.$el.toggleClass('o_field_empty', !!(
record.data.id
&& (modifiers.readonly || mode === 'readonly')
&& !element.widget.isSet()
));
},
keepBaseMode: !!options.keepBaseMode,
mode: mode,
});
self._postProcessField(widget, node);
});
return widget;
},
/**
* Renders the nocontent helper.
*
* This method is a helper for renderers that want to display a help
* message when no content is available.
*
* @private
*/
_renderNoContentHelper: function () {
var $msg = $('<div>')
.addClass('oe_view_nocontent')
.html(this.noContentHelp);
this.$el.html($msg);
},
/**
* Actual rendering. Supposed to be overridden by concrete renderers.
* The basic responsabilities of _renderView are:
* - use the xml arch of the view to render a jQuery representation
* - instantiate a widget from the registry for each field in the arch
*
* Note that the 'state' field should contains all necessary information
* for the rendering. The field widgets should be as synchronous as
* possible.
*
* @abstract
* @returns {Deferred}
*/
_renderView: function () {
return $.when();
},
/**
* Instantiate custom widgets
*
* @private
* @param {Object} record
* @param {Object} node
* @returns {jQueryElement}
*/
_renderWidget: function (record, node) {
var Widget = widgetRegistry.get(node.attrs.name);
var widget = new Widget(this, record, node);
this.widgets.push(widget);
// Prepare widget rendering and save the related deferred
var def = widget.__widgetRenderAndInsert(function () {});
if (def.state() === 'pending') {
this.defs.push(def);
}
// handle other attributes/modifiers
this._handleAttributes(widget.$el, node);
this._registerModifiers(node, record, widget);
widget.$el.addClass('o_widget');
return widget.$el;
},
/**
* Rerenders a given widget and make sure the associated data which
* referenced the old one is updated.
*
* @private
* @param {Widget} widget
* @param {Object} record
* @param {Object} [options] options passed to @_renderFieldWidget
* @returns {AbstractField}
*/
_rerenderFieldWidget: function (widget, record, options) {
// Render the new field widget
var newWidget = this._renderFieldWidget(widget.__node, record, options);
widget.$el.replaceWith(newWidget.$el);
// Destroy the old widget and position the new one at the old one's
var oldIndex = this._destroyFieldWidget(record.id, widget);
var recordWidgets = this.allFieldWidgets[record.id];
recordWidgets.splice(oldIndex, 0, newWidget);
recordWidgets.pop();
return newWidget;
},
/**
* Unregisters an element of the modifiers data associated to the given
* node and record.
*
* @param {Object} node
* @param {string} recordID id of the local resource
* @param {jQuery|AbstractField} element
*/
_unregisterModifiersElement: function (node, recordID, element) {
var modifiersData = this._getModifiersData(node);
if (modifiersData) {
var elements = modifiersData.elementsByRecord[recordID];
var index = _.findIndex(elements, function (oldElement) {
return oldElement.widget === element
|| oldElement.$el[0] === element[0];
});
if (index >= 0) {
elements.splice(index, 1);
}
}
},
/**
* Does two actions, for each registered modifiers:
* 1) Recomputes the modifiers associated to the given record and saves them
* (as boolean values) in the appropriate modifiers data.
* 2) Updates the rendering of the view elements associated to the given
* record to match the new modifiers.
*
* @see _applyModifiers
*
* @private
* @param {Object} record
* @returns {Deferred} resolved once finished
*/
_updateAllModifiers: function (record) {
var self = this;
var defs = [];
this.defs = defs; // Potentially filled by widget rerendering
_.each(this.allModifiersData, function (modifiersData) {
modifiersData.evaluatedModifiers[record.id] = record.evalModifiers(modifiersData.modifiers);
self._applyModifiers(modifiersData, record);
});
delete this.defs;
return $.when.apply($, defs);
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* When someone presses the TAB/UP/DOWN/... key in a widget, it is nice to
* be able to navigate in the view (default browser behaviors are disabled
* by Flectra).
*
* @abstract
* @private
* @param {FlectraEvent} ev
*/
_onNavigationMove: function (ev) {},
});
return BasicRenderer;
});