Files
Odoo-Modules/fusion_claims/static/src/js/google_address_autocomplete.js
gsinghpal f85658c03a updates
2026-02-24 01:18:44 -05:00

1839 lines
76 KiB
JavaScript

/** @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');
}