/** @odoo-module **/ // Fusion Claims - Google Address Autocomplete for Contacts // Copyright 2024-2025 Nexa Systems Inc. // License OPL-1 import { FormController } from "@web/views/form/form_controller"; import { useService } from "@web/core/utils/hooks"; import { onMounted, onWillUnmount } from "@odoo/owl"; import { patch } from "@web/core/utils/patch"; // Store for autocomplete instances and services let googleMapsLoaded = false; let googleMapsLoading = false; let googleMapsApiKey = null; let globalOrm = null; const autocompleteInstances = new Map(); /** * Load Google Maps API dynamically */ async function loadGoogleMapsApi(apiKey) { if (googleMapsLoaded) { return Promise.resolve(); } if (googleMapsLoading) { return new Promise((resolve) => { const checkLoaded = setInterval(() => { if (googleMapsLoaded) { clearInterval(checkLoaded); resolve(); } }, 100); }); } googleMapsLoading = true; return new Promise((resolve, reject) => { window.initGoogleMapsAutocomplete = () => { googleMapsLoaded = true; googleMapsLoading = false; resolve(); }; const script = document.createElement('script'); script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=places&callback=initGoogleMapsAutocomplete`; script.async = true; script.defer = true; script.onerror = () => { googleMapsLoading = false; reject(new Error('Failed to load Google Maps API')); }; document.head.appendChild(script); }); } /** * Get API key from Odoo config */ async function getGoogleMapsApiKey(orm) { if (googleMapsApiKey) { return googleMapsApiKey; } try { const result = await orm.call( 'ir.config_parameter', 'get_param', ['fusion_claims.google_maps_api_key'] ); googleMapsApiKey = result || null; return googleMapsApiKey; } catch (error) { console.warn('Could not fetch Google Maps API key:', error); return null; } } /** * Initialize autocomplete on a street input field */ function initAutocompleteOnField(input, formModel) { if (!input || !window.google || !window.google.maps || !window.google.maps.places) { return null; } if (autocompleteInstances.has(input)) { return autocompleteInstances.get(input); } const autocomplete = new google.maps.places.Autocomplete(input, { componentRestrictions: { country: 'ca' }, types: ['address'], fields: ['address_components', 'formatted_address', 'geometry'] }); autocomplete.addListener('place_changed', async () => { const place = autocomplete.getPlace(); if (!place.address_components) { return; } // Parse address components let streetNumber = ''; let streetName = ''; let unitNumber = ''; // For apartment/unit/suite let city = ''; let province = ''; let postalCode = ''; let countryCode = ''; for (const component of place.address_components) { const types = component.types; if (types.includes('street_number')) { streetNumber = component.long_name; } else if (types.includes('route')) { streetName = component.long_name; } else if (types.includes('subpremise')) { // Unit, apartment, suite number unitNumber = component.long_name; } else if (types.includes('floor')) { // Floor number if (!unitNumber) unitNumber = 'Floor ' + component.long_name; } else if (types.includes('locality')) { city = component.long_name; } else if (types.includes('sublocality_level_1') && !city) { city = component.long_name; } else if (types.includes('administrative_area_level_1')) { province = component.short_name; } else if (types.includes('postal_code')) { postalCode = component.long_name; } else if (types.includes('country')) { countryCode = component.short_name; } } const street = streetNumber ? `${streetNumber} ${streetName}` : streetName; console.log('[GooglePlaces] Parsed address:', { street, unitNumber, city, province, postalCode, countryCode }); if (!formModel || !formModel.root) { console.warn('[GooglePlaces] No form model available'); return; } const record = formModel.root; // First, lookup country and state IDs let countryId = null; let stateId = null; if (globalOrm && countryCode) { try { // Find country const countries = await globalOrm.searchRead( 'res.country', [['code', '=', countryCode]], ['id'], { limit: 1 } ); if (countries.length) { countryId = countries[0].id; console.log('[GooglePlaces] Found country ID:', countryId); // Find state if (province) { const states = await globalOrm.searchRead( 'res.country.state', [['code', '=', province], ['country_id', '=', countryId]], ['id'], { limit: 1 } ); if (states.length) { stateId = states[0].id; console.log('[GooglePlaces] Found state ID:', stateId); } } } } catch (error) { console.error('[GooglePlaces] Error looking up country/state:', error); } } // Build complete update payload const updatePayload = {}; if (street) updatePayload.street = street; if (unitNumber) updatePayload.street2 = unitNumber; if (city) updatePayload.city = city; if (postalCode) updatePayload.zip = postalCode; if (countryId !== null) updatePayload.country_id = countryId; if (stateId !== null) updatePayload.state_id = stateId; console.log('[GooglePlaces] Full update payload:', updatePayload); console.log('[GooglePlaces] Record ID:', record.resId); console.log('[GooglePlaces] Country ID to set:', countryId); console.log('[GooglePlaces] State ID to set:', stateId); // If record is saved (has ID), use ORM write directly if (record.resId && globalOrm) { try { console.log('[GooglePlaces] Using ORM write for saved record'); // First write country and text fields const firstWrite = { ...updatePayload }; delete firstWrite.state_id; // Remove state from first write await globalOrm.write('res.partner', [record.resId], firstWrite); console.log('[GooglePlaces] First write (country + text) successful'); // Then write state separately after a small delay if (stateId !== null) { await new Promise(resolve => setTimeout(resolve, 100)); await globalOrm.write('res.partner', [record.resId], { state_id: stateId }); console.log('[GooglePlaces] Second write (state) successful'); } // Reload the record to show updated values await record.load(); console.log('[GooglePlaces] Record reloaded'); } catch (error) { console.error('[GooglePlaces] ORM write failed:', error); } } else { // For new records - use DOM manipulation approach console.log('[GooglePlaces] New record - using DOM approach'); try { // Update text fields through record.update (these work) const textUpdate = {}; if (street) textUpdate.street = street; if (unitNumber) textUpdate.street2 = unitNumber; if (city) textUpdate.city = city; if (postalCode) textUpdate.zip = postalCode; if (Object.keys(textUpdate).length > 0) { await record.update(textUpdate); console.log('[GooglePlaces] Text fields updated'); } // For Many2one fields, we need to simulate user interaction const formEl = input.closest('.o_form_view') || input.closest('.o_content') || document.body; if (countryId !== null && globalOrm) { // Get country and state names in parallel for speed const [countryData, stateData] = await Promise.all([ globalOrm.read('res.country', [countryId], ['display_name']), stateId ? globalOrm.read('res.country.state', [stateId], ['display_name']) : Promise.resolve([]) ]); const countryName = countryData[0]?.display_name || 'Canada'; const stateName = stateData[0]?.display_name || province; // Find and update country field via DOM await simulateMany2OneSelection(formEl, 'country_id', countryId, countryName); // Wait for country onchange to complete (reduced time) await new Promise(resolve => setTimeout(resolve, 300)); // Now set state if (stateId !== null) { await simulateMany2OneSelection(formEl, 'state_id', stateId, stateName); } } } catch (error) { console.error('[GooglePlaces] New record update failed:', error); } } }); autocompleteInstances.set(input, autocomplete); return autocomplete; } /** * Simulate Many2One field selection by finding the widget and triggering its update */ async function simulateMany2OneSelection(formEl, fieldName, valueId, displayName) { console.log(`[GooglePlaces] Simulating selection for ${fieldName}: ${valueId} - ${displayName}`); // Find the field container const fieldSelectors = [ `[name="${fieldName}"]`, `.o_field_widget[name="${fieldName}"]`, `div[name="${fieldName}"]`, ]; let fieldContainer = null; for (const selector of fieldSelectors) { fieldContainer = formEl.querySelector(selector); if (fieldContainer) break; } if (!fieldContainer) { console.log(`[GooglePlaces] Field container not found for ${fieldName}`); return false; } console.log(`[GooglePlaces] Found field container for ${fieldName}`); // Try to find the OWL component instance // In Odoo 17+, OWL components store their instance in __owl__ let owlComponent = null; let el = fieldContainer; while (el && !owlComponent) { if (el.__owl__) { owlComponent = el.__owl__; break; } el = el.parentElement; } if (owlComponent && owlComponent.component) { console.log(`[GooglePlaces] Found OWL component for ${fieldName}`); // Try to call the component's update method const component = owlComponent.component; if (component.props && component.props.record) { try { // Try updating through the props record await component.props.record.update({ [fieldName]: valueId }); console.log(`[GooglePlaces] Updated via props.record for ${fieldName}`); return true; } catch (e) { console.log(`[GooglePlaces] props.record.update failed for ${fieldName}:`, e.message); } } if (typeof component.updateValue === 'function') { try { await component.updateValue(valueId); console.log(`[GooglePlaces] Updated via updateValue for ${fieldName}`); return true; } catch (e) { console.log(`[GooglePlaces] updateValue failed for ${fieldName}:`, e.message); } } } // Fallback: Try to find and manipulate the input directly const inputEl = fieldContainer.querySelector('input:not([type="hidden"])'); if (inputEl) { // Focus the input first inputEl.focus(); // Clear and set value inputEl.value = ''; inputEl.value = displayName; // Trigger input event to open dropdown inputEl.dispatchEvent(new InputEvent('input', { bubbles: true, cancelable: true, data: displayName, inputType: 'insertText' })); console.log(`[GooglePlaces] Triggered input event for ${fieldName}, waiting for dropdown...`); // Wait for dropdown to appear and search results to load (reduced) await new Promise(resolve => setTimeout(resolve, 250)); // Look for dropdown items with various selectors used by Odoo const dropdownSelectors = [ '.o-autocomplete--dropdown-menu .o-autocomplete--dropdown-item', '.o_m2o_dropdown_option', '.dropdown-menu .dropdown-item', '.o-autocomplete .dropdown-item', 'ul.ui-autocomplete li', '.o_field_many2one_selection li', ]; let found = false; for (const selector of dropdownSelectors) { const dropdownItems = document.querySelectorAll(selector); console.log(`[GooglePlaces] Checking selector "${selector}": found ${dropdownItems.length} items`); for (const item of dropdownItems) { const itemText = item.textContent.trim(); if (itemText.includes(displayName) || displayName.includes(itemText)) { // Click the item item.click(); console.log(`[GooglePlaces] Clicked dropdown item for ${fieldName}: "${itemText}"`); found = true; break; } } if (found) break; } if (!found) { console.log(`[GooglePlaces] No matching dropdown item found for ${fieldName}`); // Try pressing Enter to select the first option inputEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true })); console.log(`[GooglePlaces] Sent Enter key for ${fieldName}`); // Also try Tab to confirm selection await new Promise(resolve => setTimeout(resolve, 50)); inputEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', code: 'Tab', keyCode: 9, bubbles: true })); } // Blur to finalize await new Promise(resolve => setTimeout(resolve, 50)); inputEl.blur(); return found; } return false; } /** * Initialize company name autocomplete (searches for businesses) */ function initCompanyAutocomplete(input, formModel) { if (!input || !window.google || !window.google.maps || !window.google.maps.places) { return null; } const instanceKey = 'company_' + input.id; if (autocompleteInstances.has(instanceKey)) { return autocompleteInstances.get(instanceKey); } const autocomplete = new google.maps.places.Autocomplete(input, { componentRestrictions: { country: 'ca' }, types: ['establishment'], // Search for businesses fields: ['place_id', 'name', 'address_components', 'formatted_address', 'formatted_phone_number', 'international_phone_number', 'website', 'geometry'] }); autocomplete.addListener('place_changed', async () => { let place = autocomplete.getPlace(); if (!place.name && !place.place_id) { return; } console.log('[GooglePlaces] Company selected:', place.name); console.log('[GooglePlaces] Initial place data:', place); // If phone/website not in initial response, fetch place details if (place.place_id && (!place.formatted_phone_number && !place.website)) { try { const service = new google.maps.places.PlacesService(document.createElement('div')); const detailsPromise = new Promise((resolve, reject) => { service.getDetails( { placeId: place.place_id, fields: ['formatted_phone_number', 'international_phone_number', 'website', 'name', 'address_components'] }, (result, status) => { if (status === google.maps.places.PlacesServiceStatus.OK) { resolve(result); } else { reject(new Error('Place details failed: ' + status)); } } ); }); const details = await detailsPromise; console.log('[GooglePlaces] Fetched place details:', details); // Merge details into place if (details.formatted_phone_number) place.formatted_phone_number = details.formatted_phone_number; if (details.international_phone_number) place.international_phone_number = details.international_phone_number; if (details.website) place.website = details.website; } catch (e) { console.log('[GooglePlaces] Could not fetch place details:', e.message); } } console.log('[GooglePlaces] Final place data:', place); // Parse address components let streetNumber = ''; let streetName = ''; let unitNumber = ''; // For apartment/unit/suite let city = ''; let province = ''; let postalCode = ''; let countryCode = ''; if (place.address_components) { for (const component of place.address_components) { const types = component.types; if (types.includes('street_number')) { streetNumber = component.long_name; } else if (types.includes('route')) { streetName = component.long_name; } else if (types.includes('subpremise')) { // Unit, apartment, suite number unitNumber = component.long_name; } else if (types.includes('floor')) { // Floor number - add to unit if no unit already if (!unitNumber) unitNumber = 'Floor ' + component.long_name; } else if (types.includes('locality')) { city = component.long_name; } else if (types.includes('sublocality_level_1') && !city) { city = component.long_name; } else if (types.includes('administrative_area_level_1')) { province = component.short_name; } else if (types.includes('postal_code')) { postalCode = component.long_name; } else if (types.includes('country')) { countryCode = component.short_name; } } } const street = streetNumber ? `${streetNumber} ${streetName}` : streetName; console.log('[GooglePlaces] Parsed: street=', street, 'unit=', unitNumber, 'city=', city); if (!formModel || !formModel.root) { return; } const record = formModel.root; // Lookup country and state IDs let countryId = null; let stateId = null; if (globalOrm && countryCode) { try { const [countryData, stateData] = await Promise.all([ globalOrm.searchRead('res.country', [['code', '=', countryCode]], ['id'], { limit: 1 }), province ? globalOrm.searchRead('res.country.state', [['code', '=', province], ['country_id.code', '=', countryCode]], ['id'], { limit: 1 }) : Promise.resolve([]) ]); if (countryData.length) countryId = countryData[0].id; if (stateData.length) stateId = stateData[0].id; } catch (error) { console.error('[GooglePlaces] Error looking up country/state:', error); } } // Get phone number (prefer formatted, fallback to international) const phoneNumber = place.formatted_phone_number || place.international_phone_number || ''; // Build update payload const updatePayload = { name: place.name, }; if (street) updatePayload.street = street; if (unitNumber) updatePayload.street2 = unitNumber; if (city) updatePayload.city = city; if (postalCode) updatePayload.zip = postalCode; if (phoneNumber) updatePayload.phone = phoneNumber; if (place.website) updatePayload.website = place.website; if (countryId) updatePayload.country_id = countryId; if (stateId) updatePayload.state_id = stateId; console.log('[GooglePlaces] Phone:', phoneNumber, 'Website:', place.website, 'Unit:', unitNumber); console.log('[GooglePlaces] Company update payload:', updatePayload); // For saved records, use ORM write if (record.resId && globalOrm) { try { const writePayload = { ...updatePayload }; delete writePayload.state_id; // Write state separately await globalOrm.write('res.partner', [record.resId], writePayload); if (stateId) { await new Promise(resolve => setTimeout(resolve, 100)); await globalOrm.write('res.partner', [record.resId], { state_id: stateId }); } await record.load(); console.log('[GooglePlaces] Company record updated'); } catch (error) { console.error('[GooglePlaces] Company ORM write failed:', error); } } else { // For new records, update through the form try { // Text fields const textFields = {}; if (place.name) textFields.name = place.name; if (street) textFields.street = street; if (unitNumber) textFields.street2 = unitNumber; if (city) textFields.city = city; if (postalCode) textFields.zip = postalCode; if (phoneNumber) textFields.phone = phoneNumber; if (place.website) textFields.website = place.website; console.log('[GooglePlaces] New record text fields:', textFields); await record.update(textFields); // Country and state via DOM simulation const formEl = input.closest('.o_form_view') || input.closest('.o_content') || document.body; if (countryId && globalOrm) { const [countryInfo, stateInfo] = await Promise.all([ globalOrm.read('res.country', [countryId], ['display_name']), stateId ? globalOrm.read('res.country.state', [stateId], ['display_name']) : Promise.resolve([]) ]); await simulateMany2OneSelection(formEl, 'country_id', countryId, countryInfo[0]?.display_name || 'Canada'); if (stateId) { await new Promise(resolve => setTimeout(resolve, 300)); await simulateMany2OneSelection(formEl, 'state_id', stateId, stateInfo[0]?.display_name || province); } } console.log('[GooglePlaces] Company new record updated'); } catch (error) { console.error('[GooglePlaces] Company update failed:', error); } } }); autocompleteInstances.set(instanceKey, autocomplete); return autocomplete; } /** * Setup autocomplete for partner form */ async function setupPartnerAutocomplete(el, model, orm) { globalOrm = orm; const apiKey = await getGoogleMapsApiKey(orm); if (!apiKey) { console.log('[GooglePlaces] API key not configured'); return; } try { await loadGoogleMapsApi(apiKey); } catch (error) { console.warn('[GooglePlaces] Failed to load API:', error); return; } // Find street input field for address autocomplete const streetInputSelectors = [ 'input[name="street"]', '.o_field_widget[name="street"] input', '[name="street"] input', 'div[name="street"] input', '.o_address_street input' ]; let streetInput = null; for (const selector of streetInputSelectors) { streetInput = el.querySelector(selector); if (streetInput) break; } if (streetInput && !autocompleteInstances.has(streetInput)) { initAutocompleteOnField(streetInput, model); // Add visual indicator streetInput.style.backgroundImage = 'url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 24 24\' fill=\'%234CAF50\'%3E%3Cpath d=\'M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z\'/%3E%3C/svg%3E")'; streetInput.style.backgroundRepeat = 'no-repeat'; streetInput.style.backgroundPosition = 'right 8px center'; streetInput.style.backgroundSize = '20px'; streetInput.style.paddingRight = '35px'; console.log('[GooglePlaces] Address autocomplete initialized on street field'); } // Find company name input field for company autocomplete const nameInputSelectors = [ 'input[name="name"]', '.o_field_widget[name="name"] input', '[name="name"] input', 'input.o_input[placeholder*="Lumber"]', // Odoo's placeholder for company name '.o_field_char[name="name"] input', ]; let nameInput = null; for (const selector of nameInputSelectors) { nameInput = el.querySelector(selector); if (nameInput) { console.log('[GooglePlaces] Found name input with selector:', selector); break; } } // Check if company type is selected - try multiple ways let isCompany = false; // Method 1: Check radio buttons const companyRadio = el.querySelector('input[name="company_type"][value="company"]'); if (companyRadio && companyRadio.checked) { isCompany = true; console.log('[GooglePlaces] Company detected via radio button'); } // Method 2: Check for company-specific elements (building icon visible) if (!isCompany) { const companyIcon = el.querySelector('.fa-building, .o_field_partner_type .fa-building-o'); if (companyIcon) { isCompany = true; console.log('[GooglePlaces] Company detected via building icon'); } } // Method 3: Check data attribute or class if (!isCompany) { const companyTypeField = el.querySelector('[name="company_type"]'); if (companyTypeField) { const selectedOption = companyTypeField.querySelector('.active, .selected, [aria-checked="true"]'); if (selectedOption && selectedOption.textContent.toLowerCase().includes('company')) { isCompany = true; console.log('[GooglePlaces] Company detected via active option'); } } } // Method 4: Check the model data if (!isCompany && model && model.root && model.root.data) { const companyType = model.root.data.company_type; if (companyType === 'company') { isCompany = true; console.log('[GooglePlaces] Company detected via model data'); } } console.log('[GooglePlaces] isCompany:', isCompany, 'nameInput:', !!nameInput); if (nameInput) { const instanceKey = 'company_' + (nameInput.id || 'default'); if (isCompany && !autocompleteInstances.has(instanceKey)) { initCompanyAutocomplete(nameInput, model); // Add visual indicator (building icon) nameInput.style.backgroundImage = 'url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 24 24\' fill=\'%232196F3\'%3E%3Cpath d=\'M12 7V3H2v18h20V7H12zM6 19H4v-2h2v2zm0-4H4v-2h2v2zm0-4H4V9h2v2zm0-4H4V5h2v2zm4 12H8v-2h2v2zm0-4H8v-2h2v2zm0-4H8V9h2v2zm0-4H8V5h2v2zm10 12h-8v-2h2v-2h-2v-2h2v-2h-2V9h8v10zm-2-8h-2v2h2v-2zm0 4h-2v2h2v-2z\'/%3E%3C/svg%3E")'; nameInput.style.backgroundRepeat = 'no-repeat'; nameInput.style.backgroundPosition = 'right 8px center'; nameInput.style.backgroundSize = '20px'; nameInput.style.paddingRight = '35px'; console.log('[GooglePlaces] Company autocomplete initialized on name field'); } else if (!isCompany && autocompleteInstances.has(instanceKey)) { // Remove company autocomplete when switched to Person autocompleteInstances.delete(instanceKey); nameInput.style.backgroundImage = ''; nameInput.style.paddingRight = ''; console.log('[GooglePlaces] Company autocomplete removed (switched to Person)'); } } // Listen for company_type changes - try multiple selectors const companyTypeSelectors = [ 'input[name="company_type"]', '[name="company_type"] input', '.o_field_radio[name="company_type"] input', '.o_field_widget[name="company_type"] input', ]; for (const selector of companyTypeSelectors) { const inputs = el.querySelectorAll(selector); inputs.forEach(input => { if (!input.dataset.googlePlacesListener) { input.dataset.googlePlacesListener = 'true'; input.addEventListener('change', () => { console.log('[GooglePlaces] company_type changed, re-initializing...'); setTimeout(() => { setupPartnerAutocomplete(el, model, orm); }, 200); }); console.log('[GooglePlaces] Added listener to company_type input'); } }); } // Also listen for clicks on the company type labels/buttons const companyTypeContainer = el.querySelector('[name="company_type"]'); if (companyTypeContainer && !companyTypeContainer.dataset.googlePlacesListener) { companyTypeContainer.dataset.googlePlacesListener = 'true'; companyTypeContainer.addEventListener('click', () => { console.log('[GooglePlaces] company_type container clicked, re-initializing...'); setTimeout(() => { setupPartnerAutocomplete(el, model, orm); }, 300); }); } } /** * Cleanup autocomplete instances */ function cleanupAutocomplete(el) { if (!el) return; const inputs = el.querySelectorAll('input'); inputs.forEach(input => { if (autocompleteInstances.has(input)) { autocompleteInstances.delete(input); } }); } /** * Setup autocomplete for technician task form (address_street field) */ async function setupTaskAutocomplete(el, model, orm) { globalOrm = orm; const apiKey = await getGoogleMapsApiKey(orm); if (!apiKey) { console.log('[GooglePlaces Task] API key not configured'); return; } try { await loadGoogleMapsApi(apiKey); } catch (error) { console.warn('[GooglePlaces Task] Failed to load API:', error); return; } // Find address_street input const streetSelectors = [ 'div[name="address_street"] input', '.o_field_widget[name="address_street"] input', '[name="address_street"] input', ]; let streetInput = null; for (const selector of streetSelectors) { streetInput = el.querySelector(selector); if (streetInput) break; } if (!streetInput || autocompleteInstances.has(streetInput)) { return; } _attachTaskAutocomplete(streetInput, el, model); } /** * Attach Google Places autocomplete to a task address_street input. * Separated so it can be re-called after OWL re-renders the input. */ function _attachTaskAutocomplete(streetInput, el, model) { if (!streetInput || !window.google?.maps?.places) return; if (autocompleteInstances.has(streetInput)) return; console.log('[GooglePlaces Task] Attaching autocomplete on address_street'); const autocomplete = new google.maps.places.Autocomplete(streetInput, { componentRestrictions: { country: 'ca' }, types: ['address'], fields: ['address_components', 'formatted_address', 'geometry'], }); autocomplete.addListener('place_changed', async () => { const place = autocomplete.getPlace(); if (!place.address_components) return; let streetNumber = '', streetName = '', unitNumber = ''; let city = '', province = '', postalCode = '', countryCode = ''; let lat = 0, lng = 0; for (const c of place.address_components) { const t = c.types; if (t.includes('street_number')) streetNumber = c.long_name; else if (t.includes('route')) streetName = c.long_name; else if (t.includes('subpremise')) unitNumber = c.long_name; else if (t.includes('floor') && !unitNumber) unitNumber = 'Floor ' + c.long_name; else if (t.includes('locality')) city = c.long_name; else if (t.includes('sublocality_level_1') && !city) city = c.long_name; else if (t.includes('administrative_area_level_1')) province = c.short_name; else if (t.includes('postal_code')) postalCode = c.long_name; else if (t.includes('country')) countryCode = c.short_name; } if (place.geometry && place.geometry.location) { lat = place.geometry.location.lat(); lng = place.geometry.location.lng(); } const streetOnly = streetNumber ? `${streetNumber} ${streetName}` : streetName; // Use the full formatted address from Google for the Street field // so the user sees the complete address in one shot. const fullAddress = place.formatted_address || streetOnly; console.log('[GooglePlaces Task] Parsed:', { fullAddress, streetOnly, unitNumber, city, province, postalCode, lat, lng }); if (!model || !model.root) return; const record = model.root; // Look up state ID let stateId = false; if (province && globalOrm) { try { const states = await globalOrm.searchRead( 'res.country.state', [['code', '=', province], ['country_id.code', '=', countryCode || 'CA']], ['id'], { limit: 1 } ); if (states.length) stateId = states[0].id; } catch (e) { console.warn('[GooglePlaces Task] State lookup failed:', e); } } // Update through the form model // address_street gets the FULL formatted address so the user can see it. // Hidden fields still get parsed components for data/geocoding/travel. try { const update = {}; update.address_street = fullAddress; if (unitNumber) update.address_street2 = unitNumber; if (city) update.address_city = city; if (postalCode) update.address_zip = postalCode; if (lat) update.address_lat = lat; if (lng) update.address_lng = lng; if (stateId) update.address_state_id = stateId; await record.update(update); console.log('[GooglePlaces Task] Address fields updated via model'); } catch (err) { console.error('[GooglePlaces Task] Update failed:', err); } // After record.update(), OWL may re-render and replace the input element. // Re-attach autocomplete on the (potentially new) input after a short delay. setTimeout(() => { _reattachTaskAutocomplete(el, model); }, 400); }); autocompleteInstances.set(streetInput, autocomplete); // Visual indicator streetInput.style.backgroundImage = 'url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 24 24\' fill=\'%234CAF50\'%3E%3Cpath d=\'M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z\'/%3E%3C/svg%3E")'; streetInput.style.backgroundRepeat = 'no-repeat'; streetInput.style.backgroundPosition = 'right 8px center'; streetInput.style.backgroundSize = '20px'; streetInput.style.paddingRight = '35px'; console.log('[GooglePlaces Task] Autocomplete ready on address_street'); } /** * Re-attach autocomplete after OWL re-renders the task form input. * Finds the current address_street input and attaches if not already done. */ function _reattachTaskAutocomplete(el, model) { const streetSelectors = [ 'div[name="address_street"] input', '.o_field_widget[name="address_street"] input', '[name="address_street"] input', ]; let streetInput = null; for (const selector of streetSelectors) { streetInput = el.querySelector(selector); if (streetInput) break; } if (streetInput && !autocompleteInstances.has(streetInput)) { console.log('[GooglePlaces Task] Re-attaching autocomplete after re-render'); _attachTaskAutocomplete(streetInput, el, model); } } /** * Attach Google Places autocomplete to a simple Char field (address only, no parsing). * Sets the full formatted address string in the input. */ function initSimpleAddressAutocomplete(input) { if (!input || !window.google || !window.google.maps || !window.google.maps.places) return; if (autocompleteInstances.has(input)) return; const autocomplete = new google.maps.places.Autocomplete(input, { componentRestrictions: { country: 'ca' }, types: ['address'], fields: ['formatted_address'], }); autocomplete.addListener('place_changed', () => { const place = autocomplete.getPlace(); if (place && place.formatted_address) { // Use native setter for OWL reactivity const nativeSetter = Object.getOwnPropertyDescriptor( window.HTMLInputElement.prototype, 'value' ).set; nativeSetter.call(input, place.formatted_address); input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); console.log('[GooglePlaces] Simple address set:', place.formatted_address); } }); autocompleteInstances.set(input, autocomplete); // Visual indicator input.style.backgroundImage = 'url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 24 24\' fill=\'%234CAF50\'%3E%3Cpath d=\'M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z\'/%3E%3C/svg%3E")'; input.style.backgroundRepeat = 'no-repeat'; input.style.backgroundPosition = 'right 8px center'; input.style.backgroundSize = '20px'; input.style.paddingRight = '35px'; console.log('[GooglePlaces] Simple address autocomplete attached'); } /** * Setup autocomplete on simple address Char fields by name. * Works for res.users (x_fc_start_address) and res.config.settings (fc_technician_start_address). */ async function setupSimpleAddressFields(el, orm) { const apiKey = await getGoogleMapsApiKey(orm); if (!apiKey) return; try { await loadGoogleMapsApi(apiKey); } catch (e) { return; } const fieldNames = ['x_fc_start_address', 'fc_technician_start_address']; for (const name of fieldNames) { const selectors = [ `div[name="${name}"] input`, `.o_field_widget[name="${name}"] input`, `[name="${name}"] input`, ]; for (const sel of selectors) { const inp = el.querySelector(sel); if (inp) { initSimpleAddressAutocomplete(inp); break; } } } } /** * Setup autocomplete for LTC Facility form. * Attaches establishment search on the name field and address search on street. */ async function setupFacilityAutocomplete(el, model, orm) { globalOrm = orm; const apiKey = await getGoogleMapsApiKey(orm); if (!apiKey) return; try { await loadGoogleMapsApi(apiKey); } catch (e) { return; } // --- Name field: establishment autocomplete --- const nameSelectors = [ '.oe_title [name="name"] input', 'div[name="name"] input', '.o_field_widget[name="name"] input', '[name="name"] input', ]; let nameInput = null; for (const sel of nameSelectors) { nameInput = el.querySelector(sel); if (nameInput) break; } if (nameInput && !autocompleteInstances.has('facility_name_' + (nameInput.id || 'default'))) { _attachFacilityNameAutocomplete(nameInput, el, model); } // --- Street field: address autocomplete --- const streetSelectors = [ 'div[name="street"] input', '.o_field_widget[name="street"] input', '[name="street"] input', ]; let streetInput = null; for (const sel of streetSelectors) { streetInput = el.querySelector(sel); if (streetInput) break; } if (streetInput && !autocompleteInstances.has(streetInput)) { _attachFacilityAddressAutocomplete(streetInput, el, model); } } /** * Attach establishment (business) autocomplete on facility name field. * Selecting a business fills name, address, phone, email, and website. */ function _attachFacilityNameAutocomplete(input, el, model) { if (!input || !window.google?.maps?.places) return; const instanceKey = 'facility_name_' + (input.id || 'default'); if (autocompleteInstances.has(instanceKey)) return; const autocomplete = new google.maps.places.Autocomplete(input, { componentRestrictions: { country: 'ca' }, types: ['establishment'], fields: [ 'place_id', 'name', 'address_components', 'formatted_address', 'formatted_phone_number', 'international_phone_number', 'website', ], }); autocomplete.addListener('place_changed', async () => { let place = autocomplete.getPlace(); if (!place.name && !place.place_id) return; if (place.place_id && !place.formatted_phone_number && !place.website) { try { const service = new google.maps.places.PlacesService(document.createElement('div')); const details = await new Promise((resolve, reject) => { service.getDetails( { placeId: place.place_id, fields: ['formatted_phone_number', 'international_phone_number', 'website'], }, (result, status) => { if (status === google.maps.places.PlacesServiceStatus.OK) resolve(result); else reject(new Error(status)); } ); }); if (details.formatted_phone_number) place.formatted_phone_number = details.formatted_phone_number; if (details.international_phone_number) place.international_phone_number = details.international_phone_number; if (details.website) place.website = details.website; } catch (_) { /* ignore */ } } let streetNumber = '', streetName = '', unitNumber = ''; let city = '', province = '', postalCode = '', countryCode = ''; if (place.address_components) { for (const c of place.address_components) { const t = c.types; if (t.includes('street_number')) streetNumber = c.long_name; else if (t.includes('route')) streetName = c.long_name; else if (t.includes('subpremise')) unitNumber = c.long_name; else if (t.includes('floor') && !unitNumber) unitNumber = 'Floor ' + c.long_name; else if (t.includes('locality')) city = c.long_name; else if (t.includes('sublocality_level_1') && !city) city = c.long_name; else if (t.includes('administrative_area_level_1')) province = c.short_name; else if (t.includes('postal_code')) postalCode = c.long_name; else if (t.includes('country')) countryCode = c.short_name; } } const street = streetNumber ? `${streetNumber} ${streetName}` : streetName; const phone = place.formatted_phone_number || place.international_phone_number || ''; if (!model?.root) return; const record = model.root; let countryId = null, stateId = null; if (globalOrm && countryCode) { try { const [countries, states] = await Promise.all([ globalOrm.searchRead('res.country', [['code', '=', countryCode]], ['id'], { limit: 1 }), province ? globalOrm.searchRead('res.country.state', [['code', '=', province], ['country_id.code', '=', countryCode]], ['id'], { limit: 1 }) : Promise.resolve([]), ]); if (countries.length) countryId = countries[0].id; if (states.length) stateId = states[0].id; } catch (_) { /* ignore */ } } if (record.resId && globalOrm) { try { const firstWrite = {}; if (place.name) firstWrite.name = place.name; if (street) firstWrite.street = street; if (unitNumber) firstWrite.street2 = unitNumber; if (city) firstWrite.city = city; if (postalCode) firstWrite.zip = postalCode; if (phone) firstWrite.phone = phone; if (place.website) firstWrite.website = place.website; if (countryId) firstWrite.country_id = countryId; await globalOrm.write('fusion.ltc.facility', [record.resId], firstWrite); if (stateId) { await new Promise(r => setTimeout(r, 100)); await globalOrm.write('fusion.ltc.facility', [record.resId], { state_id: stateId }); } await record.load(); } catch (err) { console.error('[GooglePlaces Facility] Name autocomplete ORM write failed:', err); } } else { try { const textUpdate = {}; if (place.name) textUpdate.name = place.name; if (street) textUpdate.street = street; if (unitNumber) textUpdate.street2 = unitNumber; if (city) textUpdate.city = city; if (postalCode) textUpdate.zip = postalCode; if (phone) textUpdate.phone = phone; if (place.website) textUpdate.website = place.website; await record.update(textUpdate); if (countryId && globalOrm) { const formEl = input.closest('.o_form_view') || input.closest('.o_content') || document.body; const [countryData, stateData] = await Promise.all([ globalOrm.read('res.country', [countryId], ['display_name']), stateId ? globalOrm.read('res.country.state', [stateId], ['display_name']) : Promise.resolve([]), ]); await simulateMany2OneSelection(formEl, 'country_id', countryId, countryData[0]?.display_name || 'Canada'); await new Promise(r => setTimeout(r, 300)); if (stateId && stateData.length) { await simulateMany2OneSelection(formEl, 'state_id', stateId, stateData[0]?.display_name || province); } } } catch (err) { console.error('[GooglePlaces Facility] Name autocomplete update failed:', err); } } }); autocompleteInstances.set(instanceKey, autocomplete); input.style.backgroundImage = 'url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 24 24\' fill=\'%232196F3\'%3E%3Cpath d=\'M12 7V3H2v18h20V7H12zM6 19H4v-2h2v2zm0-4H4v-2h2v2zm0-4H4V9h2v2zm0-4H4V5h2v2zm4 12H8v-2h2v2zm0-4H8v-2h2v2zm0-4H8V9h2v2zm0-4H8V5h2v2zm10 12h-8v-2h2v-2h-2v-2h2v-2h-2V9h8v10zm-2-8h-2v2h2v-2zm0 4h-2v2h2v-2z\'/%3E%3C/svg%3E")'; input.style.backgroundRepeat = 'no-repeat'; input.style.backgroundPosition = 'right 8px center'; input.style.backgroundSize = '20px'; input.style.paddingRight = '35px'; } /** * Attach address autocomplete on facility street field. * Fills street, street2, city, state, zip, and country. */ function _attachFacilityAddressAutocomplete(input, el, model) { if (!input || !window.google?.maps?.places) return; if (autocompleteInstances.has(input)) return; const autocomplete = new google.maps.places.Autocomplete(input, { componentRestrictions: { country: 'ca' }, types: ['address'], fields: ['address_components', 'formatted_address'], }); autocomplete.addListener('place_changed', async () => { const place = autocomplete.getPlace(); if (!place.address_components) return; let streetNumber = '', streetName = '', unitNumber = ''; let city = '', province = '', postalCode = '', countryCode = ''; for (const c of place.address_components) { const t = c.types; if (t.includes('street_number')) streetNumber = c.long_name; else if (t.includes('route')) streetName = c.long_name; else if (t.includes('subpremise')) unitNumber = c.long_name; else if (t.includes('floor') && !unitNumber) unitNumber = 'Floor ' + c.long_name; else if (t.includes('locality')) city = c.long_name; else if (t.includes('sublocality_level_1') && !city) city = c.long_name; else if (t.includes('administrative_area_level_1')) province = c.short_name; else if (t.includes('postal_code')) postalCode = c.long_name; else if (t.includes('country')) countryCode = c.short_name; } const street = streetNumber ? `${streetNumber} ${streetName}` : streetName; if (!model?.root) return; const record = model.root; let countryId = null, stateId = null; if (globalOrm && countryCode) { try { const [countries, states] = await Promise.all([ globalOrm.searchRead('res.country', [['code', '=', countryCode]], ['id'], { limit: 1 }), province ? globalOrm.searchRead('res.country.state', [['code', '=', province], ['country_id.code', '=', countryCode]], ['id'], { limit: 1 }) : Promise.resolve([]), ]); if (countries.length) countryId = countries[0].id; if (states.length) stateId = states[0].id; } catch (_) { /* ignore */ } } if (record.resId && globalOrm) { try { const firstWrite = {}; if (street) firstWrite.street = street; if (unitNumber) firstWrite.street2 = unitNumber; if (city) firstWrite.city = city; if (postalCode) firstWrite.zip = postalCode; if (countryId) firstWrite.country_id = countryId; await globalOrm.write('fusion.ltc.facility', [record.resId], firstWrite); if (stateId) { await new Promise(r => setTimeout(r, 100)); await globalOrm.write('fusion.ltc.facility', [record.resId], { state_id: stateId }); } await record.load(); } catch (err) { console.error('[GooglePlaces Facility] Address ORM write failed:', err); } } else { try { const textUpdate = {}; if (street) textUpdate.street = street; if (unitNumber) textUpdate.street2 = unitNumber; if (city) textUpdate.city = city; if (postalCode) textUpdate.zip = postalCode; await record.update(textUpdate); if (countryId && globalOrm) { const formEl = input.closest('.o_form_view') || input.closest('.o_content') || document.body; const [countryData, stateData] = await Promise.all([ globalOrm.read('res.country', [countryId], ['display_name']), stateId ? globalOrm.read('res.country.state', [stateId], ['display_name']) : Promise.resolve([]), ]); await simulateMany2OneSelection(formEl, 'country_id', countryId, countryData[0]?.display_name || 'Canada'); await new Promise(r => setTimeout(r, 300)); if (stateId && stateData.length) { await simulateMany2OneSelection(formEl, 'state_id', stateId, stateData[0]?.display_name || province); } } } catch (err) { console.error('[GooglePlaces Facility] Address autocomplete update failed:', err); } } setTimeout(() => { _reattachFacilityAutocomplete(el, model); }, 400); }); autocompleteInstances.set(input, autocomplete); input.style.backgroundImage = 'url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 24 24\' fill=\'%234CAF50\'%3E%3Cpath d=\'M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z\'/%3E%3C/svg%3E")'; input.style.backgroundRepeat = 'no-repeat'; input.style.backgroundPosition = 'right 8px center'; input.style.backgroundSize = '20px'; input.style.paddingRight = '35px'; } /** * Re-attach facility autocomplete after OWL re-renders inputs. */ function _reattachFacilityAutocomplete(el, model) { const streetSelectors = [ 'div[name="street"] input', '.o_field_widget[name="street"] input', '[name="street"] input', ]; for (const sel of streetSelectors) { const inp = el.querySelector(sel); if (inp && !autocompleteInstances.has(inp)) { _attachFacilityAddressAutocomplete(inp, el, model); break; } } } /** * Patch FormController to add Google autocomplete for partner forms and dialog detection */ patch(FormController.prototype, { setup() { super.setup(); this.orm = useService("orm"); onMounted(() => { // Store ORM globally for dialog watcher globalOrm = this.orm; // Direct partner form if (this.props.resModel === 'res.partner') { setTimeout(() => { if (this.rootRef && this.rootRef.el) { setupPartnerAutocomplete(this.rootRef.el, this.model, this.orm); } }, 800); if (this.rootRef && this.rootRef.el) { this._addressObserver = new MutationObserver((mutations) => { const hasNewInputs = mutations.some(m => m.addedNodes.length > 0 && Array.from(m.addedNodes).some(n => n.nodeType === 1 && (n.tagName === 'INPUT' || n.querySelector?.('input')) ) ); if (hasNewInputs) { setTimeout(() => { setupPartnerAutocomplete(this.rootRef.el, this.model, this.orm); }, 300); } }); this._addressObserver.observe(this.rootRef.el, { childList: true, subtree: true }); } } // Technician task form if (this.props.resModel === 'fusion.technician.task') { setTimeout(() => { if (this.rootRef && this.rootRef.el) { setupTaskAutocomplete(this.rootRef.el, this.model, this.orm); } }, 800); if (this.rootRef && this.rootRef.el) { this._taskAddressObserver = new MutationObserver((mutations) => { const hasNewInputs = mutations.some(m => m.addedNodes.length > 0 && Array.from(m.addedNodes).some(n => n.nodeType === 1 && (n.tagName === 'INPUT' || n.querySelector?.('input')) ) ); if (hasNewInputs) { setTimeout(() => { setupTaskAutocomplete(this.rootRef.el, this.model, this.orm); }, 300); } }); this._taskAddressObserver.observe(this.rootRef.el, { childList: true, subtree: true, }); // "Calculate Travel" button -- handle via JS to prevent dialog close const calcBtn = this.rootRef.el.querySelector('.o_fc_calculate_travel'); if (calcBtn) { const formModel = this.model; const orm = this.orm; calcBtn.addEventListener('click', async (ev) => { ev.preventDefault(); ev.stopPropagation(); const resId = formModel.root.resId; if (!resId) { // New unsaved record -- save first const saved = await formModel.root.save(); if (!saved) return; } else { // Existing record -- save pending changes if (formModel.root.isDirty) { const saved = await formModel.root.save(); if (!saved) return; } } const id = formModel.root.resId; if (!id) return; // Show loading state on button const origText = calcBtn.textContent; calcBtn.disabled = true; calcBtn.textContent = ' Calculating...'; try { await orm.call( 'fusion.technician.task', 'action_calculate_travel_times', [id], ); // Reload form data to show updated travel fields await formModel.root.load(); } catch (e) { console.error('[CalcTravel] Error:', e); } finally { calcBtn.disabled = false; calcBtn.textContent = origText || ' Calculate Travel'; } }); } } } // LTC Facility form if (this.props.resModel === 'fusion.ltc.facility') { setTimeout(() => { if (this.rootRef && this.rootRef.el) { setupFacilityAutocomplete(this.rootRef.el, this.model, this.orm); } }, 800); if (this.rootRef && this.rootRef.el) { this._facilityAddrObserver = new MutationObserver((mutations) => { const hasNewInputs = mutations.some(m => m.addedNodes.length > 0 && Array.from(m.addedNodes).some(n => n.nodeType === 1 && (n.tagName === 'INPUT' || n.querySelector?.('input')) ) ); if (hasNewInputs) { setTimeout(() => { setupFacilityAutocomplete(this.rootRef.el, this.model, this.orm); }, 300); } }); this._facilityAddrObserver.observe(this.rootRef.el, { childList: true, subtree: true, }); } } // Simple address autocomplete: res.partner, res.users, res.config.settings if (this.props.resModel === 'res.partner' || this.props.resModel === 'res.users' || this.props.resModel === 'res.config.settings') { setTimeout(() => { if (this.rootRef && this.rootRef.el) { setupSimpleAddressFields(this.rootRef.el, this.orm); } }, 800); if (this.rootRef && this.rootRef.el) { this._simpleAddrObserver = new MutationObserver((mutations) => { const hasNewInputs = mutations.some(m => m.addedNodes.length > 0 && Array.from(m.addedNodes).some(n => n.nodeType === 1 && (n.tagName === 'INPUT' || n.querySelector?.('input')) ) ); if (hasNewInputs) { setTimeout(() => { setupSimpleAddressFields(this.rootRef.el, this.orm); }, 300); } }); this._simpleAddrObserver.observe(this.rootRef.el, { childList: true, subtree: true }); } } // Start global dialog watcher once _startDialogWatcher(this.orm); }); onWillUnmount(() => { if (this._addressObserver) { this._addressObserver.disconnect(); } if (this._taskAddressObserver) { this._taskAddressObserver.disconnect(); } if (this._facilityAddrObserver) { this._facilityAddrObserver.disconnect(); } if (this._simpleAddrObserver) { this._simpleAddrObserver.disconnect(); } if (this.rootRef && this.rootRef.el) { cleanupAutocomplete(this.rootRef.el); } }); } }); /** * Start the global dialog watcher (runs once, watches forever) */ let _dialogWatcherStarted = false; let _dialogCheckTimer = null; const _processedDialogs = new WeakSet(); function _startDialogWatcher(orm) { if (_dialogWatcherStarted) return; _dialogWatcherStarted = true; console.log('[GooglePlaces] Starting global dialog watcher...'); // Pre-load the API key and Google Maps getGoogleMapsApiKey(orm).then(apiKey => { if (!apiKey) { console.log('[GooglePlaces] No API key, dialog watcher disabled'); return; } loadGoogleMapsApi(apiKey).then(() => { console.log('[GooglePlaces] API loaded, dialog watcher active'); // Watch for ANY DOM change on body const observer = new MutationObserver(() => { if (_dialogCheckTimer) clearTimeout(_dialogCheckTimer); _dialogCheckTimer = setTimeout(_checkDialogsForPartnerForms, 600); }); observer.observe(document.body, { childList: true, subtree: true }); }).catch(err => console.warn('[GooglePlaces] API load failed:', err)); }).catch(err => console.warn('[GooglePlaces] API key fetch failed:', err)); } /** * Check all open dialogs for partner address forms */ function _checkDialogsForPartnerForms() { // Get all visible modals/dialogs const modals = document.querySelectorAll( '.o_dialog, .modal, .o_FormViewDialog, [role="dialog"]' ); for (const modal of modals) { // Skip already processed if (_processedDialogs.has(modal)) continue; // Skip hidden modals if (modal.offsetParent === null && !modal.classList.contains('show')) continue; // Check if this dialog has a street field (partner form) const streetInput = modal.querySelector( '[name="street"] input, ' + '.o_address_street input, ' + 'div[name="street"] input, ' + '.o_field_widget[name="street"] input' ); if (!streetInput) continue; // Mark as processed _processedDialogs.add(modal); console.log('[GooglePlaces] Found partner dialog with street field!'); // Get form element and model const formEl = modal.querySelector('.o_form_view') || modal; let formModel = null; // Walk through ALL elements to find OWL component with model const allEls = formEl.querySelectorAll('*'); for (const el of allEls) { if (el.__owl__?.component?.model) { formModel = el.__owl__.component.model; console.log('[GooglePlaces] Found form model via child element'); break; } } // Also walk up from formEl if (!formModel) { let node = formEl; while (node) { if (node.__owl__?.component?.model) { formModel = node.__owl__.component.model; console.log('[GooglePlaces] Found form model via parent'); break; } node = node.parentElement; } } // If still no model, try the modal itself and its controller if (!formModel) { let node = modal; while (node) { const comp = node.__owl__?.component; if (comp) { // Check if this component has a model property if (comp.model) { formModel = comp.model; console.log('[GooglePlaces] Found form model via modal OWL component'); break; } // Check props for FormViewDialog if (comp.props?.record) { formModel = { root: comp.props.record }; console.log('[GooglePlaces] Found form model via dialog props.record'); break; } } node = node.parentElement; } } if (!formModel) { console.log('[GooglePlaces] No form model found - will use DOM-only approach for dialog'); } // Initialize autocomplete on the street field, with a special dialog handler _initDialogAutocomplete(streetInput, formEl, formModel, modal); // Watch for re-renders inside the dialog let innerTimer = null; const innerObs = new MutationObserver(() => { if (innerTimer) clearTimeout(innerTimer); innerTimer = setTimeout(() => { const newStreet = modal.querySelector( '[name="street"] input, .o_address_street input, div[name="street"] input' ); if (newStreet && !autocompleteInstances.has(newStreet)) { _initDialogAutocomplete(newStreet, formEl, formModel, modal); } }, 400); }); innerObs.observe(modal, { childList: true, subtree: true }); } } /** * Initialize autocomplete specifically for dialog forms (handles DOM-only updates) */ function _initDialogAutocomplete(streetInput, formEl, formModel, modal) { if (!streetInput || !window.google?.maps?.places) return; if (autocompleteInstances.has(streetInput)) return; const autocomplete = new google.maps.places.Autocomplete(streetInput, { componentRestrictions: { country: 'ca' }, types: ['address'], fields: ['address_components', 'formatted_address'] }); autocomplete.addListener('place_changed', async () => { const place = autocomplete.getPlace(); if (!place.address_components) return; // Parse address components let streetNumber = '', streetName = '', unitNumber = ''; let city = '', province = '', postalCode = '', countryCode = ''; for (const c of place.address_components) { const t = c.types; if (t.includes('street_number')) streetNumber = c.long_name; else if (t.includes('route')) streetName = c.long_name; else if (t.includes('subpremise')) unitNumber = c.long_name; else if (t.includes('floor') && !unitNumber) unitNumber = 'Floor ' + c.long_name; else if (t.includes('locality')) city = c.long_name; else if (t.includes('sublocality_level_1') && !city) city = c.long_name; else if (t.includes('administrative_area_level_1')) province = c.short_name; else if (t.includes('postal_code')) postalCode = c.long_name; else if (t.includes('country')) countryCode = c.short_name; } const street = streetNumber ? `${streetNumber} ${streetName}` : streetName; console.log('[GooglePlaces Dialog] Parsed:', { street, unitNumber, city, province, postalCode, countryCode }); // Try record.update first (works if formModel is available) if (formModel?.root) { try { const textUpdate = {}; if (street) textUpdate.street = street; if (unitNumber) textUpdate.street2 = unitNumber; if (city) textUpdate.city = city; if (postalCode) textUpdate.zip = postalCode; await formModel.root.update(textUpdate); console.log('[GooglePlaces Dialog] Text fields updated via model'); // Handle country and state via model if (countryCode && globalOrm) { const countries = await globalOrm.searchRead('res.country', [['code', '=', countryCode]], ['id'], { limit: 1 }); if (countries.length) { await formModel.root.update({ country_id: countries[0].id }); console.log('[GooglePlaces Dialog] Country set via model'); if (province) { await new Promise(r => setTimeout(r, 300)); const states = await globalOrm.searchRead('res.country.state', [['code', '=', province], ['country_id', '=', countries[0].id]], ['id'], { limit: 1 }); if (states.length) { await formModel.root.update({ state_id: states[0].id }); console.log('[GooglePlaces Dialog] State set via model'); } } } } return; } catch (err) { console.log('[GooglePlaces Dialog] Model update failed, falling back to DOM:', err.message); } } // DOM fallback - directly set input values in the dialog console.log('[GooglePlaces Dialog] Using DOM fallback'); function setFieldValue(container, fieldName, value) { if (!value) return; const field = container.querySelector(`[name="${fieldName}"] input, div[name="${fieldName}"] input`); if (field) { // Set value and trigger input event for OWL reactivity const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; nativeInputValueSetter.call(field, value); field.dispatchEvent(new Event('input', { bubbles: true })); field.dispatchEvent(new Event('change', { bubbles: true })); console.log(`[GooglePlaces Dialog] Set ${fieldName} = ${value}`); } else { console.log(`[GooglePlaces Dialog] Field ${fieldName} input not found`); } } // Set text fields via DOM setFieldValue(modal, 'street', street); setFieldValue(modal, 'street2', unitNumber); setFieldValue(modal, 'city', city); setFieldValue(modal, 'zip', postalCode); // Set country and state via Many2one simulation if (countryCode && globalOrm) { try { const countries = await globalOrm.searchRead('res.country', [['code', '=', countryCode]], ['id', 'display_name'], { limit: 1 }); if (countries.length) { await simulateMany2OneSelection(modal, 'country_id', countries[0].id, countries[0].display_name); if (province) { await new Promise(r => setTimeout(r, 500)); const states = await globalOrm.searchRead('res.country.state', [['code', '=', province], ['country_id', '=', countries[0].id]], ['id', 'display_name'], { limit: 1 }); if (states.length) { await simulateMany2OneSelection(modal, 'state_id', states[0].id, states[0].display_name); } } } } catch (err) { console.error('[GooglePlaces Dialog] Country/state lookup failed:', err); } } }); autocompleteInstances.set(streetInput, autocomplete); // Add visual indicator streetInput.style.backgroundImage = 'url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 24 24\' fill=\'%234CAF50\'%3E%3Cpath d=\'M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z\'/%3E%3C/svg%3E")'; streetInput.style.backgroundRepeat = 'no-repeat'; streetInput.style.backgroundPosition = 'right 8px center'; streetInput.style.backgroundSize = '20px'; streetInput.style.paddingRight = '35px'; console.log('[GooglePlaces Dialog] Autocomplete initialized on dialog street field'); }