diff --git a/addons/account/static/src/js/account_resequence_field.js b/addons/account/static/src/js/account_resequence_field.js index 0f918e9a297..57b57bfeac0 100644 --- a/addons/account/static/src/js/account_resequence_field.js +++ b/addons/account/static/src/js/account_resequence_field.js @@ -4,7 +4,7 @@ odoo.define('account.ShowResequenceRenderer', function (require) { const { Component } = owl; const { useState } = owl.hooks; const AbstractFieldOwl = require('web.AbstractFieldOwl'); -const field_registry = require('web.field_registry'); +const field_registry = require('web.field_registry_owl'); class ChangeLine extends Component { } ChangeLine.template = 'account.ResequenceChangeLine'; diff --git a/addons/account/static/src/js/grouped_view_widget.js b/addons/account/static/src/js/grouped_view_widget.js index 3af2d0e1fe0..e1df30cf997 100644 --- a/addons/account/static/src/js/grouped_view_widget.js +++ b/addons/account/static/src/js/grouped_view_widget.js @@ -4,7 +4,7 @@ odoo.define('account.ShowGroupedList', function (require) { const { Component } = owl; const { useState } = owl.hooks; const AbstractFieldOwl = require('web.AbstractFieldOwl'); -const field_registry = require('web.field_registry'); +const field_registry = require('web.field_registry_owl'); class ListItem extends Component { } ListItem.template = 'account.GroupedItemTemplate'; diff --git a/addons/web/__manifest__.py b/addons/web/__manifest__.py index ccfbd9b8499..e72fabaff2a 100644 --- a/addons/web/__manifest__.py +++ b/addons/web/__manifest__.py @@ -23,6 +23,7 @@ This module provides the core of the Odoo Web Client. 'qweb': [ "static/src/xml/base.xml", "static/src/xml/chart.xml", + "static/src/xml/fields.xml", "static/src/xml/file_upload_progress_bar.xml", "static/src/xml/file_upload_progress_card.xml", "static/src/xml/kanban.xml", diff --git a/addons/web/static/src/js/_deprecated/basic_fields.js b/addons/web/static/src/js/_deprecated/basic_fields.js new file mode 100644 index 00000000000..2a70c3793c0 --- /dev/null +++ b/addons/web/static/src/js/_deprecated/basic_fields.js @@ -0,0 +1,154 @@ +//////////////////////////////////////////////////////////////////////////////// +// /!\ DEPRECATED +// +// Legacy Field Widgets are added in this file when they are converted into +// Owl Component. +//////////////////////////////////////////////////////////////////////////////// + +odoo.define('web.basic_fields.deprecated', function (require) { +"use strict"; + +/** + * This module contains most of the basic (meaning: non relational) field + * widgets. Field widgets are supposed to be used in views inheriting from + * BasicView, so, they can work with the records obtained from a BasicModel. + */ + +var AbstractField = require('web.AbstractField'); +var core = require('web.core'); + +var _lt = core._lt; + +var FieldBoolean = AbstractField.extend({ + className: 'o_field_boolean', + description: _lt("Checkbox"), + events: _.extend({}, AbstractField.prototype.events, { + change: '_onChange', + }), + supportedFieldTypes: ['boolean'], + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Toggle the checkbox if it is activated due to a click on itself. + * + * @override + */ + activate: function (options) { + var activated = this._super.apply(this, arguments); + // The formatValue of boolean fields renders HTML elements similar to + // the one rendered by the widget itself. Even though the event might + // have been fired on the non-widget version of this field, we can still + // test the presence of its custom class. + if (activated && options && options.event && $(options.event.target).closest('.custom-control.custom-checkbox').length) { + this._setValue(!this.value); // Toggle the checkbox + } + return activated; + }, + + /** + * @override + * @returns {jQuery} the focusable checkbox input + */ + getFocusableElement: function () { + return this.mode === 'readonly' ? $() : this.$input; + }, + /** + * A boolean field is always set since false is a valid value. + * + * @override + */ + isSet: function () { + return true; + }, + /** + * When the checkbox is rerendered, we need to check if it was the actual + * origin of the reset. If it is, we need to activate it back so it looks + * like it was not rerendered but is still the same input. + * + * @override + */ + reset: function (record, event) { + var rendered = this._super.apply(this, arguments); + if (event && event.target.name === this.name) { + this.activate(); + } + return rendered; + }, + /** + * Associates the 'for' attribute of the internal label. + * + * @override + */ + setIDForLabel: function (id) { + this._super.apply(this, arguments); + this.$('.custom-control-label').attr('for', id); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * The actual checkbox is designed in css to have full control over its + * appearance, as opposed to letting the browser and the os decide how + * a checkbox should look. The actual input is disabled and hidden. In + * readonly mode, the checkbox is disabled. + * + * @override + * @private + */ + _render: function () { + var $checkbox = this._formatValue(this.value); + this.$input = $checkbox.find('input'); + this.$input.prop('disabled', this.mode === 'readonly'); + this.$el.addClass($checkbox.attr('class')); + this.$el.empty().append($checkbox.contents()); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Properly update the value when the checkbox is (un)ticked to trigger + * possible onchanges. + * + * @private + */ + _onChange: function () { + this._setValue(this.$input[0].checked); + }, + /** + * Implement keyboard movements. Mostly useful for its environment, such + * as a list view. + * + * @override + * @private + * @param {KeyEvent} ev + */ + _onKeydown: function (ev) { + switch (ev.which) { + case $.ui.keyCode.ENTER: + // prevent subsequent 'click' event (see _onKeydown of AbstractField) + ev.preventDefault(); + this.$input.prop('checked', !this.value); + this._setValue(!this.value); + return; + case $.ui.keyCode.UP: + case $.ui.keyCode.RIGHT: + case $.ui.keyCode.DOWN: + case $.ui.keyCode.LEFT: + ev.preventDefault(); + } + this._super.apply(this, arguments); + }, +}); + +return { + FieldBoolean: FieldBoolean, +}; + +}); diff --git a/addons/web/static/src/js/components/custom_checkbox.js b/addons/web/static/src/js/components/custom_checkbox.js new file mode 100644 index 00000000000..bc98dd7bab2 --- /dev/null +++ b/addons/web/static/src/js/components/custom_checkbox.js @@ -0,0 +1,58 @@ +odoo.define('web.CustomCheckbox', function (require) { + "use strict"; + + const utils = require('web.utils'); + + const { Component } = owl; + + /** + * Custom checkbox + * + * Component that can be used in templates to render the custom checkbox of Odoo. + * + * + * + * @extends Component + */ + class CustomCheckbox extends Component { + /** + * @param {Object} [props] + * @param {string | number | null} [props.id] + * @param {boolean} [props.value=false] + * @param {boolean} [props.disabled=false] + * @param {string} [props.text] + */ + constructor() { + super(...arguments); + this._id = `checkbox-comp-${utils.generateID()}`; + } + } + + CustomCheckbox.props = { + id: { + type: [String, Number], + optional: 1, + }, + disabled: { + type: Boolean, + optional: 1, + }, + value: { + type: Boolean, + optional: 1, + }, + text: { + type: String, + optional: 1, + }, + }; + + CustomCheckbox.template = 'web.CustomCheckbox'; + + return CustomCheckbox; +}); diff --git a/addons/web/static/src/js/core/utils.js b/addons/web/static/src/js/core/utils.js index 93e9bda5593..f1d4b00c6a9 100644 --- a/addons/web/static/src/js/core/utils.js +++ b/addons/web/static/src/js/core/utils.js @@ -445,6 +445,14 @@ var utils = { is_bin_size: function (v) { return (/^\d+(\.\d*)? [^0-9]+$/).test(v); }, + /** + * Checks if a class is an extension of owl.Component. + * + * @param {any} value A class reference + */ + isComponent: function (value) { + return value.prototype instanceof owl.Component; + }, /** * Returns whether the given anchor is valid. * diff --git a/addons/web/static/src/js/fields/abstract_field_owl.js b/addons/web/static/src/js/fields/abstract_field_owl.js index c9beafc9203..937fc2f88ee 100644 --- a/addons/web/static/src/js/fields/abstract_field_owl.js +++ b/addons/web/static/src/js/fields/abstract_field_owl.js @@ -9,34 +9,39 @@ odoo.define('web.AbstractFieldOwl', function (require) { /** * 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.. + * + * ========================================================================= * - * 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 + * 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 widget are mainly: + * 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 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. + * - 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 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 + * - 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 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'. + * 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 */ @@ -45,118 +50,28 @@ odoo.define('web.AbstractFieldOwl', function (require) { * 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' + * @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(parent, props) { - super(parent, props); + constructor() { + super(...arguments); - 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 + // 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; - } + 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 @@ -174,38 +89,91 @@ odoo.define('web.AbstractFieldOwl', function (require) { 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; + this._lastSetValue = undefined; return super.willUpdateProps(nextProps); } - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- + //---------------------------------------------------------------------- + // Getters + //---------------------------------------------------------------------- /** - * Returns the main field's DOM element (jQuery form) which can be focused - * by the browser. + * 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 widget + * @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 widget is in readonly, to hide it + * method is typically called when the component is in readonly, to hide it * (and its label) if it is empty. * * @returns {boolean} @@ -214,39 +182,171 @@ odoo.define('web.AbstractFieldOwl', function (require) { return !this.isSet; } /** - * Returns true if the widget has a visible element that can take the focus + * 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); + 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; } /** - * Activates the field widget. By default, activation means focusing and + * 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 + * 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 + * @returns {boolean} true if the component was activated, false if the * focusable element was not found or invisible */ activate(options) { @@ -264,50 +364,60 @@ odoo.define('web.AbstractFieldOwl', function (require) { return false; } /** - * This function should be implemented by widgets that are not able to + * 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 - * widget's value, so it should call _setValue() to notify the environment + * 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) { + 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 + * add the invalid class on a field + * + * This function should be removed when BasicRenderer will be rewritten in owl */ - updateModifiersValue(modifiers) { - this.attrs.modifiersValue = modifiers || {}; + 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() { @@ -326,13 +436,13 @@ odoo.define('web.AbstractFieldOwl', function (require) { * @returns {string} */ _formatValue(value) { - const options = _.extend({}, this.nodeOptions, + const options = Object.assign({}, 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 + * 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. @@ -355,18 +465,18 @@ odoo.define('web.AbstractFieldOwl', function (require) { 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. + * 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 widget, which does a _setValue in the + * 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. @@ -377,16 +487,16 @@ odoo.define('web.AbstractFieldOwl', function (require) { _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 === '')) { + if (this._lastSetValue === value || (this.value === false && value === '')) { return Promise.resolve(); } - this.lastSetValue = value; + this._lastSetValue = value; try { value = this._parseValue(value); this._isValid = true; } catch (e) { this._isValid = false; - this.trigger('set-dirty', {dataPointID: this.dataPointID}); + this.trigger('set-dirty', {dataPointID: this.dataPointId}); return Promise.reject({message: "Value set is not valid"}); } if (!(options && options.forceChange) && this._isSameValue(value)) { @@ -396,7 +506,7 @@ odoo.define('web.AbstractFieldOwl', function (require) { const changes = {}; changes[this.name] = value; this.trigger('field-changed', { - dataPointID: this.dataPointID, + dataPointID: this.dataPointId, changes: changes, viewType: this.viewType, doNotSetDirty: options && options.doNotSetDirty, @@ -408,20 +518,14 @@ odoo.define('web.AbstractFieldOwl', function (require) { }); } - //-------------------------------------------------------------------------- + //---------------------------------------------------------------------- // 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 */ @@ -430,9 +534,8 @@ odoo.define('web.AbstractFieldOwl', function (require) { case $.ui.keyCode.TAB: this.trigger('navigation-move', { direction: ev.shiftKey ? 'previous' : 'next', + originalEvent: ev, }); - // 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: @@ -479,18 +582,13 @@ odoo.define('web.AbstractFieldOwl', function (require) { } /** - * An object representing fields to be fetched by the model eventhough not present in the view + * An object representing fields to be fetched by the model even though 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. + * @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 @@ -500,15 +598,15 @@ odoo.define('web.AbstractFieldOwl', function (require) { */ AbstractField.specialData = false; /** - * to override to indicate which field types are supported by the widget + * to override to indicate which field types are supported by the component * - * @type Array + * @type Array */ AbstractField.supportedFieldTypes = []; /** - * To override to give a user friendly name to the widget. + * To override to give a user friendly name to the component. * - * @type + * @type string */ AbstractField.description = ""; /** diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js index 3a3a037a0f0..ce7a7fac07d 100644 --- a/addons/web/static/src/js/fields/basic_fields.js +++ b/addons/web/static/src/js/fields/basic_fields.js @@ -11,6 +11,7 @@ var AbstractField = require('web.AbstractField'); var config = require('web.config'); var core = require('web.core'); var datepicker = require('web.datepicker'); +var deprecatedFields = require('web.basic_fields.deprecated'); var dom = require('web.dom'); var Domain = require('web.Domain'); var DomainSelector = require('web.DomainSelector'); @@ -24,6 +25,8 @@ var field_utils = require('web.field_utils'); var time = require('web.time'); var ColorpickerDialog = require('web.ColorpickerDialog'); +let FieldBoolean = deprecatedFields.FieldBoolean; + require("web.zoomodoo"); var qweb = core.qweb; @@ -986,134 +989,6 @@ var FieldMonetary = NumericField.extend({ }, }); -var FieldBoolean = AbstractField.extend({ - className: 'o_field_boolean', - description: _lt("Checkbox"), - events: _.extend({}, AbstractField.prototype.events, { - change: '_onChange', - }), - supportedFieldTypes: ['boolean'], - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * Toggle the checkbox if it is activated due to a click on itself. - * - * @override - */ - activate: function (options) { - var activated = this._super.apply(this, arguments); - // The formatValue of boolean fields renders HTML elements similar to - // the one rendered by the widget itself. Even though the event might - // have been fired on the non-widget version of this field, we can still - // test the presence of its custom class. - if (activated && options && options.event && $(options.event.target).closest('.custom-control.custom-checkbox').length) { - this._setValue(!this.value); // Toggle the checkbox - } - return activated; - }, - - /** - * @override - * @returns {jQuery} the focusable checkbox input - */ - getFocusableElement: function () { - return this.mode === 'readonly' ? $() : this.$input; - }, - /** - * A boolean field is always set since false is a valid value. - * - * @override - */ - isSet: function () { - return true; - }, - /** - * When the checkbox is rerendered, we need to check if it was the actual - * origin of the reset. If it is, we need to activate it back so it looks - * like it was not rerendered but is still the same input. - * - * @override - */ - reset: function (record, event) { - var rendered = this._super.apply(this, arguments); - if (event && event.target.name === this.name) { - this.activate(); - } - return rendered; - }, - /** - * Associates the 'for' attribute of the internal label. - * - * @override - */ - setIDForLabel: function (id) { - this._super.apply(this, arguments); - this.$('.custom-control-label').attr('for', id); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * The actual checkbox is designed in css to have full control over its - * appearance, as opposed to letting the browser and the os decide how - * a checkbox should look. The actual input is disabled and hidden. In - * readonly mode, the checkbox is disabled. - * - * @override - * @private - */ - _render: function () { - var $checkbox = this._formatValue(this.value); - this.$input = $checkbox.find('input'); - this.$input.prop('disabled', this.mode === 'readonly'); - this.$el.addClass($checkbox.attr('class')); - this.$el.empty().append($checkbox.contents()); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * Properly update the value when the checkbox is (un)ticked to trigger - * possible onchanges. - * - * @private - */ - _onChange: function () { - this._setValue(this.$input[0].checked); - }, - /** - * Implement keyboard movements. Mostly useful for its environment, such - * as a list view. - * - * @override - * @private - * @param {KeyEvent} ev - */ - _onKeydown: function (ev) { - switch (ev.which) { - case $.ui.keyCode.ENTER: - // prevent subsequent 'click' event (see _onKeydown of AbstractField) - ev.preventDefault(); - this.$input.prop('checked', !this.value); - this._setValue(!this.value); - return; - case $.ui.keyCode.UP: - case $.ui.keyCode.RIGHT: - case $.ui.keyCode.DOWN: - case $.ui.keyCode.LEFT: - ev.preventDefault(); - } - this._super.apply(this, arguments); - }, -}); - var FieldInteger = NumericField.extend({ description: _lt("Integer"), className: 'o_field_integer o_field_number', diff --git a/addons/web/static/src/js/fields/basic_fields_owl.js b/addons/web/static/src/js/fields/basic_fields_owl.js new file mode 100644 index 00000000000..a00b14fb9ee --- /dev/null +++ b/addons/web/static/src/js/fields/basic_fields_owl.js @@ -0,0 +1,111 @@ +odoo.define('web.basic_fields_owl', function (require) { + "use strict"; + + const AbstractField = require('web.AbstractFieldOwl'); + const CustomCheckbox = require('web.CustomCheckbox'); + const core = require('web.core'); + + const _lt = core._lt; + + class FieldBoolean extends AbstractField { + patched() { + super.patched(); + if (this.props.event && this.props.event.target === this) { + this.activate(); + } + } + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * @override + * @returns {HTMLElement|null} the focusable checkbox input + */ + get focusableElement() { + return this.mode === 'readonly' ? null : this.el.querySelector('input'); + } + /** + * A boolean field is always set since false is a valid value. + * + * @override + */ + get isSet() { + return true; + } + /** + * Toggle the checkbox if it is activated due to a click on itself. + * + * @override + * @param {Object} [options] + * @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) { + const activated = super.activate(options); + // The event might have been fired on the non field version of + // this field, we can still test the presence of its custom class. + if (activated && options && options.event && options.event.target + .closest('.custom-control.custom-checkbox')) { + this._setValue(!this.value); // Toggle the checkbox + } + return activated; + } + /** + * Associates the 'for' attribute of the internal label. + * + * @override + */ + setIdForLabel(id) { + super.setIdForLabel(id); + this.el.querySelector('label').setAttribute('for', id); + } + + //---------------------------------------------------------------------- + // Handlers + //---------------------------------------------------------------------- + + /** + * Properly update the value when the checkbox is (un)ticked to trigger + * possible onchanges. + * + * @private + */ + _onChange(ev) { + this._setValue(ev.target.checked); + } + /** + * Implement keyboard movements. Mostly useful for its environment, such + * as a list view. + * + * @override + * @private + * @param {KeyEvent} ev + */ + _onKeydown(ev) { + switch (ev.which) { + case $.ui.keyCode.ENTER: + // prevent subsequent 'click' event (see _onKeydown of AbstractField) + ev.preventDefault(); + this._setValue(!this.value); + return; + case $.ui.keyCode.UP: + case $.ui.keyCode.RIGHT: + case $.ui.keyCode.DOWN: + case $.ui.keyCode.LEFT: + ev.preventDefault(); + } + super._onKeydown(ev); + } + } + FieldBoolean.components = { CustomCheckbox }; + FieldBoolean.description = _lt("Checkbox"); + FieldBoolean.supportedFieldTypes = ['boolean']; + FieldBoolean.template = 'web.FieldBoolean'; + + return { + FieldBoolean, + }; +}); diff --git a/addons/web/static/src/js/fields/field_registry.js b/addons/web/static/src/js/fields/field_registry.js index 49f1f678304..b8b3fe61452 100644 --- a/addons/web/static/src/js/fields/field_registry.js +++ b/addons/web/static/src/js/fields/field_registry.js @@ -3,7 +3,16 @@ odoo.define('web.field_registry', function (require) { var Registry = require('web.Registry'); -return new Registry(); +var FieldRegistry = Registry.extend({ + add: function (key, value, score) { + if (value.prototype instanceof owl.Component) { + throw new Error("This registry should not contain any Component. Use 'web.field_registry_owl' instead."); + } + return this._super(...arguments); + }, +}); + +return new FieldRegistry(); }); odoo.define('web._field_registry', function (require) { diff --git a/addons/web/static/src/js/fields/field_registry_owl.js b/addons/web/static/src/js/fields/field_registry_owl.js new file mode 100644 index 00000000000..f5f7d900ef8 --- /dev/null +++ b/addons/web/static/src/js/fields/field_registry_owl.js @@ -0,0 +1,31 @@ +odoo.define('web.field_registry_owl', function (require) { + "use strict"; + + const Registry = require('web.Registry'); + + const FieldRegistry = Registry.extend({ + add: function (key, value, score) { + if (!(value.prototype instanceof owl.Component)) { + throw new Error("This registry should only contain subclasses of Component"); + } + return this._super(...arguments); + }, + }); + + return new FieldRegistry(); +}); + +odoo.define('web._field_registry_owl', function (require) { + "use strict"; + + /** + * This module registers field components (specifications of the AbstractField Component) + */ + + const basicFields = require('web.basic_fields_owl'); + const registry = require('web.field_registry_owl'); + + // Basic fields + registry + .add('boolean', basicFields.FieldBoolean); +}); diff --git a/addons/web/static/src/js/fields/field_wrapper.js b/addons/web/static/src/js/fields/field_wrapper.js index 0b808d0c732..8cf747d84e9 100644 --- a/addons/web/static/src/js/fields/field_wrapper.js +++ b/addons/web/static/src/js/fields/field_wrapper.js @@ -33,8 +33,6 @@ odoo.define('web.FieldWrapper', function (require) { 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; @@ -43,9 +41,19 @@ odoo.define('web.FieldWrapper', function (require) { if (this._data.attrs.decorations) { this._data.resetOnAnyFieldChange = true; } - for (const key in this._data) { + + for (let key in this._data) { Object.defineProperty(this, key, { - get: () => (this.el ? this.componentRef.comp : this._data)[key], + get: () => { + if (this.el) { + if (key === 'dataPointID') { + return this.componentRef.comp.dataPointId; + } else if (key === 'res_id') { + return this.componentRef.comp.resId; + } + } + return (this.el ? this.componentRef.comp : this._data)[key]; + }, }); } } @@ -60,9 +68,6 @@ odoo.define('web.FieldWrapper', function (require) { get fieldDependencies() { return this.Component.fieldDependencies; } - get resetOnAnyFieldChange() { - return this.Component.resetOnAnyFieldChange; - } get specialData() { return this.Component.specialData; } @@ -80,11 +85,11 @@ odoo.define('web.FieldWrapper', function (require) { // Public //---------------------------------------------------------------------- - activate(options) { - return this.componentRef.comp.activate(options); + activate() { + return this.componentRef.comp.activate(...arguments); } commitChanges() { - return this.componentRef.comp.commitChanges(); + return this.componentRef.comp.commitChanges(...arguments); } getFocusableElement() { return $(this.componentRef.comp.focusableElement); @@ -99,7 +104,7 @@ odoo.define('web.FieldWrapper', function (require) { if (this.componentRef.comp) { return this.componentRef.comp.isSet; } - // because of the willStart, the real field widget may not be + // because of the willStart, the real field component 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. @@ -111,14 +116,27 @@ odoo.define('web.FieldWrapper', function (require) { isValid() { return this.componentRef.comp.isValid; } + removeInvalidClass() { + return this.componentRef.comp.removeInvalidClass(...arguments); + } reset(record, event) { return this.update({record, event}); } - setIDForLabel(id) { - return this.componentRef.comp.setIDForLabel(id); + setIDForLabel() { + return this.componentRef.comp.setIdForLabel(...arguments); + } + setInvalidClass() { + return this.componentRef.comp.setInvalidClass(...arguments); } updateModifiersValue(modifiers) { - return this.componentRef.comp.updateModifiersValue(modifiers); + if (this.props.options.attrs) { + this.props.options.attrs.modifiersValue = modifiers || {}; + } else { + const viewType = this.props.options.viewType || 'default'; + const fieldsInfo = this.props.record.fieldsInfo[viewType]; + fieldsInfo[this.props.fieldName].modifiersValue = modifiers || {}; + } + this.componentRef.comp.props = this.props; } } diff --git a/addons/web/static/src/js/views/basic/basic_view.js b/addons/web/static/src/js/views/basic/basic_view.js index 140c338d6f6..4d266657c71 100644 --- a/addons/web/static/src/js/views/basic/basic_view.js +++ b/addons/web/static/src/js/views/basic/basic_view.js @@ -15,6 +15,7 @@ var BasicController = require('web.BasicController'); var BasicModel = require('web.BasicModel'); var config = require('web.config'); var fieldRegistry = require('web.field_registry'); +var fieldRegistryOwl = require('web.field_registry_owl'); var pyUtils = require('web.py_utils'); var utils = require('web.utils'); @@ -78,7 +79,8 @@ var BasicView = AbstractView.extend({ _getFieldWidgetClass: function (viewType, field, attrs) { var FieldWidget; if (attrs.widget) { - FieldWidget = fieldRegistry.getAny([viewType + "." + attrs.widget, attrs.widget]); + FieldWidget = fieldRegistryOwl.getAny([viewType + "." + attrs.widget, attrs.widget]) || + fieldRegistry.getAny([viewType + "." + attrs.widget, attrs.widget]); if (!FieldWidget) { console.warn("Missing widget: ", attrs.widget, " for field", attrs.name, "of type", field.type); } @@ -87,7 +89,9 @@ var BasicView = AbstractView.extend({ // is not specified in the view FieldWidget = fieldRegistry.get('kanban.many2many_tags'); } - return FieldWidget || fieldRegistry.getAny([viewType + "." + field.type, field.type, "abstract"]); + return FieldWidget || + fieldRegistryOwl.getAny([viewType + "." + field.type, field.type, "abstract"]) || + fieldRegistry.getAny([viewType + "." + field.type, field.type, "abstract"]); }, /** * In some cases, we already have a preloaded record diff --git a/addons/web/static/src/js/views/calendar/calendar_popover.js b/addons/web/static/src/js/views/calendar/calendar_popover.js index d804f74e53e..9b4af4bb065 100644 --- a/addons/web/static/src/js/views/calendar/calendar_popover.js +++ b/addons/web/static/src/js/views/calendar/calendar_popover.js @@ -2,10 +2,13 @@ odoo.define('web.CalendarPopover', function (require) { "use strict"; var fieldRegistry = require('web.field_registry'); +const fieldRegistryOwl = require('web.field_registry_owl'); +const FieldWrapper = require('web.FieldWrapper'); var StandaloneFieldManagerMixin = require('web.StandaloneFieldManagerMixin'); var Widget = require('web.Widget'); +const { WidgetAdapterMixin } = require('web.OwlCompatibility'); -var CalendarPopover = Widget.extend(StandaloneFieldManagerMixin, { +var CalendarPopover = Widget.extend(WidgetAdapterMixin, StandaloneFieldManagerMixin, { template: 'CalendarView.event.popover', events: { 'click .o_cw_popover_edit': '_onClickPopoverEdit', @@ -45,6 +48,25 @@ var CalendarPopover = Widget.extend(StandaloneFieldManagerMixin, { }); return this._super.apply(this, arguments); }, + /** + * @override + */ + destroy: function () { + this._super.apply(this, arguments); + WidgetAdapterMixin.destroy.call(this); + }, + /** + * Called each time the widget is attached into the DOM. + */ + on_attach_callback: function () { + WidgetAdapterMixin.on_attach_callback.call(this); + }, + /** + * Called each time the widget is detached from the DOM. + */ + on_detach_callback: function () { + WidgetAdapterMixin.on_detach_callback.call(this); + }, //-------------------------------------------------------------------------- // Public @@ -129,8 +151,20 @@ var CalendarPopover = Widget.extend(StandaloneFieldManagerMixin, { var record = self.model.get(recordID); _.each(fieldsToGenerate, function (field) { if (field.invisible) return; - var FieldClass = fieldRegistry.getAny([field.widget, field.type]); - var fieldWidget = new FieldClass(self, field.name, record, self.displayFields[field.name]); + let isLegacy = true; + let fieldWidget; + let FieldClass = fieldRegistryOwl.getAny([field.widget, field.type]); + if (FieldClass) { + isLegacy = false; + fieldWidget = new FieldWrapper(this, FieldClass, { + fieldName: field.name, + record, + options: self.displayFields[field.name], + }); + } else { + FieldClass = fieldRegistry.getAny([field.widget, field.type]); + fieldWidget = new FieldClass(self, field.name, record, self.displayFields[field.name]); + } self._registerWidget(recordID, field.name, fieldWidget); var $field = $('
  • ', {class: 'list-group-item flex-shrink-0 d-flex flex-wrap'}); @@ -139,7 +173,13 @@ var CalendarPopover = Widget.extend(StandaloneFieldManagerMixin, { var $fieldContainer = $('
    ', {class: 'flex-grow-1'}); $fieldContainer.appendTo($field); - defs.push(fieldWidget.appendTo($fieldContainer).then(function () { + let def; + if (isLegacy) { + def = fieldWidget.appendTo($fieldContainer); + } else { + def = fieldWidget.mount($fieldContainer[0]); + } + defs.push(def.then(function () { self.$fieldsList.push($field); })); }); 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 bcab9808773..a258d16a917 100644 --- a/addons/web/static/src/js/views/form/form_renderer.js +++ b/addons/web/static/src/js/views/form/form_renderer.js @@ -1065,6 +1065,13 @@ var FormRenderer = BasicRenderer.extend({ */ _onNavigationMove: function (ev) { ev.stopPropagation(); + // We prevent the default behaviour and stop the propagation of the + // originalEvent when the originalEvent is a tab keydown to not let + // the browser do it. The action is done by this renderer. + if (ev.data.originalEvent && ['next', 'previous'].includes(ev.data.direction)) { + ev.data.originalEvent.preventDefault(); + ev.data.originalEvent.stopPropagation(); + } var index; let target = ev.data.target || ev.target; if (target.__owl__) { diff --git a/addons/web/static/src/js/views/list/list_editable_renderer.js b/addons/web/static/src/js/views/list/list_editable_renderer.js index 8b4db487ac4..8cd7f5a370d 100644 --- a/addons/web/static/src/js/views/list/list_editable_renderer.js +++ b/addons/web/static/src/js/views/list/list_editable_renderer.js @@ -15,6 +15,7 @@ var core = require('web.core'); var dom = require('web.dom'); var ListRenderer = require('web.ListRenderer'); var utils = require('web.utils'); +const { WidgetAdapterMixin } = require('web.OwlCompatibility'); var _t = core._t; @@ -462,6 +463,9 @@ ListRenderer.include({ } return Promise.all(defs).then(function () { + // mark Owl sub components as mounted + WidgetAdapterMixin.on_attach_callback.call(self); + // necessary to trigger resize on fieldtexts core.bus.trigger('DOM_updated'); }); @@ -1553,6 +1557,10 @@ ListRenderer.include({ return; } ev.stopPropagation(); // stop the event, the action is done by this renderer + if (ev.data.originalEvent && ['next', 'previous'].includes(ev.data.direction)) { + ev.data.originalEvent.preventDefault(); + ev.data.originalEvent.stopPropagation(); + } switch (ev.data.direction) { case 'previous': if (this.currentFieldIndex > 0) { diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml index 2b0ff6f31dd..db1e893c168 100644 --- a/addons/web/static/src/xml/base.xml +++ b/addons/web/static/src/xml/base.xml @@ -93,6 +93,21 @@
    + +
    + + +
    +
    + diff --git a/addons/web/static/src/xml/fields.xml b/addons/web/static/src/xml/fields.xml new file mode 100644 index 00000000000..ff7939f36a3 --- /dev/null +++ b/addons/web/static/src/xml/fields.xml @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/addons/web/static/tests/components/custom_checkbox_tests.js b/addons/web/static/tests/components/custom_checkbox_tests.js new file mode 100644 index 00000000000..21d7ae53af2 --- /dev/null +++ b/addons/web/static/tests/components/custom_checkbox_tests.js @@ -0,0 +1,56 @@ +odoo.define('web.custom_checkbox_tests', function (require) { + "use strict"; + + const CustomCheckbox = require('web.CustomCheckbox'); + const testUtils = require('web.test_utils'); + + const { createComponent, dom: testUtilsDom } = testUtils; + + QUnit.module('Components', {}, function () { + + QUnit.module('CustomCheckbox'); + + QUnit.test('test checkbox: default values', async function(assert) { + assert.expect(6); + + const checkbox = await createComponent(CustomCheckbox, {}); + + assert.containsOnce(checkbox.el, 'input'); + assert.containsNone(checkbox.el, 'input:disabled'); + assert.containsOnce(checkbox.el, 'label'); + + const input = checkbox.el.querySelector('input'); + assert.notOk(input.checked, 'checkbox should be unchecked'); + assert.ok(input.id.startsWith('checkbox-comp-')); + + await testUtilsDom.click(checkbox.el.querySelector('label')); + assert.ok(input.checked, 'checkbox should be checked'); + + checkbox.destroy(); + }); + + QUnit.test('test checkbox: custom values', async function(assert) { + assert.expect(6); + + const checkbox = await createComponent(CustomCheckbox, { + props: { + id: 'my-custom-checkbox', + disabled: true, + value: true, + text: 'checkbox', + } + }); + + assert.containsOnce(checkbox.el, 'input'); + assert.containsOnce(checkbox.el, 'input:disabled'); + assert.containsOnce(checkbox.el, 'label'); + + const input = checkbox.el.querySelector('input'); + assert.ok(input.checked, 'checkbox should be checked'); + assert.strictEqual(input.id, 'my-custom-checkbox'); + assert.ok(input.checked, 'checkbox should be checked'); + + checkbox.destroy(); + }); + }); +}); diff --git a/addons/web/static/tests/fields/basic_fields_tests.js b/addons/web/static/tests/fields/basic_fields_tests.js index 6bd62b2d25c..710dd66e40e 100644 --- a/addons/web/static/tests/fields/basic_fields_tests.js +++ b/addons/web/static/tests/fields/basic_fields_tests.js @@ -267,12 +267,14 @@ QUnit.module('basic_fields', { assert.containsOnce(form, '.o_field_boolean input:checked', "checkbox should now be checked"); // blindly press enter again, it should uncheck the checkbox - $(document.activeElement).trigger({type: "keydown", which: $.ui.keyCode.ENTER}); + await testUtils.dom.triggerEvent(document.activeElement, "keydown", + {which: $.ui.keyCode.ENTER}); assert.containsNone(form, '.o_field_boolean input:checked', "checkbox should not be checked"); await testUtils.nextTick(); // blindly press enter again, it should check the checkbox back - $(document.activeElement).trigger({type: "keydown", which: $.ui.keyCode.ENTER}); + await testUtils.dom.triggerEvent(document.activeElement, "keydown", + {which: $.ui.keyCode.ENTER}); assert.containsOnce(form, '.o_field_boolean input:checked', "checkbox should still be checked"); @@ -333,6 +335,7 @@ QUnit.module('basic_fields', { // the checkbox directly in readonly mode ! $cell = list.$('tr.o_data_row:has(.custom-checkbox input:not(:checked)) td:not(.o_list_record_selector)').first(); await testUtils.dom.click($cell.find('.custom-checkbox .custom-control-label')); + await testUtils.nextTick(); assert.strictEqual(list.$('tbody td:not(.o_list_record_selector) .custom-checkbox input').length, 5, "should still have 5 checkboxes"); diff --git a/addons/web/static/tests/views/form_tests.js b/addons/web/static/tests/views/form_tests.js index 9b5cad26e75..ee3c68534e3 100644 --- a/addons/web/static/tests/views/form_tests.js +++ b/addons/web/static/tests/views/form_tests.js @@ -4308,17 +4308,19 @@ QUnit.module('Views', { // focus first input, trigger tab form.$('input[name="foo"]').focus(); - form.$('input[name="foo"]').trigger($.Event('keydown', {which: $.ui.keyCode.TAB})); + const tabKey = { keyCode: $.ui.keyCode.TAB, which: $.ui.keyCode.TAB }; + await testUtils.dom.triggerEvent(form.$('input[name="foo"]'), 'keydown', tabKey); assert.ok($.contains(form.$('div[name="bar"]')[0], document.activeElement), "bar checkbox should be focused"); - form.$('div[name="bar"]').trigger($.Event('keydown', {which: $.ui.keyCode.TAB})); + await testUtils.dom.triggerEvent(document.activeElement, 'keydown', tabKey); assert.strictEqual(form.$('input[name="display_name"]')[0], document.activeElement, "display_name should be focused"); // simulate shift+tab on active element - $(document.activeElement).trigger($.Event('keydown', {which: $.ui.keyCode.TAB, shiftKey: true})); - $(document.activeElement).trigger($.Event('keydown', {which: $.ui.keyCode.TAB, shiftKey: true})); + const shiftTabKey = Object.assign({}, tabKey, { shiftKey: true }); + await testUtils.dom.triggerEvent(document.activeElement, 'keydown', shiftTabKey); + await testUtils.dom.triggerEvent(document.activeElement, 'keydown', shiftTabKey); assert.strictEqual(document.activeElement, form.$('input[name="foo"]')[0], "first input should be focused"); diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml index c5e78394245..20bf8b43205 100644 --- a/addons/web/views/webclient_templates.xml +++ b/addons/web/views/webclient_templates.xml @@ -284,14 +284,17 @@ + + + @@ -299,6 +302,7 @@ + @@ -715,6 +719,7 @@ + diff --git a/odoo/addons/test_new_api/static/tests/tours/x2many.js b/odoo/addons/test_new_api/static/tests/tours/x2many.js index a9ad0a7fecc..1eeb10e6977 100644 --- a/odoo/addons/test_new_api/static/tests/tours/x2many.js +++ b/odoo/addons/test_new_api/static/tests/tours/x2many.js @@ -223,7 +223,7 @@ odoo.define('web.test.x2many', function (require) { extra_trigger: '.tab-pane:eq(0) .o_field_widget tbody tr td:contains(aaa)', }, { content: "open the many2one to select another user", - trigger: '.o_input_dropdown > input', + trigger: '.o_field_many2one[name="author"] .o_input_dropdown > input', run: 'text Marc', }, { content: "select another user",