From c93d6e6f130964558ee952602c01eada31f4dddf Mon Sep 17 00:00:00 2001 From: Louis Baudoux Date: Mon, 26 Sep 2022 09:12:56 +0000 Subject: [PATCH] [IMP] web,partner_autocomplete: Owl conversion of partner autocomplete widgets The `field_partner_autocomplete` & `res_partner_many2one` widgets are now converted to Owl. It required some changes in the `Autocomplete` component as we needed to access the ref of the `` element to pass it to the `useInputField` of `CharField`. The hook `useInputField` was also modified to allow passing a ref directly as parameter instead of a ref name. X-original-commit: 0b8c86bc5b19baccf7324fd4b18d438278816a5a Part-of: odoo/odoo#102220 --- addons/partner_autocomplete/__manifest__.py | 10 +- .../src/js/partner_autocomplete_core.js | 365 ++++------ .../src/js/partner_autocomplete_fieldchar.js | 405 +++-------- .../src/js/partner_autocomplete_many2one.js | 181 ++--- .../static/src/scss/partner_autocomplete.scss | 88 +-- .../static/src/xml/partner_autocomplete.xml | 59 +- .../tests/partner_autocomplete_tests.js | 647 ++++++++---------- .../src/core/autocomplete/autocomplete.js | 5 +- .../src/views/fields/input_field_hook.js | 2 +- 9 files changed, 635 insertions(+), 1127 deletions(-) diff --git a/addons/partner_autocomplete/__manifest__.py b/addons/partner_autocomplete/__manifest__.py index 72cdffb709b..66185ad989e 100644 --- a/addons/partner_autocomplete/__manifest__.py +++ b/addons/partner_autocomplete/__manifest__.py @@ -23,12 +23,10 @@ 'auto_install': True, 'assets': { 'web.assets_backend': [ - 'partner_autocomplete/static/src/scss/partner_autocomplete.scss', - 'partner_autocomplete/static/src/js/partner_autocomplete_core.js', - 'partner_autocomplete/static/src/js/partner_autocomplete_fieldchar.js', - 'partner_autocomplete/static/src/js/partner_autocomplete_many2one.js', - 'partner_autocomplete/static/src/js/web_company_autocomplete.js', - 'partner_autocomplete/static/src/xml/**/*', + 'partner_autocomplete/static/lib/jsvat.js', + 'partner_autocomplete/static/src/scss/*', + 'partner_autocomplete/static/src/js/*', + 'partner_autocomplete/static/src/xml/*', ], 'web.tests_assets': [ 'partner_autocomplete/static/lib/**/*', diff --git a/addons/partner_autocomplete/static/src/js/partner_autocomplete_core.js b/addons/partner_autocomplete/static/src/js/partner_autocomplete_core.js index 8831b9312b4..c0808e3893e 100644 --- a/addons/partner_autocomplete/static/src/js/partner_autocomplete_core.js +++ b/addons/partner_autocomplete/static/src/js/partner_autocomplete_core.js @@ -1,56 +1,52 @@ -/* global checkVATNumber */ -odoo.define('partner.autocomplete.Mixin', function (require) { -'use strict'; +/** @odoo-module **/ -var concurrency = require('web.concurrency'); - -var core = require('web.core'); -var Qweb = core.qweb; -var utils = require('web.utils'); -var _t = core._t; +import { _t } from "@web/core/l10n/translation"; +import { KeepLast } from "@web/core/utils/concurrency"; +import { useService } from "@web/core/utils/hooks"; +import { renderToMarkup } from "@web/core/utils/render"; +import { getDataURLFromFile } from "@web/core/utils/urls"; /** - * This mixin only works with classes having EventDispatcherMixin in 'web.mixins' + * Get list of companies via Autocomplete API + * + * @param {string} value + * @returns {Promise} + * @private */ -var PartnerAutocompleteMixin = { - _dropPreviousOdoo: new concurrency.DropPrevious(), - _dropPreviousClearbit: new concurrency.DropPrevious(), - _timeout : 1000, // Timeout for Clearbit autocomplete in ms +export function usePartnerAutocomplete() { + const keepLastOdoo = new KeepLast(); + const keepLastClearbit = new KeepLast(); - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- + const http = useService("http"); + const notification = useService("notification"); + const orm = useService("orm"); - /** - * Get list of companies via Autocomplete API - * - * @param {string} value - * @returns {Promise} - * @private - */ - _autocomplete: function (value) { - var self = this; + function autocomplete(value, isVAT = false) { value = value.trim(); - var isVAT = this._isVAT(value); - var odooSuggestions = []; - var clearbitSuggestions = []; - return new Promise(function (resolve, reject) { - var odooPromise = self._getOdooSuggestions(value, isVAT).then(function (suggestions){ + let odooSuggestions = []; + let clearbitSuggestions = []; + return new Promise((resolve, reject) => { + const odooPromise = getOdooSuggestions(value, isVAT).then((suggestions) => { odooSuggestions = suggestions; }); // Only get Clearbit suggestions if not a VAT number - var clearbitPromise = isVAT ? false : self._getClearbitSuggestions(value).then(function (suggestions){ + const clearbitPromise = isVAT ? false : getClearbitSuggestions(value).then((suggestions) => { + suggestions.forEach((suggestion) => { + suggestion.label = suggestion.name; + suggestion.website = suggestion.domain; + suggestion.description = suggestion.website; + }); clearbitSuggestions = suggestions; }); - var concatResults = function () { + const concatResults = () => { // Add Clearbit result with Odoo result (with unique domain) if (clearbitSuggestions && clearbitSuggestions.length) { - var websites = odooSuggestions.map(function (suggestion) { + const websites = odooSuggestions.map((suggestion) => { return suggestion.website; }); - clearbitSuggestions.forEach(function (suggestion) { + clearbitSuggestions.forEach((suggestion) => { if (websites.indexOf(suggestion.domain) < 0) { websites.push(suggestion.domain); odooSuggestions.push(suggestion); @@ -58,19 +54,18 @@ var PartnerAutocompleteMixin = { }); } - odooSuggestions = _.filter(odooSuggestions, function (suggestion) { + odooSuggestions = odooSuggestions.filter((suggestion) => { return !suggestion.ignored; }); - _.each(odooSuggestions, function(suggestion){ - delete suggestion.ignored; + odooSuggestions.forEach((suggestion) => { + delete suggestion.ignored; }); return resolve(odooSuggestions); }; - self._whenAll([odooPromise, clearbitPromise]).then(concatResults, concatResults); + whenAll([odooPromise, clearbitPromise]).then(concatResults, concatResults); }); - - }, + } /** * Get enrichment data @@ -82,13 +77,13 @@ var PartnerAutocompleteMixin = { * @returns {Promise} * @private */ - _enrichCompany: function (company) { - return this._rpc({ - model: 'res.partner', - method: 'enrich_company', - args: [company.website, company.partner_gid, company.vat], - }); - }, + function enrichCompany(company) { + return orm.call( + 'res.partner', + 'enrich_company', + [company.website, company.partner_gid, company.vat] + ); + } /** * Get the company logo as Base 64 image from url @@ -97,14 +92,16 @@ var PartnerAutocompleteMixin = { * @returns {Promise} * @private */ - _getCompanyLogo: function (url) { - return this._getBase64Image(url).then(function (base64Image) { + async function getCompanyLogo(url) { + try { + const base64Image = await getBase64Image(url) // base64Image equals "data:" if image not available on given url return base64Image ? base64Image.replace(/^data:image[^;]*;base64,?/, '') : false; - }).catch(function () { + } + catch { return false; - }); - }, + } + } /** * Get enriched data + logo before populating partner form @@ -112,54 +109,54 @@ var PartnerAutocompleteMixin = { * @param {Object} company * @returns {Promise} */ - _getCreateData: function (company) { - var self = this; - - var removeUselessFields = function (company) { - var fields = 'label,description,domain,logo,legal_name,ignored,email'.split(','); - fields.forEach(function (field) { + function getCreateData(company) { + const removeUselessFields = (company) => { + // Delete attribute to avoid "Field_changed" errors + const fields = ['label', 'description', 'domain', 'logo', 'legal_name', 'ignored', 'email', 'bank_ids', 'classList']; + fields.forEach((field) => { delete company[field]; }); - var notEmptyFields = "country_id,state_id".split(','); - notEmptyFields.forEach(function (field) { - if (!company[field]) delete company[field]; + // Remove if empty and format it otherwise + const many2oneFields = ['country_id', 'state_id']; + many2oneFields.forEach((field) => { + if (!company[field]) { + delete company[field]; + } }); }; - return new Promise(function (resolve) { + return new Promise((resolve) => { // Fetch additional company info via Autocomplete Enrichment API - var enrichPromise = self._enrichCompany(company); + const enrichPromise = enrichCompany(company); // Get logo - var logoPromise = company.logo ? self._getCompanyLogo(company.logo) : false; - self._whenAll([enrichPromise, logoPromise]).then(function (result) { - var company_data = result[0]; - var logo_data = result[1]; - + const logoPromise = company.logo ? getCompanyLogo(company.logo) : false; + whenAll([enrichPromise, logoPromise]).then(([company_data, logo_data]) => { // The vat should be returned for free. This is the reason why // we add it into the data of 'company' even if an error such as - // an insufficient credit error is raised. + // an insufficient credit error is raised. if (company_data.error && company_data.vat) { company.vat = company_data.vat; } if (company_data.error) { if (company_data.error_message === 'Insufficient Credit') { - self._notifyNoCredits(); - } else if (company_data.error_message === 'No Account Token') { - self._notifyAccountToken(); - } else { - self.displayNotification({ message: company_data.error_message }); + notifyNoCredits(); + } + else if (company_data.error_message === 'No Account Token') { + notifyAccountToken(); + } + else { + notification.add(company_data.error_message); } company_data = company; } - if (_.isEmpty(company_data)) { + if (!Object.keys(company_data).length) { company_data = company; } - // Delete attribute to avoid "Field_changed" errors removeUselessFields(company_data); // Assign VAT coming from parent VIES VAT query @@ -172,33 +169,7 @@ var PartnerAutocompleteMixin = { }); }); }); - }, - - /** - * Check connectivity - * - * @returns {boolean} - */ - _isOnline: function () { - return navigator && navigator.onLine; - }, - - /** - * Validate: Not empty and length > 1 - * - * @param {string} search_val - * @param {string} onlyVAT : Only valid VAT Number search - * @returns {boolean} - * @private - */ - _validateSearchTerm: function (search_val, onlyVAT) { - if (onlyVAT) return this._isVAT(search_val); - else return search_val && search_val.length > 2; - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- + } /** * Returns a promise which will be resolved with the base64 data of the @@ -208,18 +179,18 @@ var PartnerAutocompleteMixin = { * @param {string} url : the url where to find the image to fetch * @returns {Promise} */ - _getBase64Image: function (url) { - return new Promise(function (resolve, reject) { - var xhr = new XMLHttpRequest(); - xhr.onload = function () { - utils.getDataURLFromFile(xhr.response).then(resolve); + function getBase64Image(url) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.onload = () => { + getDataURLFromFile(xhr.response).then(resolve); }; xhr.open('GET', url); xhr.responseType = 'blob'; xhr.onerror = reject; xhr.send(); }); - }, + } /** * Use Clearbit Autocomplete API to return suggestions @@ -228,25 +199,11 @@ var PartnerAutocompleteMixin = { * @returns {Promise} * @private */ - _getClearbitSuggestions: function (value) { - var url = 'https://autocomplete.clearbit.com/v1/companies/suggest?query=' + value; - var def = $.ajax({ - url: url, - dataType: 'json', - timeout: this._timeout, - success: function (suggestions) { - suggestions.map(function (suggestion) { - suggestion.label = suggestion.name; - suggestion.website = suggestion.domain; - suggestion.description = suggestion.website; - return suggestion; - }); - return suggestions; - }, - }); - - return this._dropPreviousClearbit.add(def); - }, + async function getClearbitSuggestions(value) { + const url = `https://autocomplete.clearbit.com/v1/companies/suggest?query=${value}`; + const prom = http.get(url); + return keepLastClearbit.add(prom); + } /** * Use Odoo Autocomplete API to return suggestions @@ -256,56 +213,31 @@ var PartnerAutocompleteMixin = { * @returns {Promise} * @private */ - _getOdooSuggestions: function (value, isVAT) { - var method = isVAT ? 'read_by_vat' : 'autocomplete'; + async function getOdooSuggestions(value, isVAT) { + const method = isVAT ? 'read_by_vat' : 'autocomplete'; - var def = this._rpc({ - model: 'res.partner', - method: method, - args: [value], - }, { - shadow: true, - }).then(function (suggestions) { - suggestions.map(function (suggestion) { - suggestion.logo = suggestion.logo || ''; - suggestion.label = suggestion.legal_name || suggestion.name; - if (suggestion.vat) suggestion.description = suggestion.vat; - else if (suggestion.website) suggestion.description = suggestion.website; + const prom = orm.silent.call( + 'res.partner', + method, + [value], + ); - if (suggestion.country_id && suggestion.country_id.display_name) { - if (suggestion.description) suggestion.description += _.str.sprintf(' (%s)', suggestion.country_id.display_name); - else suggestion.description += suggestion.country_id.display_name; - } + const suggestions = await keepLastOdoo.add(prom); + suggestions.map((suggestion) => { + suggestion.logo = suggestion.logo || ''; + suggestion.label = suggestion.legal_name || suggestion.name; + if (suggestion.vat) suggestion.description = suggestion.vat; + else if (suggestion.website) suggestion.description = suggestion.website; - return suggestion; - }); - return suggestions; + if (suggestion.country_id && suggestion.country_id.display_name) { + if (suggestion.description) suggestion.description += ` (${suggestion.country_id.display_name})`; + else suggestion.description += suggestion.country_id.display_name; + } + + return suggestion; }); - - return this._dropPreviousOdoo.add(def); - }, - /** - * Check if searched value is possibly a VAT : 2 first chars = alpha + min 5 numbers - * - * @param {string} search_val - * @returns {boolean} - * @private - */ - _isVAT: function (search_val) { - var str = this._sanitizeVAT(search_val); - return checkVATNumber(str); - }, - - /** - * Sanitize search value by removing all not alphanumeric - * - * @param {string} search_value - * @returns {string} - * @private - */ - _sanitizeVAT: function (search_value) { - return search_value ? search_value.replace(/[^A-Za-z0-9]/g, '') : ''; - }, + return suggestions; + } /** * Utility to wait for multiple promises @@ -316,60 +248,49 @@ var PartnerAutocompleteMixin = { * @returns {Promise} * @private */ - _whenAll: function (promises) { - return Promise.all(promises.map(function (p) { + function whenAll(promises) { + return Promise.all(promises.map((p) => { return Promise.resolve(p); })); - }, + } /** * @private * @returns {Promise} */ - _notifyNoCredits: function () { - var self = this; - return this._rpc({ - model: 'iap.account', - method: 'get_credits_url', - args: ['partner_autocomplete'], - }).then(function (url) { - var title = _t('Not enough credits for Partner Autocomplete'); - var content = Qweb.render('partner_autocomplete.insufficient_credit_notification', { - credits_url: url + async function notifyNoCredits() { + const url = await orm.call( + 'iap.account', + 'get_credits_url', + ['partner_autocomplete'], + ); + const title = _t('Not enough credits for Partner Autocomplete'); + const content = renderToMarkup('partner_autocomplete.InsufficientCreditNotification', { + credits_url: url + }); + notification.add(content, { + title, + }); + } + + async function notifyAccountToken() { + const url = await orm.call( + 'iap.account', + 'get_config_account_url', + [] + ); + const title = _t('IAP Account Token missing'); + if (url) { + const content = renderToMarkup('partner_autocomplete.AccountTokenMissingNotification', { + account_url: url }); - self.displayNotification({ + notification.add(content, { title, - message: utils.Markup(content), - className: 'o_partner_autocomplete_no_credits_notify', }); - }); - }, - - _notifyAccountToken: function () { - var self = this; - return this._rpc({ - model: 'iap.account', - method: 'get_config_account_url', - args: [] - }).then(function (url) { - var title = _t('IAP Account Token missing'); - if (url){ - var content = Qweb.render('partner_autocomplete.account_token', { - account_url: url - }); - self.displayNotification({ - title, - message: utils.Markup(content), - className: 'o_partner_autocomplete_no_credits_notify', - }); - } - else { - self.displayNotification({ title }); - } - }); - }, -}; - -return PartnerAutocompleteMixin; - -}); + } + else { + notification.add(title); + } + } + return { autocomplete, getCreateData }; +} diff --git a/addons/partner_autocomplete/static/src/js/partner_autocomplete_fieldchar.js b/addons/partner_autocomplete/static/src/js/partner_autocomplete_fieldchar.js index a9342c5d7fc..03be2716579 100644 --- a/addons/partner_autocomplete/static/src/js/partner_autocomplete_fieldchar.js +++ b/addons/partner_autocomplete/static/src/js/partner_autocomplete_fieldchar.js @@ -1,341 +1,96 @@ -odoo.define('partner.autocomplete.fieldchar', function (require) { -'use strict'; +/** @odoo-module **/ +/* global checkVATNumber */ -var basic_fields = require('web.basic_fields'); -var core = require('web.core'); -var field_registry = require('web.field_registry'); -var AutocompleteMixin = require('partner.autocomplete.Mixin'); +import { AutoComplete } from "@web/core/autocomplete/autocomplete"; +import { useChildRef } from "@web/core/utils/hooks"; +import { registry } from "@web/core/registry"; +import { _t } from "@web/core/l10n/translation"; +import { CharField } from "@web/views/fields/char/char_field"; +import { useInputField } from "@web/views/fields/input_field_hook"; -var QWeb = core.qweb; +import { usePartnerAutocomplete } from "@partner_autocomplete/js/partner_autocomplete_core" -var FieldChar = basic_fields.FieldChar; +export class PartnerAutoCompleteCharField extends CharField { + setup() { + super.setup(); -/** - * FieldChar extension to suggest existing companies when changing the company - * name on a res.partner view (indeed, it is designed to change the "name", - * "website" and "image" fields of records of this model). - */ -var FieldAutocomplete = FieldChar.extend(AutocompleteMixin, { - className: 'o_field_partner_autocomplete', - debounceSuggestions: 400, - resetOnAnyFieldChange: true, + this.partner_autocomplete = usePartnerAutocomplete(); - jsLibs: [ - '/partner_autocomplete/static/lib/jsvat.js' - ], + this.inputRef = useChildRef(); + useInputField({ getValue: () => this.props.value || "", parse: (v) => this.parse(v), ref: this.inputRef}); + } - events: _.extend({}, FieldChar.prototype.events, { - 'keyup': '_onKeyup', - 'mousedown .o_partner_autocomplete_suggestion': '_onMousedown', - 'focusout': '_onFocusout', - 'mouseenter .o_partner_autocomplete_suggestion': '_onHoverDropdown', - 'click .o_partner_autocomplete_suggestion': '_onSuggestionClicked', - }), + sanitizeVAT(request) { + return request ? request.replace(/[^A-Za-z0-9]/g, '') : ''; + } - /** - * @constructor - * Prepares the basic rendering of edit mode by setting the root to be a - * div.dropdown.open. - * @see FieldChar.init - */ - init: function () { - this._super.apply(this, arguments); + isVAT(request) { + // checkVATNumber is defined in library jsvat. + // It validates that the input has a valid VAT number format + return checkVATNumber(this.sanitizeVAT(request)); + } - // If the autocomplete is applied to vat field, only search valid vat number - this.onlyVAT = this.name === 'vat'; - - if (this.mode === 'edit') { - this.tagName = 'div'; - this.className += ' dropdown open w-100'; + validateSearchTerm(request) { + if (this.props.name == 'vat') { + return this.isVAT(request); } - - if (this.debounceSuggestions > 0) { - this._suggestCompanies = _.debounce(this._suggestCompanies.bind(this), this.debounceSuggestions); + else { + return request && request.length > 2; } - }, + } - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Check if the autocomplete should be active - * Active : - * - only when creating new record - * - on model res.partner and is_company=true - * - on model res.company - * - * @returns {boolean} - * @private - */ - _isActive: function () { - return this.model === 'res.company' || - ( - this.model === 'res.partner' - && this.record.data.is_company - && !(this.record.data && this.record.data.id) - ); - }, - - /** - * - * @private - */ - _removeDropdown: function () { - if (this.$dropdown) { - this.$dropdown.remove(); - this.$dropdown = undefined; - } - }, - - /** - * Adds the element and prepares it. Note: the dropdown rendering - * is handled outside of the rendering routine (but instead by reacting to - * user input). - * - * @override - * @private - */ - _renderEdit: function () { - this.$el.empty(); - // Prepare and add the input - this._prepareInput().appendTo(this.$el); - }, - - /** - * Selects the given company suggestions by notifying changes to the view - * for the "name", "website" and "image" fields. This is of course intended - * to work only with the "res.partner" form view. - * - * @private - * @param {Object} company - */ - _selectCompany: function (company) { - var self = this; - this._getCreateData(company).then(function (data) { - if (data.logo) { - var logoField = self.model === 'res.partner' ? 'image_1920' : 'logo'; - data.company[logoField] = data.logo; - } - - // Some fields are unnecessary in res.company - if (self.model === 'res.company') { - var fields = 'comment,child_ids,bank_ids,additional_info'.split(','); - fields.forEach(function (field) { - delete data.company[field]; - }); - } - - self._setOne2ManyField('bank_ids', data.company.bank_ids); - delete data.company.bank_ids; - - self.trigger_up('field_changed', { - dataPointID: self.dataPointID, - changes: data.company, - onSuccess: function () { - // update the input's value directly - if (self.onlyVAT) - self.$input.val(self._formatValue(company.vat)); - else - self.$input.val(self._formatValue(company.name)); + get sources() { + return [ + { + options: async (request) => { + if (this.validateSearchTerm(request)) { + const suggestions = await this.partner_autocomplete.autocomplete(request, this.isVAT(request)); + suggestions.forEach((suggestion) => { + suggestion.classList = "partner_autocomplete_dropdown_char"; + }); + return suggestions; + } + else { + return []; + } }, + optionTemplate: "partner_autocomplete.CharFieldDropdownOption", + placeholder: _t('Searching Autocomplete...'), + }, + ]; + } + + async onSelect(option) { + const data = await this.partner_autocomplete.getCreateData(Object.getPrototypeOf(option)); + + if (data.logo) { + const logoField = this.props.record.resModel === 'res.partner' ? 'image_1920' : 'logo'; + data.company[logoField] = data.logo; + } + + // Some fields are unnecessary in res.company + if (this.props.record.resModel === 'res.company') { + const fields = ['comment', 'child_ids', 'additional_info']; + fields.forEach((field) => { + delete data.company[field]; }); + } + + // Format the many2one fields + const many2oneFields = ['country_id', 'state_id']; + many2oneFields.forEach((field) => { + if (data.company[field]) { + data.company[field] = [data.company[field].id, data.company[field].display_name]; + } }); - this._removeDropdown(); - }, + this.props.record.update(data.company); + } +} - _setOne2ManyField: function (field, list) { - var self = this; - var viewType = this.record.viewType; - if (list && this.record.fieldsInfo[viewType] && this.record.fieldsInfo[viewType][field]) { - list.forEach(function (item) { - var changes = {}; - changes[field] = { - operation: 'CREATE', - data: item, - }; +PartnerAutoCompleteCharField.template = "partner_autocomplete.PartnerAutoCompleteCharField"; +PartnerAutoCompleteCharField.components = { + ...CharField.components, + AutoComplete, +}; - self.trigger_up('field_changed', { - dataPointID: self.dataPointID, - changes: changes, - }); - }); - } - }, - - /** - * Shows the dropdown with the suggestions. If one is - * already opened, it removes the old one before rerendering the dropdown. - * - * @private - */ - _showDropdown: function () { - this._removeDropdown(); - if (this.suggestions.length > 0) { - this.$dropdown = $(QWeb.render('partner_autocomplete.dropdown', { - suggestions: this.suggestions, - })); - this.$dropdown.appendTo(this.$el); - // We need to make sure that the element containing the dropdown menu doesn't have the - // style attribute overflow: hidden, otherwise the dropdown menu will be hidden by it - this.$el.removeClass('o_text_overflow'); - this.$input.addClass('o_text_overflow'); - } - }, - - /** - * Shows suggestions according to the given value. - * Note: this method is debounced (@see init). - * - * @private - * @param {string} value - searched term - */ - _suggestCompanies: function (value) { - var self = this; - if (this._validateSearchTerm(value, this.onlyVAT) && this._isOnline()) { - return this._autocomplete(value).then(function (suggestions) { - if (suggestions && suggestions.length) { - self.suggestions = suggestions; - self._showDropdown(); - } else { - self._removeDropdown(); - } - }); - } else { - this._removeDropdown(); - } - }, - - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * Called on focusout -> removes the suggestions dropdown. - * - * @private - */ - _onFocusout: function () { - this._removeDropdown(); - }, - - /** - * Called when hovering a suggestion in the dropdown -> sets it as active. - * - * @private - * @param {Event} e - */ - _onHoverDropdown: function (e) { - this.$dropdown.find('.active').removeClass('active'); - $(e.currentTarget).parent().addClass('active'); - }, - - /** - * @override of FieldChar (called when the user is typing text) - * Checks the value and shows suggestions according to - * this value. - * - * @private - */ - _onInput: function () { - this._super.apply(this, arguments); - if (this._isActive()) { - this._suggestCompanies(this.$input.val()); - } - }, - - /** - * @override of FieldChar - * Changes the "up" and "down" key behavior when the dropdown is opened (to - * navigate through dropdown suggestions). - * Triggered by keydown to execute the navigation multiple times when the - * user keeps the "down" or "up" pressed. - * - * @private - * @param {Event} e - */ - _onKeydown: function (e) { - switch (e.which) { - case $.ui.keyCode.UP: - case $.ui.keyCode.DOWN: - if (!this.$dropdown) { - break; - } - e.preventDefault(); - var $suggestions = this.$dropdown.children(); - var $active = $suggestions.filter('.active'); - var $to; - if ($active.length) { - $to = e.which === $.ui.keyCode.DOWN ? - $active.next() : - $active.prev(); - } else { - $to = $suggestions.first(); - } - if ($to.length) { - $active.removeClass('active'); - $to.addClass('active'); - } - return; - } - this._super.apply(this, arguments); - }, - - /** - * Called on keyup events to: - * -> remove the suggestions dropdown when hitting the "escape" key - * -> select the highlighted suggestion when hitting the "enter" key - * - * @private - * @param {Event} e - */ - _onKeyup: function (e) { - switch (e.which) { - case $.ui.keyCode.ESCAPE: - e.preventDefault(); - this._removeDropdown(); - break; - case $.ui.keyCode.ENTER: - if (!this.$dropdown) { - break; - } - e.preventDefault(); - var $active = this.$dropdown.find('.o_partner_autocomplete_suggestion.active'); - if (!$active.length) { - return; - } - this._selectCompany(this.suggestions[$active.data('index')]); - break; - } - }, - - /** - * Called on mousedown event on a suggestion -> prevent default - * action so that the element does not lose the focus. - * - * @private - * @param {Event} e - */ - _onMousedown: function (e) { - e.preventDefault(); // prevent losing focus on suggestion click - }, - - /** - * Called when a dropdown suggestion is clicked -> trigger_up changes for - * some fields in the view (not only this one) with the associated - * data (@see _selectCompany). - * - * @private - * @param {Event} e - */ - _onSuggestionClicked: function (e) { - e.preventDefault(); - this._selectCompany(this.suggestions[$(e.currentTarget).data('index')]); - }, -}); - -field_registry.add('field_partner_autocomplete', FieldAutocomplete); - -return FieldAutocomplete; -}); +registry.category("fields").add("field_partner_autocomplete", PartnerAutoCompleteCharField); diff --git a/addons/partner_autocomplete/static/src/js/partner_autocomplete_many2one.js b/addons/partner_autocomplete/static/src/js/partner_autocomplete_many2one.js index 2fbeefced4e..1f503860f86 100644 --- a/addons/partner_autocomplete/static/src/js/partner_autocomplete_many2one.js +++ b/addons/partner_autocomplete/static/src/js/partner_autocomplete_many2one.js @@ -1,141 +1,72 @@ -odoo.define('partner.autocomplete.many2one', function (require) { -'use strict'; +/** @odoo-module **/ -var FieldMany2One = require('web.relational_fields').FieldMany2One; -var core = require('web.core'); -var AutocompleteMixin = require('partner.autocomplete.Mixin'); -var field_registry = require('web.field_registry'); +import { Many2XAutocomplete } from '@web/views/fields/relational_utils'; +import { Many2OneField } from '@web/views/fields/many2one/many2one_field'; +import { _t } from "@web/core/l10n/translation"; +import { registry } from "@web/core/registry"; -var _t = core._t; +import { usePartnerAutocomplete } from "@partner_autocomplete/js/partner_autocomplete_core" -var PartnerField = FieldMany2One.extend(AutocompleteMixin, { - jsLibs: [ - '/partner_autocomplete/static/lib/jsvat.js' - ], +export class PartnerMany2XAutocomplete extends Many2XAutocomplete { + setup() { + super.setup(); - /** - * @override - */ - init: function () { - this._super.apply(this, arguments); - this._addAutocompleteSource(this._searchSuggestions, { - placeholder: _t('Searching Autocomplete...'), - order: 20, - validation: this._validateSearchTerm, - }); + this.partner_autocomplete = usePartnerAutocomplete(); + } - this.additionalContext['show_vat'] = true; - }, + validateSearchTerm(request) { + return request && request.length > 2; + } - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- + get sources() { + return super.sources.concat( + { + options: async (request) => { + if (this.validateSearchTerm(request)) { + const suggestions = await this.partner_autocomplete.autocomplete(request); + suggestions.forEach((suggestion) => { + suggestion.classList = "partner_autocomplete_dropdown_many2one"; + }); + return suggestions; + } + else { + return []; + } + }, + optionTemplate: "partner_autocomplete.Many2oneDropdownOption", + placeholder: _t('Searching Autocomplete...'), + }, + ); + } - /** - * Action : create popup form with pre-filled values from Autocomplete - * - * @param {Object} company - * @returns {Promise} - * @private - */ - _createPartner: function (company) { - var self = this; - self.$('input').val(''); - - return self._getCreateData(company).then(function (data){ - var context = { + async onSelect(option) { + if (option.partner_gid) { // Checks that it is a partner autocomplete option + const data = await this.partner_autocomplete.getCreateData(Object.getPrototypeOf(option)); + let context = { 'default_is_company': true }; - _.each(data.company, function (val, key) { + + for (const [key, val] of Object.entries(data.company)) { context['default_' + key] = val && val.id ? val.id : val; - }); + } - // if(data.company.street_name && !data.company.street_number) context.default_street_number = ''; - if (data.logo) context.default_image_1920 = data.logo; - - return self._searchCreatePopup("form", false, context); - }); - }, - - /** - * Modify autocomplete results rendering - * Add logo in the autocomplete results if logo is provided - * - * @private - */ - _modifyAutompleteRendering: function (){ - var api = this.$input.data('ui-autocomplete'); - // FIXME: bugfix to prevent traceback in mobile apps due to override - // of Many2one widget with native implementation. - if (!api) { - return; + if (data.logo) { + context.default_image_1920 = data.logo; + } + return this.openMany2X({ context }); } - api._renderItem = function(ul, item){ - ul.addClass('o_partner_autocomplete_dropdown'); - var $a = $('')["html"](item.label); - if (item.logo){ - var $img = $('').attr('src', item.logo); - $a.append($img); - } + else { + return super.onSelect(option); + } + } - return $("
  • ") - .data("item.autocomplete",item) - .append($a) - .appendTo(ul) - .addClass(item.classname); - }; - }, +} - /** - * @override - * @private - */ - _renderEdit: function (){ - this._super.apply(this, arguments); - this._modifyAutompleteRendering(); - }, +export class PartnerAutoCompleteMany2one extends Many2OneField {} - /** - * Query Autocomplete and add results to the popup - * - * @override - * @param search_val {string} - * @returns {Promise} - * @private - */ - _searchSuggestions: function (search_val) { - var self = this; - return new Promise(function (resolve, reject) { - if (self._isOnline()) { +PartnerAutoCompleteMany2one.components = { + ...Many2OneField.components, + Many2XAutocomplete: PartnerMany2XAutocomplete, +} - self._autocomplete(search_val).then(function (suggestions) { - var choices = []; - if (suggestions && suggestions.length) { - _.each(suggestions, function (suggestion) { - var label = ' '; - label += _.str.sprintf('%s, %s', suggestion.label, suggestion.description); - - choices.push({ - label: label, - action: function () { - self._createPartner(suggestion); - }, - logo: suggestion.logo, - classname: 'o_partner_autocomplete_dropdown_item', - }); - }); - } - - resolve(choices); - }); - } else { - resolve([]); - } - }); - }, -}); - -field_registry.add('res_partner_many2one', PartnerField); - -return PartnerField; -}); +registry.category("fields").add("res_partner_many2one", PartnerAutoCompleteMany2one); diff --git a/addons/partner_autocomplete/static/src/scss/partner_autocomplete.scss b/addons/partner_autocomplete/static/src/scss/partner_autocomplete.scss index cb337d4bc80..d785bfeef63 100644 --- a/addons/partner_autocomplete/static/src/scss/partner_autocomplete.scss +++ b/addons/partner_autocomplete/static/src/scss/partner_autocomplete.scss @@ -1,66 +1,32 @@ -.o_field_partner_autocomplete.dropdown { - > .o_partner_autocomplete_dropdown .dropdown-item { - min-width: 300px; - padding: 4px 8px; - > img { - float: left; - width: 36px; - height: 36px; +.ui-widget.ui-autocomplete { + .ui-menu-item { + &.partner_autocomplete_dropdown_char { + > a { + > img { + float: left; + width: 32px; + height: 32px; + } + > .o_partner_autocomplete_info { + margin-left: 50px; + > * { + @include o-text-overflow(block); + } + > strong { + font-weight: $font-weight-bolder; + } + } + } } - > .o_partner_autocomplete_info { - margin-left: 50px; - > * { - @include o-text-overflow(block); + &.partner_autocomplete_dropdown_many2one { + > a { + > img { + float: right; + width: 20px; + height: 20px; + right: 20px; + } } } } } - -.ui-autocomplete.o_partner_autocomplete_dropdown { - > .ui-menu-item:nth-of-type(1n+16) { - display: none; - } - > .o_partner_autocomplete_dropdown_item { - > a { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - position: relative; - padding-right: 50px; - padding-left: 40px; - max-width: 400px; - > img { - position: absolute; - width: 20px; - height: 20px; - right: 20px; - top: 3px; - } - } - } - > .ui-menu-item > a.ui-state-active .text-muted { - color: white !important; - } -} - -@media (max-height: 700px) { - .ui-autocomplete.o_partner_autocomplete_dropdown { - > .ui-menu-item:nth-of-type(1n+13) { - display: none; - } - } -} -@media (max-height: 620px) { - .ui-autocomplete.o_partner_autocomplete_dropdown { - > .ui-menu-item:nth-of-type(1n+12) { - display: none; - } - } -} -@media (max-height: 570px) { - .ui-autocomplete.o_partner_autocomplete_dropdown { - > .ui-menu-item:nth-of-type(1n+11) { - display: none; - } - } -} diff --git a/addons/partner_autocomplete/static/src/xml/partner_autocomplete.xml b/addons/partner_autocomplete/static/src/xml/partner_autocomplete.xml index 6a1edfd843e..f929d4f6408 100644 --- a/addons/partner_autocomplete/static/src/xml/partner_autocomplete.xml +++ b/addons/partner_autocomplete/static/src/xml/partner_autocomplete.xml @@ -1,27 +1,42 @@ -
    + + + + + + + - - + + Placeholder +
    + +
    +
    + - + + + , + + Placeholder + + + + + + + + + diff --git a/addons/partner_autocomplete/static/tests/partner_autocomplete_tests.js b/addons/partner_autocomplete/static/tests/partner_autocomplete_tests.js index 160a1ebb898..304e55dbb4f 100644 --- a/addons/partner_autocomplete/static/tests/partner_autocomplete_tests.js +++ b/addons/partner_autocomplete/static/tests/partner_autocomplete_tests.js @@ -1,115 +1,65 @@ -odoo.define('partner_autocomplete.tests', function (require) { - "use strict"; +/** @odoo-module **/ - var FormView = require('web.FormView'); - var concurrency = require('web.concurrency'); - var testUtils = require("web.test_utils"); - var AutocompleteField = require('partner.autocomplete.fieldchar'); - var PartnerField = require('partner.autocomplete.many2one'); +import { browser } from "@web/core/browser/browser"; +import { registry } from "@web/core/registry"; +import { + click, + editSelect, + getFixture, + patchWithCleanup, + triggerEvent, +} from "@web/../tests/helpers/utils"; +import { makeView, setupViewRegistries } from "@web/../tests/views/helpers"; - var createView = testUtils.createView; +const serviceRegistry = registry.category("services"); - function _compareResultFields(assert, form, fields, createData) { - var type, formatted, $fieldInput; +let target; - _.each(createData, function (val, key) { - if (fields[key]) { - if (key === 'image_1920') { - if (val) val = 'data:image/png;base64,' + val; - assert.hasAttrValue(form.$(".o_field_image img"), "data-src", val, 'image value should have been updated to "' + val + '"'); - } else { - type = fields[key].type; - $fieldInput = form.$('input[name="' + key + '"]'); - if ($fieldInput.length) { - formatted = $fieldInput.val(); - formatted = type === 'integer' ? parseInt(formatted, 10) : formatted; - assert.strictEqual( - formatted, - val === false ? 0 : val, - key + ' value should have been updated to "' + val + '"' - ); - } +async function editInputNoChangeEvent(input, value) { + // Note: we can't use editInput as it triggers the 'change' event which will close the autocomplete dropdown + input.value = value; + await triggerEvent(input, null, "input"); +} - } - } +QUnit.module('partner_autocomplete', { + beforeEach() { + target = getFixture(); + + // Make autocomplete input instantaneous + patchWithCleanup(browser, { + setTimeout: (fn) => fn(), }); - } - var suggestions = [{ - name: "Odoo", - website: "odoo.com", - domain: "odoo.com", - logo: "odoo.com/logo.png", - vat: "BE0477472701" - }]; + setupViewRegistries(); + const fakeHTTPService = { + start() { + return { + get: (route) => { + return Promise.resolve([ + { + "name": "Odoo", + "domain": "odoo.com", + }, + { + "name": "MyCompany", + "domain": "mycompany.com", + }, + { + "name": "YourCompany", + "domain": "yourcompany.com", + }, + ]); + }, + }; + }, + }; + serviceRegistry.add("http", fakeHTTPService); + }, +}, function () { - var enrichData = {}; - - var createData = {}; - - QUnit.module('partner_autocomplete', { - before: function () { - var fieldsToPatch = [PartnerField, AutocompleteField]; - _.each(fieldsToPatch, function (fieldToPatch) { - testUtils.mock.patch(fieldToPatch, { - _getBase64Image: function (url) { - return Promise.resolve(url === "odoo.com/logo.png" ? "odoobase64" : ""); - }, - _isOnline: function () { - return true; - }, - _getCreateData: function (company) { - var def = this._super.apply(this, arguments); - def.then(function (data) { - createData = data.company; - }); - return def; - }, - _enrichCompany: function (company) { - return Promise.resolve(enrichData); - }, - _getOdooSuggestions: function (value, isVAT) { - var results = _.filter(suggestions, function (suggestion) { - value = value ? value.toLowerCase() : ''; - if (isVAT) return (suggestion.vat.toLowerCase().indexOf(value) >= 0); - else return (suggestion.name.toLowerCase().indexOf(value) >= 0); - }); - return Promise.resolve(results); - }, - _getClearbitSuggestions: function (value) { - return this._getOdooSuggestions(value); - }, - displayNotification: function ({ title, message, sticky }) { - return this._super({ - type: 'warning', - title: title, - message: message, - sticky: sticky, - className: 'o_partner_autocomplete_test_notify' - }); - }, - }); - }); - - testUtils.mock.patch(AutocompleteField, { - debounceSuggestions: 0, - }); - }, - beforeEach: function () { - enrichData = { - country_id: 20, - state_id: false, - partner_gid: 1, - website: "odoo.com", - comment: "Comment on Odoo", - street: "40 Chaussée de Namur", - city: "Ramillies", - zip: "1367", - phone: "+1 650-691-3277", - vat: "BE0477472701", - }; - - this.data = { + const makeViewParams = { + serverData: { + models: { 'res.partner': { fields: { company_type: { @@ -119,15 +69,15 @@ odoo.define('partner_autocomplete.tests', function (require) { searchable: true }, name: {string: "Name", type: "char", searchable: true}, - parent_id: {string: "Company", type: "many2one", relation: "res.partner"}, + parent_id: {string: "Company", type: "many2one", relation: "res.partner", searchable: true}, website: {string: "Website", type: "char", searchable: true}, image_1920: {string: "Image", type: "binary", searchable: true}, phone: {string: "Phone", type: "char", searchable: true}, street: {string: "Street", type: "char", searchable: true}, city: {string: "City", type: "char", searchable: true}, zip: {string: "Zip", type: "char", searchable: true}, - state_id: {string: "State", type: "integer", searchable: true}, - country_id: {string: "Country", type: "integer", searchable: true}, + state_id: {string: "State", type: "many2one", relation: "res.country.state", searchable: true}, + country_id: {string: "Country", type: "many2one", relation: "res.country", searchable: true}, comment: {string: "Comment", type: "char", searchable: true}, vat: {string: "Vat", type: "char", searchable: true}, is_company: {string: "Is company", type: "bool", searchable: true}, @@ -135,288 +85,259 @@ odoo.define('partner_autocomplete.tests', function (require) { }, records: [], onchanges: { - company_type: function (obj) { + company_type: (obj) => { obj.is_company = obj.company_type === 'company'; }, }, }, - }; - }, - after: function () { - testUtils.mock.unpatch(AutocompleteField); - testUtils.mock.unpatch(PartnerField); + 'res.country': { + fields: { + display_name: {string: "Name", type: "char", searchable: true}, + }, + records: [{ + id: 1, + name: 'United States', + }], + }, + 'res.country.state': { + fields: { + display_name: {string: "Name", type: "char", searchable: true}, + }, + records: [{ + id: 1, + name: 'California (US)', + }], + }, + }, }, + resModel: "res.partner", + type: "form", + arch: + `
    + + + + + + + + + + + + + + `, + async mockRPC(route, args) { + if (route === "/web/dataset/call_kw/res.partner/autocomplete" || route === "/web/dataset/call_kw/res.partner/read_by_vat") { + return Promise.resolve([ + { + "partner_gid": 1, + "website": "firstcompany.com", + "name": "First company", + "ignored": false, + "vat": "" + }, + { + "partner_gid": 2, + "website": "secondcompany.com", + "name": "Second company", + "ignored": false, + "vat": "" + }, + { + "partner_gid": 3, + "website": "thirdcompany.com", + "name": "Third company", + "ignored": false, + "vat": "" + }, + ]); + } + else if (route === "/web/dataset/call_kw/res.partner/enrich_company") { + return Promise.resolve({ + "partner_gid": 1, + "website": "firstcompany.com", + "name": "First company", + 'logo': false, + "ignored": false, + "vat": "Some VAT number", + "street": "Some street", + "city": "Some city", + "zip": "1234", + "phone": "+0123456789", + "email": "info@firstcompany.com", + "country_id": { + 'id': 1, + 'display_name': "United States", + }, + "state_id": { + 'id': 1, + 'display_name': "California (US)", + }, + }); + } + } + } + + QUnit.test("Partner autocomplete : Company type = Individual", async function (assert) { + assert.expect(13); + await makeView(makeViewParams); + + // Set company type to Individual + await editSelect(target, "[name='company_type'] > select", '"individual"'); + + const nameInput = target.querySelector("[name='name'] input"); + assert.doesNotHaveClass(nameInput, 'o-autocomplete--input', "The input for field 'name' should be a regular input"); + + const companyInput = target.querySelector("[name='parent_id'] input"); + + await click(companyInput, null); + assert.containsNone( + companyInput, + ".o-autocomplete--dropdown-menu", + "There should be no option when input is empty" + ); + + await editInputNoChangeEvent(companyInput, "od"); + assert.containsNone( + companyInput, + ".o-autocomplete--dropdown-menu", + "There should be no option when the length of the query is < 3" + ); + + await editInputNoChangeEvent(companyInput, "company"); + const autocompleteContainer = companyInput.parentElement; + assert.containsN( + autocompleteContainer, + ".o-autocomplete--dropdown-item.partner_autocomplete_dropdown_many2one", + 6, + "Clearbit and Odoo autocomplete options should be shown" + ); + + // Click on the first option - "First company" + await click(autocompleteContainer.querySelectorAll('ul li.partner_autocomplete_dropdown_many2one')[0], null); + + const modalContent = target.querySelector('.o_dialog_container .modal-content'); + // Check that the fields of the modal have been pre-filled + const expectedValues = { + "website": "firstcompany.com", + "name": "First company", + "vat": "Some VAT number", + "street": "Some street", + "city": "Some city", + "zip": "1234", + "phone": "+0123456789", + "country_id": "United States", + "state_id": "California (US)", + }; + for (const [fieldName, expectedValue] of Object.entries(expectedValues)) { + assert.strictEqual(modalContent.querySelector(`[name=${fieldName}] input`).value, expectedValue, `${fieldName} should be pre-filled`); + } }); - QUnit.test("Partner autocomplete : Company type = Individual", function (assert) { - assert.expect(2); - var done = assert.async(); - createView({ - View: FormView, - model: 'res.partner', - data: this.data, - arch: - '
    ' + - '' + - '' + - '' + - '' + - '', - }).then(function (form){ - // Set company type to Individual - var $company_type = form.$("select[name='company_type']"); - testUtils.fields.editSelect($company_type, '"individual"'); - - // Check input exists - var $input = form.$(".o_field_partner_autocomplete > input:visible"); - assert.strictEqual($input.length, 1, "there should be an for the Partner field"); - - // Change input val and assert nothing happens - testUtils.fields.editInput($input, "odoo") - var $dropdown = form.$(".o_field_partner_autocomplete .dropdown-menu:visible"); - assert.strictEqual($dropdown.length, 0, "there should not be an opened dropdown"); - - form.destroy(); - - done(); - }); - }); - - QUnit.test("Partner autocomplete : Company type = Company / Name search", async function (assert) { - assert.expect(17); - var fields = this.data['res.partner'].fields; - var form = await createView({ - View: FormView, - model: 'res.partner', - data: this.data, - arch: - '
    ' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '', - mockRPC: function (route) { - if (route === "/web/static/img/placeholder.png" - || route === "odoo.com/logo.png" - || route === "data:image/png;base64,odoobase64") { // land here as it is not valid base64 content - return Promise.resolve(); - } - return this._super.apply(this, arguments); - }, - }); - // Set company type to Company - var $company_type = form.$("select[name='company_type']"); - await testUtils.fields.editSelect($company_type, '"company"'); + assert.expect(12); + await makeView(makeViewParams); - // Check input exists - var $input = form.$(".o_field_partner_autocomplete > input:visible"); - assert.strictEqual($input.length, 1, "there should be an for the field"); + // Set company type to Company + await editSelect(target, "[name='company_type'] > select", '"company"'); - // Change input val and assert changes - await testUtils.fields.editInput($input, "odoo"); - await testUtils.nextTick(); - var $dropdown = form.$(".o_field_partner_autocomplete .dropdown-menu:visible"); - assert.strictEqual($dropdown.length, 1, "there should be an opened dropdown"); - assert.strictEqual($dropdown.children().length, 1, "there should be only ne proposition"); + const input = target.querySelector("[name='name'] .dropdown input"); - await testUtils.dom.click($dropdown.find("a").first()); - $input = form.$(".o_field_partner_autocomplete > input"); - assert.strictEqual($input.val(), "Odoo", "Input value should have been updated to \"Odoo\""); - assert.strictEqual(form.$("input.o_field_widget").val(), "odoo.com", "website value should have been updated to \"odoo.com\""); + await click(input, null); + assert.containsNone( + input, + ".o-autocomplete--dropdown-menu", + "There should be no option when input is empty" + ); - _compareResultFields(assert, form, fields, createData); + await editInputNoChangeEvent(input, "od"); + assert.containsNone( + input, + ".o-autocomplete--dropdown-menu", + "There should be no option when the length of the query is < 3" + ); - // Try suggestion with bullshit query - await testUtils.fields.editInput($input, "ZZZZZZZZZZZZZZZZZZZZZZ"); - $dropdown = form.$(".o_field_partner_autocomplete .dropdown-menu:visible"); - assert.strictEqual($dropdown.length, 0, "there should be no opened dropdown when no result"); + await editInputNoChangeEvent(input, "company"); + const autocompleteContainer = input.parentElement; + assert.containsN( + autocompleteContainer, + ".o-autocomplete--dropdown-item", + 6, + "Clearbit and Odoo autocomplete options should be shown" + ); - // Try autocomplete again - await testUtils.fields.editInput($input, "odoo"); - await testUtils.nextTick(); - $dropdown = form.$(".o_field_partner_autocomplete .dropdown-menu:visible"); - assert.strictEqual($dropdown.length, 1, "there should be an opened dropdown when typing odoo letters again"); + // Click on the first option - "First company" + await click(autocompleteContainer.querySelectorAll('ul li')[0], null); - // Test if dropdown closes on focusout - $input.trigger("focusout"); - await testUtils.nextTick(); - $dropdown = form.$(".o_field_partner_autocomplete .dropdown-menu:visible"); - assert.strictEqual($dropdown.length, 0, "unfocusing the input should close the dropdown"); - - form.destroy(); + // Check that the fields have been filled + const expectedValues = { + "website": "firstcompany.com", + "name": "First company", + "vat": "Some VAT number", + "street": "Some street", + "city": "Some city", + "zip": "1234", + "phone": "+0123456789", + "country_id": "United States", + "state_id": "California (US)", + }; + for (const [fieldName, expectedValue] of Object.entries(expectedValues)) { + assert.strictEqual(target.querySelector(`[name=${fieldName}] input`).value, expectedValue, `${fieldName} should be filled`); + } }); QUnit.test("Partner autocomplete : Company type = Company / VAT search", async function (assert) { - assert.expect(27); - var fields = this.data['res.partner'].fields; - var form = await createView({ - View: FormView, - model: 'res.partner', - data: this.data, - arch: - '
    ' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '', - mockRPC: function (route) { - if (route === "/web/static/img/placeholder.png" - || route === "odoo.com/logo.png" - || route === "data:image/png;base64,odoobase64") { // land here as it is not valid base64 content - return Promise.resolve(); - } - return this._super.apply(this, arguments); - }, - }); - // Set company type to Company - var $company_type = form.$("select[name='company_type']"); - await testUtils.fields.editSelect($company_type, '"company"'); + assert.expect(12); + await makeView(makeViewParams); - // Check input exists - var $input = form.$(".o_field_partner_autocomplete > input:visible"); - assert.strictEqual($input.length, 1, "there should be an for the field"); + // Set company type to Company + await editSelect(target, "[name='company_type'] > select", '"company"'); - // Set incomplete VAT and assert changes - await testUtils.fields.editInput($input, "BE047747270") + const input = target.querySelector("[name='vat'] .dropdown input"); - var $dropdown = form.$(".o_field_partner_autocomplete .dropdown-menu:visible"); - assert.strictEqual($dropdown.length, 0, "there should be no opened dropdown no results with incomplete VAT number"); + await click(input, null); + assert.containsNone( + input, + ".o-autocomplete--dropdown-menu", + "There should be no option when input is empty" + ); - // Set complete VAT and assert changes - // First suggestion (only vat result) - await testUtils.fields.editInput($input, "BE0477472701") - $dropdown = form.$(".o_field_partner_autocomplete .dropdown-menu:visible"); - assert.strictEqual($dropdown.length, 1, "there should be an opened dropdown"); - assert.strictEqual($dropdown.children().length, 1, "there should be one proposition for complete VAT number"); + await editInputNoChangeEvent(input, "blabla"); + assert.containsNone( + input, + ".o-autocomplete--dropdown-menu", + "There should be no option when the value doesn't have a valid VAT number format" + ); - await testUtils.dom.click($dropdown.find("a").first()); + await editInputNoChangeEvent(input, "BE0477472701"); + const autocompleteContainer = input.parentElement; + assert.containsN( + autocompleteContainer, + ".o-autocomplete--dropdown-item", + 3, + "Odoo read_by_vat options should be shown" + ); - $input = form.$(".o_field_partner_autocomplete > input"); - assert.strictEqual($input.val(), "Odoo", "Input value should have been updated to \"Odoo\""); + // Click on the first option - "First company" + await click(autocompleteContainer.querySelectorAll('ul li')[0], null); - _compareResultFields(assert, form, fields, createData); - await testUtils.nextTick(); - // Set complete VAT and assert changes - // Second suggestion (only vat + clearbit result) - await testUtils.fields.editInput($input, "BE0477472701") - $dropdown = form.$(".o_field_partner_autocomplete .dropdown-menu:visible"); - assert.strictEqual($dropdown.length, 1, "there should be an opened dropdown"); - assert.strictEqual($dropdown.children().length, 1, "there should be one proposition for complete VAT number"); - - await testUtils.dom.click($dropdown.find("a").first()); - - $input = form.$(".o_field_partner_autocomplete > input"); - assert.strictEqual($input.val(), "Odoo", "Input value should have been updated to \"Odoo\""); - - _compareResultFields(assert, form, fields, createData); - - // Test if dropdown closes on focusout - $input.trigger("focusout"); - $dropdown = form.$(".o_field_partner_autocomplete .dropdown-menu:visible"); - assert.strictEqual($dropdown.length, 0, "unfocusing the input should close the dropdown"); - - form.destroy(); - - }); - - QUnit.test("Partner autocomplete : render Many2one", function (assert) { - var done = assert.async(); - assert.expect(3); - - var M2O_DELAY = PartnerField.prototype.AUTOCOMPLETE_DELAY; - PartnerField.prototype.AUTOCOMPLETE_DELAY = 0; - - createView({ - View: FormView, - model: 'res.partner', - data: this.data, - arch: - '
    ' + - '' + - '' + - '', - }).then(async function (form) { - var $input = form.$('.o_field_many2one[name="parent_id"] input:visible'); - assert.strictEqual($input.length, 1, "there should be an for the Many2one"); - - await testUtils.fields.editInput($input, 'odoo'); - - concurrency.delay(0).then(function () { - var $dropdown = $input.autocomplete('widget'); - assert.strictEqual($dropdown.length, 1, "there should be an opened dropdown"); - assert.ok($dropdown.is('.o_partner_autocomplete_dropdown'), - "there should be a partner_autocomplete"); - - PartnerField.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY; - form.destroy(); - - done(); - }); - }); - }); - - QUnit.test("Partner autocomplete : Notify not enough credits", async function (assert) { - assert.expect(2); - - enrichData = { - error: true, - error_message: 'Insufficient Credit', + // Check that the fields have been filled + const expectedValues = { + "website": "firstcompany.com", + "name": "First company", + "vat": "Some VAT number", + "street": "Some street", + "city": "Some city", + "zip": "1234", + "phone": "+0123456789", + "country_id": "United States", + "state_id": "California (US)", }; - - var form = await createView({ - View: FormView, - model: 'res.partner', - data: this.data, - arch: - '
    ' + - '' + - '' + - '', - services: { - notification: { - notify(notification) { - assert.equal(notification.type, "warning"); - assert.equal(notification.className, "o_partner_autocomplete_test_notify"); - }, - }, - }, - mockRPC: function (route, args) { - if (args.method === "get_credits_url"){ - return Promise.resolve('credits_url'); - } - return this._super.apply(this, arguments); - }, - }); - // Set company type to Company - var $company_type = form.$("select[name='company_type']"); - await testUtils.fields.editSelect($company_type, '"company"'); - - var $input = form.$(".o_field_partner_autocomplete > input:visible"); - await testUtils.fields.editInput($input, "BE0477472701"); - - var $dropdown = form.$(".o_field_partner_autocomplete .dropdown-menu:visible"); - await testUtils.dom.click($dropdown.find("a").first()); - - form.destroy(); + for (const [fieldName, expectedValue] of Object.entries(expectedValues)) { + assert.strictEqual(target.querySelector(`[name=${fieldName}] input`).value, expectedValue, `${fieldName} should be filled`); + } }); }); diff --git a/addons/web/static/src/core/autocomplete/autocomplete.js b/addons/web/static/src/core/autocomplete/autocomplete.js index 5e68ce478f4..23cb0e8dc9e 100644 --- a/addons/web/static/src/core/autocomplete/autocomplete.js +++ b/addons/web/static/src/core/autocomplete/autocomplete.js @@ -1,6 +1,6 @@ /** @odoo-module **/ -import { useService } from "@web/core/utils/hooks"; +import { useForwardRefToParent, useService } from "@web/core/utils/hooks"; import { useDebounced } from "@web/core/utils/timing"; import { getActiveHotkey } from "@web/core/hotkeys/hotkey_service"; import { usePosition } from "@web/core/position_hook"; @@ -21,7 +21,7 @@ export class AutoComplete extends Component { value: this.props.value, }); - this.inputRef = useRef("input"); + this.inputRef = useForwardRefToParent("input"); this.root = useRef("root"); this.debouncedOnInput = useDebounced(this.onInput, this.constructor.timeout); useExternalListener(window, "scroll", this.onWindowScroll, true); @@ -312,6 +312,7 @@ Object.assign(AutoComplete, { onInput: { type: Function, optional: true }, onChange: { type: Function, optional: true }, onBlur: { type: Function, optional: true }, + input: { type: Function, optional: true }, }, defaultProps: { placeholder: "", diff --git a/addons/web/static/src/views/fields/input_field_hook.js b/addons/web/static/src/views/fields/input_field_hook.js index 4bfcc32e76a..7fe73b57540 100644 --- a/addons/web/static/src/views/fields/input_field_hook.js +++ b/addons/web/static/src/views/fields/input_field_hook.js @@ -17,7 +17,7 @@ const { useComponent, useEffect, useRef, useEnv } = owl; */ export function useInputField(params) { const env = useEnv(); - const inputRef = useRef(params.refName || "input"); + const inputRef = params.ref || useRef(params.refName || "input"); const component = useComponent(); /*