[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 `<input/>` 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
This commit is contained in:
Louis Baudoux
2022-10-06 00:36:57 +02:00
parent 1ac1ad0fef
commit c93d6e6f13
9 changed files with 635 additions and 1127 deletions
+4 -6
View File
@@ -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/**/*',
@@ -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 };
}
@@ -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 <input/> 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 <input/> 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 <input/> 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 <input/> 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);
@@ -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 = $('<a/>')["html"](item.label);
if (item.logo){
var $img = $('<img/>').attr('src', item.logo);
$a.append($img);
}
else {
return super.onSelect(option);
}
}
return $("<li></li>")
.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 = '<i class="fa fa-magic text-muted"/> ';
label += _.str.sprintf('%s, <span class="text-muted">%s</span>', 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);
@@ -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;
}
}
}
@@ -1,27 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<div t-name="partner_autocomplete.dropdown" class="o_partner_autocomplete_dropdown dropdown-menu show" role="menu">
<t t-foreach="suggestions" t-as="info">
<a role="menuitem" href="#"
t-attf-class="dropdown-item o_partner_autocomplete_suggestion clearfix#{info_index == 0 and ' active' or ''}"
t-att-data-index="info_index">
<img t-att-src="info['logo']" onerror="this.src='/base/static/img/company_image.png'" alt="Placeholder"/>
<div class="o_partner_autocomplete_info">
<strong><t t-esc="info['label'] or '&#160;'"/></strong>
<div><t t-esc="info['description']"/></div>
</div>
</a>
</t>
</div>
<t t-name="partner_autocomplete.PartnerAutoCompleteCharField" t-inherit="web.CharField" owl="1">
<xpath expr="//t[@t-else='']" position="before">
<t t-elif="props.record.data.company_type === 'company'">
<AutoComplete
value="props.value || ''"
sources="sources"
onSelect.bind="onSelect"
input="inputRef"
/>
</t>
</xpath>
</t>
<!--
@param {string} credits_url
-->
<div t-name="partner_autocomplete.insufficient_credit_notification" class="o-hidden-ios">
<a class="btn btn-link" t-att-href="credits_url"><i class="fa fa-arrow-right"/> Buy more credits</a>
</div>
<t t-name="partner_autocomplete.CharFieldDropdownOption" owl="1">
<img t-att-src="option.logo" onerror="this.src='/base/static/img/company_image.png'" alt="Placeholder"/>
<div class="o_partner_autocomplete_info">
<strong t-esc="option.label or '&#160;'"/>
<div t-esc="option.description"/>
</div>
</t>
<div t-name="partner_autocomplete.account_token" class="">
<a class="btn btn-link" t-att-href="account_url" ><i class="fa fa-arrow-right"/> Set Your Account Token</a>
</div>
<t t-name="partner_autocomplete.Many2oneDropdownOption" owl="1">
<i class="fa fa-magic text-muted pe-1"/>
<t t-esc="option.label or '&#160;'"/>,
<span class="text-muted" t-esc="option.description"/>
<img class="ms-1" t-att-src="option.logo" onerror="this.src='/base/static/img/company_image.png'" alt="Placeholder"/>
</t>
<t t-name="partner_autocomplete.InsufficientCreditNotification" owl="1">
<div class="o-hidden-ios">
<a class="btn btn-link" t-att-href="credits_url"><i class="fa fa-arrow-right"/> Buy more credits</a>
</div>
</t>
<t t-name="partner_autocomplete.AccountTokenMissingNotification" owl="1">
<div class="">
<a class="btn btn-link" t-att-href="account_url" ><i class="fa fa-arrow-right"/> Set Your Account Token</a>
</div>
</t>
</templates>
@@ -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:
`<form>
<field name="company_type"/>
<field name="name" widget="field_partner_autocomplete"/>
<field name="parent_id" widget="res_partner_many2one"/>
<field name="website"/>
<field name="image_1920" widget="image"/>
<field name="phone"/>
<field name="street"/>
<field name="city"/>
<field name="state_id"/>
<field name="zip"/>
<field name="country_id"/>
<field name="comment"/>
<field name="vat" widget="field_partner_autocomplete"/>
</form>`,
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:
'<form>' +
'<field name="company_type"/>' +
'<field name="name" widget="field_partner_autocomplete"/>' +
'<field name="website"/>' +
'<field name="image_1920" widget="image"/>' +
'</form>',
}).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 <input/> 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:
'<form>' +
'<field name="company_type"/>' +
'<field name="name" widget="field_partner_autocomplete"/>' +
'<field name="website"/>' +
'<field name="image_1920" widget="image"/>' +
'<field name="phone"/>' +
'<field name="street"/>' +
'<field name="city"/>' +
'<field name="state_id"/>' +
'<field name="zip"/>' +
'<field name="country_id"/>' +
'<field name="comment"/>' +
'<field name="vat"/>' +
'</form>',
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 <input/> 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:
'<form>' +
'<field name="company_type"/>' +
'<field name="name" widget="field_partner_autocomplete"/>' +
'<field name="website"/>' +
'<field name="image_1920" widget="image"/>' +
'<field name="phone"/>' +
'<field name="street"/>' +
'<field name="city"/>' +
'<field name="state_id"/>' +
'<field name="zip"/>' +
'<field name="country_id"/>' +
'<field name="comment"/>' +
'<field name="vat"/>' +
'</form>',
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 <input/> 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:
'<form>' +
'<field name="name"/>' +
'<field name="parent_id" widget="res_partner_many2one"/>' +
'</form>',
}).then(async function (form) {
var $input = form.$('.o_field_many2one[name="parent_id"] input:visible');
assert.strictEqual($input.length, 1, "there should be an <input/> 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:
'<form>' +
'<field name="company_type"/>' +
'<field name="name" widget="field_partner_autocomplete"/>' +
'</form>',
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`);
}
});
});
@@ -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: "",
@@ -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();
/*