diff --git a/addons/web/static/src/js/fields/abstract_field_owl.js b/addons/web/static/src/js/fields/abstract_field_owl.js new file mode 100644 index 0000000000000000000000000000000000000000..c9beafc9203e08f6599195b0d3e67af3c98bc955 --- /dev/null +++ b/addons/web/static/src/js/fields/abstract_field_owl.js @@ -0,0 +1,521 @@ +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. + * + * Note that the API is not complete yet. Some features may not work properly + * yet (e.g. part of keyboard navigation, invalid fields notification). + * + * This is the basic field widget used by all the views to render a field in a view. + * These field widgets are mostly common to all views, in particular form and list + * views. + * + * The responsabilities of a field widget 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 widget is not supposed to be able to switch between modes. If another + * mode is required, the view will take care of instantiating another widget. + * - 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 widget 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 (with trigger_up). 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 widget for all + * views. In that situation, you can have a 'view specific widget'. Just register + * the widget in the registry prefixed by the view type and a dot. So, for example, + * a form specific many2one widget should be registered as 'form.many2one'. + * + * @module web.AbstractFieldOwl + */ + class AbstractField extends owl.Component { + /** + * Abstract field class + * + * @constructor + * @param {Widget} parent + * @param {string} name The field name defined in the model + * @param {Object} record A record object (result of the get method of + * a basic model) + * @param {Object} [options] + * @param {string} [options.mode=readonly] should be 'readonly' or 'edit' + */ + constructor(parent, props) { + super(parent, props); + + const options = Object.assign({}, props.options); + const record = props.record; + // 'name' is the field name displayed by this widget + this.name = props.fieldName; + + // the datapoint fetched from the model + this.record = record; + + // the 'field' property is a description of all the various field properties, + // such as the type, the comodel (relation), ... + this.field = record.fields[this.name]; + + // the 'viewType' is the type of the view in which the field widget is + // instantiated. For standalone widgets, a 'default' viewType is set. + this.viewType = options.viewType || 'default'; + + // the 'attrs' property contains the attributes of the xml 'field' tag, + // the inner views... + const fieldsInfo = record.fieldsInfo[this.viewType]; + this.attrs = options.attrs || (fieldsInfo && fieldsInfo[this.name]) || {}; + + // the 'additionalContext' property contains the attributes to pass through the context. + this.additionalContext = options.additionalContext || {}; + + // this property tracks the current (parsed if needed) value of the field. + // Note that we don't use an event system anymore, using this.get('value') + // is no longer valid. + this.value = record.data[this.name]; + + // recordData 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. + this.recordData = record.data; + + // the 'string' property is a 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. + this.string = this.attrs.string || this.field.string || this.name; + + // Widget can often be configured in the 'options' attribute in the + // xml 'field' tag. These options are saved (and evaled) in nodeOptions + this.nodeOptions = this.attrs.options || {}; + + // dataPointID is the 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. + this.dataPointID = record.id; + + // this is the res_id for the record in database. Obviously, it is + // readonly. Also, when the user is creating a new record, there is + // no res_id. When the record will be created, the field widget will + // be destroyed (when the form view switches to readonly mode) and a new + // widget with a res_id in mode readonly will be created. + this.res_id = record.res_id; + + // useful mostly to trigger rpcs on the correct model + this.model = record.model; + + // a widget can be in two modes: 'edit' or 'readonly'. This mode should + // never be changed, if a view changes its mode, it will destroy and + // recreate a new field widget. + this.mode = options.mode || "readonly"; + + // this flag tracks if the widget 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. + 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; + + // formatType is 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. + this.formatType = this.attrs.widget in field_utils.format ? + this.attrs.widget : + this.field.type; + // formatOptions (resp. parseOptions) is a dict of options passed to + // calls to the format (resp. parse) function. + this.formatOptions = {}; + this.parseOptions = {}; + + // if we add decorations, we need to reevaluate the field whenever any + // value from the record is changed + if (this.attrs.decorations) { + this.resetOnAnyFieldChange = true; + } + + 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] + * @param {Object} [nextProps.record] + * @param {Object} [nextProps.event] + * @returns {Promise} + */ + async willUpdateProps(nextProps) { + this.record = nextProps.record; + this.recordData = this.record.data; + this.value = this.recordData[this.name]; + this.lastSetValue = undefined; + return super.willUpdateProps(nextProps); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Returns the main field's DOM element (jQuery form) which can be focused + * by the browser. + * + * @returns {HTMLElement|null} main focusable element inside the widget + */ + get focusableElement() { + return null; + } + /** + * Returns whether or not the field is empty and can thus be hidden. This + * method is typically called when the widget is in readonly, to hide it + * (and its label) if it is empty. + * + * @returns {boolean} + */ + get isEmpty() { + return !this.isSet; + } + /** + * Returns true if the widget 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); + } + /** + * @returns {boolean} + */ + get isSet() { + return !!this.value; + } + /** + * @returns {boolean} + */ + get isValid() { + return this._isValid; + } + /** + * Activates the field widget. 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 widget 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 widgets 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 + * widget's value, so it should call _setValue() to notify the environment + * if the value changed but was not notified. + * + * @abstract + * @returns {Promise|undefined} + */ + commitChanges() {} + /** + * Sets the given id on the focusable element of the field and as 'for' + * attribute of potential internal labels. + * + * @param {string} id + */ + setIDForLabel(id) { + if (this.focusableElement) { + this.focusableElement.setAttribute('id', id); + } + } + /** + * Update the modifiers with the newest value. + * Now this.attrs.modifiersValue can be used consistantly even with + * conditional modifiers inside field widgets, and without needing new + * events or synchronization between the widgets, renderer and controller + * + * @param {Object | null} modifiers the updated modifiers + * @override + */ + updateModifiersValue(modifiers) { + this.attrs.modifiersValue = modifiers || {}; + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Apply field decorations (only if field-specific decorations have been + * defined in an attribute). + * + * @private + */ + _applyDecorations() { + for (const dec of this.attrs.decorations || []) { + const isToggled = py.PY_isTrue( + py.evaluate(dec.expression, this.record.evalContext) + ); + this.el.classList.toggle(dec.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 = _.extend({}, this.nodeOptions, + { data: this.recordData }, this.formatOptions); + return field_utils.format[this.formatType](value, this.field, options); + } + /** + * This method check if a value is the same as the current value of the + * field. For example, a fieldDate widget 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 widget, 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 widget, it should be + * handled by the widget 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 widget, 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. Note that we compare the unparsed values. + if (this.lastSetValue === value || (this.value === false && 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. + * + * Note: the navigation keyboard events are stopped so that potential parent + * abstract field does not trigger the navigation_move event a second time. + * However, this might be controversial, we might wanna let the event + * continue its propagation and flag it to say that navigation has already + * been handled (TODO ?). + * + * @private + * @param {KeyEvent} ev + */ + _onKeydown(ev) { + switch (ev.which) { + case $.ui.keyCode.TAB: + this.trigger('navigation-move', { + direction: ev.shiftKey ? 'previous' : 'next', + }); + // TODO: stop/prevent original event if navigation-move event + // has been handled + break; + case $.ui.keyCode.ENTER: + // We preventDefault the ENTER key because of two coexisting behaviours: + // - In HTML5, pressing ENTER on a <button> triggers two events: a 'keydown' AND a 'click' + // - When creating and opening a dialog, the focus is automatically given to the primary button + // The end result caused some issues where a modal opened by an ENTER keypress (e.g. saving + // changes in multiple edition) confirmed the modal without any intentionnal user input. + ev.preventDefault(); + ev.stopPropagation(); + this.trigger('navigation-move', {direction: 'next_line'}); + break; + case $.ui.keyCode.ESCAPE: + this.trigger('navigation-move', {direction: 'cancel', originalEvent: ev}); + break; + case $.ui.keyCode.UP: + ev.stopPropagation(); + this.trigger('navigation-move', {direction: 'up'}); + break; + case $.ui.keyCode.RIGHT: + ev.stopPropagation(); + this.trigger('navigation-move', {direction: 'right'}); + break; + case $.ui.keyCode.DOWN: + ev.stopPropagation(); + this.trigger('navigation-move', {direction: 'down'}); + break; + case $.ui.keyCode.LEFT: + ev.stopPropagation(); + this.trigger('navigation-move', {direction: 'left'}); + break; + } + } + /** + * Updates the target data value with the current AbstractField instance. + * This allows to consider the parent field in case of nested fields. The + * field which triggered the event is still accessible through ev.target. + * + * @private + * @param {CustomEvent} ev + */ + _onNavigationMove(ev) { + ev.detail.target = this; + } + } + + /** + * An object representing fields to be fetched by the model eventhough not present in the view + * This object contains "field name" as key and an object as value. + * That value object must contain the key "type" + * see FieldBinaryImage for an example. + */ + AbstractField.fieldDependencies = {}; + /** + * If this flag is set to true, the field widget will be reset on every + * change which is made in the view (if the view supports it). This is + * currently a form view feature. + */ + AbstractField.resetOnAnyFieldChange = false; + /** + * If this flag is given a string, the related BasicModel will be used to + * initialize specialData the field might need. This data will be available + * through this.record.specialData[this.name]. + * + * @see BasicModel._fetchSpecialData + */ + AbstractField.specialData = false; + /** + * to override to indicate which field types are supported by the widget + * + * @type Array<String> + */ + AbstractField.supportedFieldTypes = []; + /** + * To override to give a user friendly name to the widget. + * + * @type <string> + */ + AbstractField.description = ""; + /** + * Currently only used in list view. + * If this flag is set to true, the list column name will be empty. + */ + AbstractField.noLabel = false; + + return AbstractField; +}); diff --git a/addons/web/static/src/js/fields/field_wrapper.js b/addons/web/static/src/js/fields/field_wrapper.js new file mode 100644 index 0000000000000000000000000000000000000000..0b808d0c732f9bfe19f57eb44ed29c0599aa011f --- /dev/null +++ b/addons/web/static/src/js/fields/field_wrapper.js @@ -0,0 +1,126 @@ +odoo.define('web.FieldWrapper', function (require) { + "use strict"; + + const { ComponentWrapper } = require('web.OwlCompatibility'); + const field_utils = require('web.field_utils'); + + /** + * This file defines the FieldWrapper component, an extension of ComponentWrapper, + * needed to instanciate Owl fields inside legacy widgets. This component + * will be no longer necessary as soon as all legacy widgets using fields will + * be rewritten in Owl. + */ + class FieldWrapper extends ComponentWrapper { + constructor() { + super(...arguments); + + this._data = {}; + + const options = this.props.options || {}; + const record = this.props.record; + this._data.name = this.props.fieldName; + this._data.record = record; + this._data.field = record.fields[this._data.name]; + this._data.viewType = options.viewType || 'default'; + const fieldsInfo = record.fieldsInfo[this._data.viewType]; + this._data.attrs = options.attrs || (fieldsInfo && fieldsInfo[this._data.name]) || {}; + this._data.additionalContext = options.additionalContext || {}; + this._data.value = record.data[this._data.name]; + this._data.recordData = record.data; + this._data.string = this._data.attrs.string || this._data.field.string || this._data.name; + this._data.nodeOptions = this._data.attrs.options || {}; + this._data.dataPointID = record.id; + this._data.res_id = record.res_id; + this._data.model = record.model; + this._data.mode = options.mode || "readonly"; + this._data._isValid = true; + this._data.lastSetValue = undefined; + this._data.formatType = this._data.attrs.widget in field_utils.format ? + this._data.attrs.widget : + this._data.field.type; + this._data.formatOptions = {}; + this._data.parseOptions = {}; + if (this._data.attrs.decorations) { + this._data.resetOnAnyFieldChange = true; + } + for (const key in this._data) { + Object.defineProperty(this, key, { + get: () => (this.el ? this.componentRef.comp : this._data)[key], + }); + } + } + + //---------------------------------------------------------------------- + // Getters + //---------------------------------------------------------------------- + + get $el() { + return $(this.el); + } + get fieldDependencies() { + return this.Component.fieldDependencies; + } + get resetOnAnyFieldChange() { + return this.Component.resetOnAnyFieldChange; + } + get specialData() { + return this.Component.specialData; + } + get supportedFieldTypes() { + return this.Component.supportedFieldTypes; + } + get description() { + return this.Component.description; + } + get noLabel() { + return this.Component.noLabel; + } + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + activate(options) { + return this.componentRef.comp.activate(options); + } + commitChanges() { + return this.componentRef.comp.commitChanges(); + } + getFocusableElement() { + return $(this.componentRef.comp.focusableElement); + } + isEmpty() { + return this.componentRef.comp.isEmpty; + } + isFocusable() { + return this.componentRef.comp.isFocusable; + } + isSet() { + if (this.componentRef.comp) { + return this.componentRef.comp.isSet; + } + // because of the willStart, the real field widget may not be + // instantiated yet when the renderer first asks if it is set + // (only the wrapper is instantiated), so we instantiate one + // with the same props, get its 'isSet' status, and destroy it. + const c = new this.Component(null, this.props); + const isSet = c.isSet; + c.destroy(); + return isSet; + } + isValid() { + return this.componentRef.comp.isValid; + } + reset(record, event) { + return this.update({record, event}); + } + setIDForLabel(id) { + return this.componentRef.comp.setIDForLabel(id); + } + updateModifiersValue(modifiers) { + return this.componentRef.comp.updateModifiersValue(modifiers); + } + } + + return FieldWrapper; +}); diff --git a/addons/web/static/src/js/owl_compatibility.js b/addons/web/static/src/js/owl_compatibility.js index d2a82e1f8b40973f285cd656d53b9b344af4dbbe..abf07d9681ddf7c0f0e0c620b8762bc2554aa831 100644 --- a/addons/web/static/src/js/owl_compatibility.js +++ b/addons/web/static/src/js/owl_compatibility.js @@ -10,7 +10,7 @@ odoo.define('web.OwlCompatibility', function () { */ const { Component, hooks, tags } = owl; - const { useSubEnv } = hooks; + const { useRef, useSubEnv } = hooks; const { xml } = tags; const widgetSymbol = odoo.widgetSymbol; @@ -297,28 +297,23 @@ odoo.define('web.OwlCompatibility', function () { */ const WidgetAdapterMixin = { /** - * Calls __callMounted on each sub component, and its descendants (this - * function isn't recursive) when the widget is appended into the DOM. + * Calls on_attach_callback on each child ComponentWrapper, which will + * call __callMounted on each sub component (recursively), to mark them + * as mounted. */ on_attach_callback() { - function recursiveCallMounted(component) { - for (const key in component.__owl__.children) { - recursiveCallMounted(component.__owl__.children[key]); - } - component.__callMounted(); - } for (const component of children.get(this) || []) { - recursiveCallMounted(component); + component.on_attach_callback(); } }, /** - * Calls __callWillUnmount on each sub component (this function is - * recursive and thus will be automatically called on its descendants) - * when the widget is removed/detached from the DOM. + * Calls on_detach_callback on each child ComponentWrapper, which will + * call __callWillUnmount to mark itself and its children as no longer + * mounted. */ on_detach_callback() { for (const component of children.get(this) || []) { - component.__callWillUnmount(); + component.on_detach_callback(); } }, /** @@ -362,16 +357,29 @@ odoo.define('web.OwlCompatibility', function () { this.Component = Component; this.props = props || {}; this._handledEvents = new Set(); // Owl events we are redirecting + + this.componentRef = useRef("component"); } /** - * Define (empty) on_attach_callback (resp. on_detach_callback) on the - * Wrapper in case the parent would call them, for instance in - * on_attach_callback (resp. on_detach_callback). We only do that to - * prevent a crash, but the logic is already handled by the mixin. + * Calls __callMounted on itself and on each sub component (as this + * function isn't recursive) when the component is appended into the DOM. */ - on_attach_callback() {} - on_detach_callback() {} + on_attach_callback() { + function recursiveCallMounted(component) { + for (const key in component.__owl__.children) { + recursiveCallMounted(component.__owl__.children[key]); + } + component.__callMounted(); + } + recursiveCallMounted(this); + } + /** + * Calls __callWillUnmount to notify the component it will be unmounted. + */ + on_detach_callback() { + this.__callWillUnmount(); + } /** * Overrides to remove the reference to this component in the parent. @@ -483,7 +491,7 @@ odoo.define('web.OwlCompatibility', function () { parentChildren.push(this); } } - ComponentWrapper.template = xml`<t t-component="Component" t-props="props"/>`; + ComponentWrapper.template = xml`<t t-component="Component" t-props="props" t-ref="component"/>`; return { ComponentAdapter, diff --git a/addons/web/static/src/js/views/basic/basic_renderer.js b/addons/web/static/src/js/views/basic/basic_renderer.js index 07edeae343aaba64159cbd8fd9080c5986f26b20..046d10a741f8138372e48ab97ff31497ce93b9cd 100644 --- a/addons/web/static/src/js/views/basic/basic_renderer.js +++ b/addons/web/static/src/js/views/basic/basic_renderer.js @@ -13,9 +13,12 @@ var core = require('web.core'); var dom = require('web.dom'); var widgetRegistry = require('web.widget_registry'); +const { WidgetAdapterMixin } = require('web.OwlCompatibility'); +const FieldWrapper = require('web.FieldWrapper'); + var qweb = core.qweb; -var BasicRenderer = AbstractRenderer.extend({ +var BasicRenderer = AbstractRenderer.extend(WidgetAdapterMixin, { custom_events: { navigation_move: '_onNavigationMove', }, @@ -35,6 +38,30 @@ var BasicRenderer = AbstractRenderer.extend({ // and on which field it is set. this.handleField = null; }, + /** + * @override + */ + destroy: function () { + this._super.apply(this, arguments); + WidgetAdapterMixin.destroy.call(this); + }, + /** + * Called each time the renderer is attached into the DOM. + */ + on_attach_callback: function () { + WidgetAdapterMixin.on_attach_callback.call(this); + }, + /** + * Called each time the renderer is detached from the DOM. + */ + on_detach_callback: function () { + WidgetAdapterMixin.on_detach_callback.call(this); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + /** * This method has two responsabilities: find every invalid fields in the * current view, and making sure that they are displayed as invalid, by @@ -625,10 +652,21 @@ var BasicRenderer = AbstractRenderer.extend({ // 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, { + const legacy = !(Widget.prototype instanceof owl.Component); + const widgetOptions = { mode: modifiers.readonly ? 'readonly' : mode, viewType: this.viewType, - }); + }; + let widget; + if (legacy) { + widget = new Widget(this, fieldName, record, widgetOptions); + } else { + widget = new FieldWrapper(this, Widget, { + fieldName, + record, + options: widgetOptions, + }); + } // Register the widget so that it can easily be found again if (this.allFieldWidgets[record.id] === undefined) { @@ -639,8 +677,13 @@ var BasicRenderer = AbstractRenderer.extend({ widget.__node = node; // TODO get rid of this if possible one day // Prepare widget rendering and save the related promise - var def = widget._widgetRenderAndInsert(function () {}); var $el = $('<div>'); + let def; + if (legacy) { + def = widget._widgetRenderAndInsert(function () {}); + } else { + def = widget.mount(document.createDocumentFragment()); + } this.defs.push(def); @@ -746,13 +789,23 @@ var BasicRenderer = AbstractRenderer.extend({ _rerenderFieldWidget: function (widget, record, options) { // Render the new field widget var $el = this._renderFieldWidget(widget.__node, record, options); - widget.$el.replaceWith($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]; - var newWidget = recordWidgets.pop(); - recordWidgets.splice(oldIndex, 0, newWidget); + const def = this.defs[this.defs.length - 1]; // this is the widget's def, resolved when it is ready + const $div = $('<div>'); + $div.append($el); // $el will be replaced when widget is ready (see _renderFieldWidget) + def.then(() => { + widget.$el.replaceWith($div.children()); + + // 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]; + let newWidget = recordWidgets.pop(); + recordWidgets.splice(oldIndex, 0, newWidget); + + // Mount new widget if necessary (mainly for Owl components) + if (this._isInDom && newWidget.on_attach_callback) { + newWidget.on_attach_callback(); + } + }); }, /** * Unregisters an element of the modifiers data associated to the given diff --git a/addons/web/static/src/js/views/form/form_renderer.js b/addons/web/static/src/js/views/form/form_renderer.js index e393f22dba4f5066f34527b4973ea0d23ed8f767..89972e700aa37e75ab541d05fd214b16b810064a 100644 --- a/addons/web/static/src/js/views/form/form_renderer.js +++ b/addons/web/static/src/js/views/form/form_renderer.js @@ -1060,11 +1060,15 @@ var FormRenderer = BasicRenderer.extend({ _onNavigationMove: function (ev) { ev.stopPropagation(); var index; + let target = ev.data.target || ev.target; + if (target.__owl__) { + target = target.__owl__.parent; // Owl fields are wrapped by the FieldWrapper + } if (ev.data.direction === "next") { - index = this.allFieldWidgets[this.state.id].indexOf(ev.data.target || ev.target); + index = this.allFieldWidgets[this.state.id].indexOf(target); this._activateNextFieldWidget(this.state, index); } else if (ev.data.direction === "previous") { - index = this.allFieldWidgets[this.state.id].indexOf(ev.data.target); + index = this.allFieldWidgets[this.state.id].indexOf(target); this._activatePreviousFieldWidget(this.state, index); } }, diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml index 78597e7ada0be84562aad624c33ac3d00b7da74e..83b3aecce4864a96c6992a4593747e7bf9b27e7c 100644 --- a/addons/web/views/webclient_templates.xml +++ b/addons/web/views/webclient_templates.xml @@ -298,6 +298,8 @@ <script type="text/javascript" src="/web/static/src/js/fields/relational_fields.js"></script> <script type="text/javascript" src="/web/static/src/js/fields/special_fields.js"></script> <script type="text/javascript" src="/web/static/src/js/fields/upgrade_fields.js"></script> + <script type="text/javascript" src="/web/static/src/js/fields/field_wrapper.js"></script> + <script type="text/javascript" src="/web/static/src/js/fields/abstract_field_owl.js"></script> <script type="text/javascript" src="/web/static/src/js/views/abstract_view.js"></script> <script type="text/javascript" src="/web/static/src/js/views/abstract_renderer.js"></script>