odoo.define('web.AbstractFieldOwl', function (require) { "use strict"; const field_utils = require('web.field_utils'); const { useListener } = require('web.custom_hooks'); const { onMounted, onPatched } = owl.hooks; /** * This file defines the Owl version of the AbstractField. Specific fields * written in Owl should override this component. * * ========================================================================= * * /!\ This api works almost exactly like the legacy one but * /!\ it still could change! There are already a few methods that will be * /!\ removed like setIdForLabel, setInvalidClass, etc.. * * ========================================================================= * * This is the basic field component used by all the views to render a field in a view. * These field components are mostly common to all views, in particular form and list * views. * * The responsabilities of a field component are mainly: * - render a visual representation of the current value of a field * - that representation is either in 'readonly' or in 'edit' mode * - notify the rest of the system when the field has been changed by * the user (in edit mode) * * Notes * - the component is not supposed to be able to switch between modes. If another * mode is required, the view will take care of instantiating another component. * - notify the system when its value has changed and its mode is changed to 'readonly' * - notify the system when some action has to be taken, such as opening a record * - the Field component should not, ever, under any circumstance, be aware of * its parent. The way it communicates changes with the rest of the system is by * triggering events. These events bubble up and are interpreted * by the most appropriate parent. * * Also, in some cases, it may not be practical to have the same component for all * views. In that situation, you can have a 'view specific component'. Just register * the component in the registry prefixed by the view type and a dot. So, for example, * a form specific many2one component should be registered as 'form.many2one'. * * @module web.AbstractFieldOwl */ class AbstractField extends owl.Component { /** * Abstract field class * * @constructor * @param {Component} parent * @param {Object} props * @param {string} props.fieldName The field name defined in the model * @param {Object} props.record A record object (result of the get method * of a basic model) * @param {Object} [props.options] * @param {string} [props.options.mode=readonly] should be 'readonly' or 'edit' * @param {string} [props.options.viewType=default] */ constructor() { super(...arguments); this._isValid = true; // this is the last value that was set by the user, unparsed. This is // used to avoid setting the value twice in a row with the exact value. this._lastSetValue = undefined; useListener('keydown', this._onKeydown); useListener('navigation-move', this._onNavigationMove); onMounted(() => this._applyDecorations()); onPatched(() => this._applyDecorations()); } /** * Hack: studio tries to find the field with a selector base on its * name, before it is mounted into the DOM. Ideally, this should be * done in the onMounted hook, but in this case we are too late, and * Studio finds nothing. As a consequence, the field can't be edited * by clicking on its label (or on the row formed by the pair label-field). * * TODO: move this to mounted at some point? * * @override */ __patch() { const res = super.__patch(...arguments); this.el.setAttribute('name', this.name); this.el.classList.add('o_field_widget'); return res; } /** * @async * @param {Object} [nextProps] * @returns {Promise} */ async willUpdateProps(nextProps) { this._lastSetValue = undefined; return super.willUpdateProps(nextProps); } //---------------------------------------------------------------------- // Getters //---------------------------------------------------------------------- /** * This contains the attributes to pass through the context. * * @returns {Object} */ get additionalContext() { return this.options.additionalContext || {}; } /** * This contains the attributes of the xml 'field' tag, the inner views... * * @returns {Object} */ get attrs() { const fieldsInfo = this.record.fieldsInfo[this.viewType]; return this.options.attrs || (fieldsInfo && fieldsInfo[this.name]) || {}; } /** * Id corresponding to the current record in the model. * Its intended use is to be able to tag any messages going upstream, * so the view knows which records was changed for example. * * @returns {string} */ get dataPointId() { return this.record.id; } /** * This is a description of all the various field properties, * such as the type, the comodel (relation), ... * * @returns {string} */ get field() { return this.record.fields[this.name]; } /** * Returns the main field's DOM element which can be focused by the browser. * * @returns {HTMLElement|null} main focusable element inside the component */ get focusableElement() { return null; } /** * Returns the additional options pass to the format function. * Override this getter to add options. * * @returns {Object} */ get formatOptions() { return {}; } /** * Used to determine which format (and parse) functions * to call to format the field's value to insert into the DOM (typically * put into a span or an input), and to parse the value from the input * to send it to the server. These functions are chosen according to * the 'widget' attrs if is is given, and if it is a valid key, with a * fallback on the field type, ensuring that the value is formatted and * displayed according to the chosen widget, if any. * * @returns {string} */ get formatType() { return this.attrs.widget in field_utils.format ? this.attrs.widget : this.field.type; } /** * Returns whether or not the field is empty and can thus be hidden. This * method is typically called when the component is in readonly, to hide it * (and its label) if it is empty. * * @returns {boolean} */ get isEmpty() { return !this.isSet; } /** * Returns true if the component has a visible element that can take the focus * * @returns {boolean} */ get isFocusable() { const focusable = this.focusableElement; // check if element is visible return focusable && !!(focusable.offsetWidth || focusable.offsetHeight || focusable.getClientRects().length); } /** * Determines if the field value is set to a meaningful * value. This is useful to determine if a field should be displayed as empty * * @returns {boolean} */ get isSet() { return !!this.value; } /** * Tracks if the component is in a valid state, meaning that the current * value represented in the DOM is a value that can be parsed and saved. * For example, a float field can only use a number and not a string. * * @returns {boolean} */ get isValid() { return this._isValid; } /** * Fields can be in two modes: 'edit' or 'readonly'. * * @returns {string} */ get mode() { return this.options.mode || "readonly"; } /** * Useful mostly to trigger rpcs on the correct model. * * @returns {string} */ get model() { return this.record.model; } /** * The field name displayed by this component. * * @returns {string} */ get name() { return this.props.fieldName; } /** * Component can often be configured in the 'options' attribute in the * xml 'field' tag. These options are saved (and evaled) in nodeOptions. * * @returns {Object} */ get nodeOptions() { return this.attrs.options || {}; } /** * @returns {Object} */ get options() { return this.props.options || {}; } /** * Returns the additional options passed to the parse function. * Override this getter to add options. * * @returns {Object} */ get parseOptions() { return {}; } /** * The datapoint fetched from the model. * * @returns {Object} */ get record() { return this.props.record; } /** * Tracks the values for the other fields for the same record. * note that it is expected to be mostly a readonly property, you cannot * use this to try to change other fields value, this is not how it is * supposed to work. Also, do not use this.recordData[this.name] to get * the current value, this could be out of sync after a _setValue. * * @returns {Object} */ get recordData() { return this.record.data; } /** * If this flag is set to true, the field component will be reset on * every change which is made in the view (if the view supports it). * This is currently a form view feature. * * /!\ This getter could be removed when basic views (form, list, kanban) * are converted. * * @returns {boolean} */ get resetOnAnyFieldChange() { return !!this.attrs.decorations; } /** * The res_id of the record in database. * When the user is creating a new record, there is no res_id. * When the record will be created, the field component will * be destroyed (when the form view switches to readonly mode) and a * new component with a res_id in mode readonly will be created. * * @returns {Number} */ get resId() { return this.record.res_id; } /** * Human readable (and translated) description of the field. * Mostly useful to be displayed in various places in the * UI, such as tooltips or create dialogs. * * @returns {string} */ get string() { return this.attrs.string || this.field.string || this.name; } /** * Tracks the current (parsed if needed) value of the field. * * @returns {any} */ get value() { return this.record.data[this.name]; } /** * The type of the view in which the field component is instantiated. * For standalone components, a 'default' viewType is set. * * @returns {string} */ get viewType() { return this.options.viewType || 'default'; } //---------------------------------------------------------------------- // Public //---------------------------------------------------------------------- /** * Activates the field component. By default, activation means focusing and * selecting (if possible) the associated focusable element. The selecting * part can be disabled. In that case, note that the focused input/textarea * will have the cursor at the very end. * * @param {Object} [options] * @param {boolean} [options.noselect=false] if false and the input * is of type text or textarea, the content will also be selected * @param {Event} [options.event] the event which fired this activation * @returns {boolean} true if the component was activated, false if the * focusable element was not found or invisible */ activate(options) { if (this.isFocusable) { const focusable = this.focusableElement; focusable.focus(); if (focusable.matches('input[type="text"], textarea')) { focusable.selectionStart = focusable.selectionEnd = focusable.value.length; if (options && !options.noselect) { focusable.select(); } } return true; } return false; } /** * This function should be implemented by components that are not able to * notify their environment when their value changes (maybe because their * are not aware of the changes) or that may have a value in a temporary * state (maybe because some action should be performed to validate it * before notifying it). This is typically called before trying to save the * component's value, so it should call _setValue() to notify the environment * if the value changed but was not notified. * * @abstract * @returns {Promise|undefined} */ commitChanges() {} /** * Remove the invalid class on a field * * This function should be removed when BasicRenderer will be rewritten in owl */ removeInvalidClass() { this.el.classList.remove('o_field_invalid'); this.el.removeAttribute('aria-invalid'); } /** * Sets the given id on the focusable element of the field and as 'for' * attribute of potential internal labels. * * This function should be removed when BasicRenderer will be rewritten in owl * * @param {string} id */ setIdForLabel(id) { if (this.focusableElement) { this.focusableElement.setAttribute('id', id); } } /** * add the invalid class on a field * * This function should be removed when BasicRenderer will be rewritten in owl */ setInvalidClass() { this.el.classList.add('o_field_invalid'); this.el.setAttribute('aria-invalid', 'true'); } //---------------------------------------------------------------------- // Private //---------------------------------------------------------------------- /** * Apply field decorations (only if field-specific decorations have been * defined in an attribute). * * This function should be removed when BasicRenderer will be rewritten in owl * * @private */ _applyDecorations() { for (const dec of this.attrs.decorations || []) { const isToggled = py.PY_isTrue( py.evaluate(dec.expression, this.record.evalContext) ); const className = this._getClassFromDecoration(dec.name); this.el.classList.toggle(className, isToggled); } } /** * Converts the value from the field to a string representation. * * @private * @param {any} value (from the field type) * @returns {string} */ _formatValue(value) { const options = Object.assign({}, this.nodeOptions, { data: this.recordData }, this.formatOptions); return field_utils.format[this.formatType](value, this.field, options); } /** * Returns the className corresponding to a given decoration. A * decoration is of the form 'decoration-%s'. By default, replaces * 'decoration' by 'text'. * * @private * @param {string} decoration must be of the form 'decoration-%s' * @returns {string} */ _getClassFromDecoration(decoration) { return `text-${decoration.split('-')[1]}`; } /** * Compares the given value with the last value that has been set. * Note that we compare unparsed values. Handles the special case where no * value has been set yet, and the given value is the empty string. * * @private * @param {any} value * @returns {boolean} true iff values are the same */ _isLastSetValue(value) { return this._lastSetValue === value || (this.value === false && value === ''); } /** * This method check if a value is the same as the current value of the * field. For example, a fieldDate component might want to use the moment * specific value isSame instead of ===. * * This method is used by the _setValue method. * * @private * @param {any} value * @returns {boolean} */ _isSameValue(value) { return this.value === value; } /** * Converts a string representation to a valid value. * * @private * @param {string} value * @returns {any} */ _parseValue(value) { return field_utils.parse[this.formatType](value, this.field, this.parseOptions); } /** * This method is called by the component, to change its value and to notify * the outside world of its new state. This method also validates the new * value. Note that this method does not rerender the component, it should be * handled by the component itself, if necessary. * * @private * @param {any} value * @param {Object} [options] * @param {boolean} [options.doNotSetDirty=false] if true, the basic model * will not consider that this field is dirty, even though it was changed. * Please do not use this flag unless you really need it. Our only use * case is currently the pad component, which does a _setValue in the * renderEdit method. * @param {boolean} [options.notifyChange=true] if false, the basic model * will not notify and not trigger the onchange, even though it was changed. * @param {boolean} [options.forceChange=false] if true, the change event will be * triggered even if the new value is the same as the old one * @returns {Promise} */ _setValue(value, options) { // we try to avoid doing useless work, if the value given has not changed. if (this._isLastSetValue(value)) { return Promise.resolve(); } this._lastSetValue = value; try { value = this._parseValue(value); this._isValid = true; } catch (e) { this._isValid = false; this.trigger('set-dirty', {dataPointID: this.dataPointId}); return Promise.reject({message: "Value set is not valid"}); } if (!(options && options.forceChange) && this._isSameValue(value)) { return Promise.resolve(); } return new Promise((resolve, reject) => { const changes = {}; changes[this.name] = value; this.trigger('field-changed', { dataPointID: this.dataPointId, changes: changes, viewType: this.viewType, doNotSetDirty: options && options.doNotSetDirty, notifyChange: !options || options.notifyChange !== false, allowWarning: options && options.allowWarning, onSuccess: resolve, onFailure: reject, }); }); } //---------------------------------------------------------------------- // Handlers //---------------------------------------------------------------------- /** * Intercepts navigation keyboard events to prevent their default behavior * and notifies the view so that it can handle it its own way. * * @private * @param {KeyEvent} ev */ _onKeydown(ev) { switch (ev.which) { case $.ui.keyCode.TAB: this.trigger('navigation-move', { direction: ev.shiftKey ? 'previous' : 'next', originalEvent: ev, }); break; case $.ui.keyCode.ENTER: // We preventDefault the ENTER key because of two coexisting behaviours: // - In HTML5, pressing ENTER on a