diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/partner_autocomplete/static/src/js | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/partner_autocomplete/static/src/js')
3 files changed, 858 insertions, 0 deletions
diff --git a/addons/partner_autocomplete/static/src/js/partner_autocomplete_core.js b/addons/partner_autocomplete/static/src/js/partner_autocomplete_core.js new file mode 100644 index 00000000..06002dd5 --- /dev/null +++ b/addons/partner_autocomplete/static/src/js/partner_autocomplete_core.js @@ -0,0 +1,366 @@ +odoo.define('partner.autocomplete.Mixin', function (require) { +'use strict'; + +var concurrency = require('web.concurrency'); + +var core = require('web.core'); +var Qweb = core.qweb; +var utils = require('web.utils'); +var _t = core._t; + +/** + * This mixin only works with classes having EventDispatcherMixin in 'web.mixins' + */ +var PartnerAutocompleteMixin = { + _dropPreviousOdoo: new concurrency.DropPrevious(), + _dropPreviousClearbit: new concurrency.DropPrevious(), + _timeout : 1000, // Timeout for Clearbit autocomplete in ms + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Get list of companies via Autocomplete API + * + * @param {string} value + * @returns {Promise} + * @private + */ + _autocomplete: function (value) { + var self = this; + 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){ + odooSuggestions = suggestions; + }); + + // Only get Clearbit suggestions if not a VAT number + var clearbitPromise = isVAT ? false : self._getClearbitSuggestions(value).then(function (suggestions){ + clearbitSuggestions = suggestions; + }); + + var concatResults = function () { + // Add Clearbit result with Odoo result (with unique domain) + if (clearbitSuggestions && clearbitSuggestions.length) { + var websites = odooSuggestions.map(function (suggestion) { + return suggestion.website; + }); + clearbitSuggestions.forEach(function (suggestion) { + if (websites.indexOf(suggestion.domain) < 0) { + websites.push(suggestion.domain); + odooSuggestions.push(suggestion); + } + }); + } + + odooSuggestions = _.filter(odooSuggestions, function (suggestion) { + return !suggestion.ignored; + }); + _.each(odooSuggestions, function(suggestion){ + delete suggestion.ignored; + }); + return resolve(odooSuggestions); + }; + + self._whenAll([odooPromise, clearbitPromise]).then(concatResults, concatResults); + }); + + }, + + /** + * Get enrichment data + * + * @param {Object} company + * @param {string} company.website + * @param {string} company.partner_gid + * @param {string} company.vat + * @returns {Promise} + * @private + */ + _enrichCompany: function (company) { + return this._rpc({ + model: 'res.partner', + method: 'enrich_company', + args: [company.website, company.partner_gid, company.vat], + }); + }, + + /** + * Get the company logo as Base 64 image from url + * + * @param {string} url + * @returns {Promise} + * @private + */ + _getCompanyLogo: function (url) { + return this._getBase64Image(url).then(function (base64Image) { + // base64Image equals "data:" if image not available on given url + return base64Image ? base64Image.replace(/^data:image[^;]*;base64,?/, '') : false; + }).catch(function () { + return false; + }); + }, + + /** + * Get enriched data + logo before populating partner form + * + * @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) { + delete company[field]; + }); + + var notEmptyFields = "country_id,state_id".split(','); + notEmptyFields.forEach(function (field) { + if (!company[field]) delete company[field]; + }); + }; + + return new Promise(function (resolve) { + // Fetch additional company info via Autocomplete Enrichment API + var enrichPromise = self._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]; + + // 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. + 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.do_notify(false, company_data.error_message); + } + company_data = company; + } + + if (_.isEmpty(company_data)) { + company_data = company; + } + + // Delete attribute to avoid "Field_changed" errors + removeUselessFields(company_data); + + // Assign VAT coming from parent VIES VAT query + if (company.vat) { + company_data.vat = company.vat; + } + resolve({ + company: company_data, + logo: logo_data + }); + }); + }); + }, + + /** + * 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 + * image fetched from the given url. + * + * @private + * @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); + }; + xhr.open('GET', url); + xhr.responseType = 'blob'; + xhr.onerror = reject; + xhr.send(); + }); + }, + + /** + * Use Clearbit Autocomplete API to return suggestions + * + * @param {string} value + * @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); + }, + + /** + * Use Odoo Autocomplete API to return suggestions + * + * @param {string} value + * @param {boolean} isVAT + * @returns {Promise} + * @private + */ + _getOdooSuggestions: function (value, isVAT) { + var 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; + + 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; + } + + return suggestion; + }); + return suggestions; + }); + + 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, '') : ''; + }, + + /** + * Utility to wait for multiple promises + * Promise.all will reject all promises whenever a promise is rejected + * This utility will continue + * + * @param {Promise[]} promises + * @returns {Promise} + * @private + */ + _whenAll: function (promises) { + return Promise.all(promises.map(function (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 + }); + self.do_notify(title, content, false, '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.do_notify(title, content, false, 'o_partner_autocomplete_no_credits_notify'); + } + else { + self.do_notify(title); + } + }); + }, +}; + +return PartnerAutocompleteMixin; + +}); diff --git a/addons/partner_autocomplete/static/src/js/partner_autocomplete_fieldchar.js b/addons/partner_autocomplete/static/src/js/partner_autocomplete_fieldchar.js new file mode 100644 index 00000000..37a2b19c --- /dev/null +++ b/addons/partner_autocomplete/static/src/js/partner_autocomplete_fieldchar.js @@ -0,0 +1,337 @@ +odoo.define('partner.autocomplete.fieldchar', function (require) { +'use strict'; + +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'); + +var QWeb = core.qweb; + +var FieldChar = basic_fields.FieldChar; + +/** + * 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, + + jsLibs: [ + '/partner_autocomplete/static/lib/jsvat.js' + ], + + 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', + }), + + /** + * @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); + + // 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'; + } + + if (this.debounceSuggestions > 0) { + this._suggestCompanies = _.debounce(this._suggestCompanies.bind(this), this.debounceSuggestions); + } + }, + + //-------------------------------------------------------------------------- + // 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)); + }, + }); + }); + this._removeDropdown(); + }, + + _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, + }; + + 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); + } + }, + + /** + * 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; +}); diff --git a/addons/partner_autocomplete/static/src/js/partner_autocomplete_many2one.js b/addons/partner_autocomplete/static/src/js/partner_autocomplete_many2one.js new file mode 100644 index 00000000..856bcf65 --- /dev/null +++ b/addons/partner_autocomplete/static/src/js/partner_autocomplete_many2one.js @@ -0,0 +1,155 @@ +odoo.define('partner.autocomplete.many2one', function (require) { +'use strict'; + +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'); + +var _t = core._t; + +var PartnerField = FieldMany2One.extend(AutocompleteMixin, { + jsLibs: [ + '/partner_autocomplete/static/lib/jsvat.js' + ], + + /** + * @override + */ + init: function () { + this._super.apply(this, arguments); + this._addAutocompleteSource(this._searchSuggestions, { + placeholder: _t('Searching Autocomplete...'), + order: 20, + validation: this._validateSearchTerm, + }); + + this.additionalContext['show_vat'] = true; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * 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 = { + 'default_is_company': true + }; + _.each(data.company, function (val, key) { + 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); + }); + }, + + /** + * Returns the display_name from a string which contains it but was altered + * as a result of the show_vat option. + * Note that the split is done on a 'figuredash', not a standard dash. + * + * @private + * @param {string} value + * @returns {string} display_name without TaxID + */ + _getDisplayNameWithoutVAT: function (value) { + return value.split(' ‒ ')[0]; + }, + + /** + * 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; + } + 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); + } + + return $("<li></li>") + .data("item.autocomplete",item) + .append($a) + .appendTo(ul) + .addClass(item.classname); + }; + }, + + /** + * @override + * @private + */ + _renderEdit: function (){ + this.m2o_value = this._getDisplayNameWithoutVAT(this.m2o_value); + this._super.apply(this, arguments); + this._modifyAutompleteRendering(); + }, + + /** + * 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()) { + + 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; +}); |
