Hello everyone,
I recently worked on integrating the Google Maps Places API for address autocomplete in Laserfiche Modern Forms, and I wanted to share a working approach and explain a specific workaround that is required to make it function correctly.
I'm sharing this in hopes it helps someone else, but I am also very open to feedback. If anyone in the community has found a way to improve this solution, or if you've successfully managed to hook Google Autocomplete directly into the native Modern Forms Address block without running into these React conflicts, please feel free to share! Better approaches and optimizations are more than welcome.
The Challenge with the Native Address Block Initially, the goal was to attach the Google Autocomplete dropdown directly to the "Street Address" sub-field inside the native Laserfiche Address block. However, because Modern Forms is built on React, it aggressively manages the DOM, state, and input events.
When you try to hook Google's native UI directly into the Address block's sub-field, React's internal event handling clashes with Google Maps. This results in the dropdown list either being hidden, failing to trigger when typing, or the input freezing entirely. Furthermore, the dynamic IDs in Modern Forms make it very difficult to reliably target that specific sub-field.
The Solution: The "Proxy" Search Field To achieve a smooth and fast user experience without fighting the React engine, the best solution is to use a separate Single Line text field as a dedicated search bar.
Here is how the flow works:
-
Create a Single Line field (e.g., Address_Search) and place it right above your native Address block.
-
Attach the Google Autocomplete script strictly to this Single Line field. Since it's a simple text input, React doesn't interfere with Google's UI, and the dropdown appears instantly as the user types.
-
Map the Data: When the user selects an address from the Google dropdown, the JavaScript captures the Google Place object, parses the components (Street, City, State, Zip, Country), and maps them to Laserfiche's format.
-
Auto-Fill the Native Block: Finally, the script uses LFForm.setFieldValues to silently push the formatted data into the actual native Address block (e.g., Address_Main) in one go.
This approach completely bypasses the React DOM conflicts, keeps the code clean, and provides a seamless, real-time autocomplete experience for the end user.
I hope this explanation saves time for anyone trying to implement a similar solution in Modern Forms!
PS: In the example image you will see I put a Radio Button questions The address is correct? the idea is prevent users type any character after put the address, but that is optional to you.
Here the JS:
(function () {
// =========================
// CONFIGURATION
// =========================
// [NOTE: This is where users should input their own credentials and Laserfiche variable names]
const GOOGLE_KEY = "YOUR_GOOGLE_API_KEY_HERE"; // Google Places API Key
const SEARCH_FIELD = { variableName: "Address_Search" }; // The Single Line "Proxy" field where the user types
const ADDRESS_FIELD = { variableName: "Address_Main" }; // The native Laserfiche Address block that will be auto-filled
const MIN_CHARS = 2; // Minimum characters required before calling the API
const DEBOUNCE_MS = 300; // Wait time (in milliseconds) after typing stops to prevent API spam
const MAX_SUGGESTIONS = 8; // Maximum number of suggested addresses to display
// =========================
// STATE
// =========================
// [NOTE: Internal variables to manage cache memory and prevent React rendering errors]
const placeIdByDescription = new Map(); // Stores searched addresses in memory to keep track of the 'Place ID'
let autocompleteService;
let placesService;
let debounceTimer = null; // Controls the typing debounce timer
let requestSeq = 0; // Prevents slow API responses from overwriting newer ones
let isSelecting = false; // Flag to determine if the user is selecting an address or just typing
let lastSuggestionKey = ""; // Prevents reloading the same address list if it hasn't changed
// =========================
// GOOGLE LOADER
// =========================
// [NOTE: Safely injects the Google Maps script into the form.
// Checks if it already exists to avoid loading it twice and causing console errors]
function loadGooglePlaces(key) {
return new Promise((resolve, reject) => {
if (window.google?.maps?.places?.AutocompleteService) return resolve();
let s = document.querySelector("script[data-lf-google-places]");
if (s) {
s.addEventListener("load", resolve);
s.addEventListener("error", () => reject(new Error("Google script failed to load")));
return;
}
s = document.createElement("script");
s.setAttribute("data-lf-google-places", "1");
s.src =
"https://maps.googleapis.com/maps/api/js" +
"?key=" + encodeURIComponent(key) +
"&libraries=places&v=weekly";
s.async = true;
s.defer = true;
s.onload = () =>
window.google?.maps?.places?.AutocompleteService
? resolve()
: reject(new Error("Places library unavailable after load"));
s.onerror = () => reject(new Error("Failed to load Google Maps script"));
document.head.appendChild(s);
});
}
// =========================
// ADDRESS PARSING
// =========================
// [NOTE: Converts Google's complex data format into a clean object
// that exactly matches the Laserfiche Address block sub-fields]
function extract(components, type, short = true) {
const c = (components || []).find(x => x.types?.includes(type));
return c ? (short ? c.short_name : c.long_name) || "" : "";
}
function buildLFAddress(place) {
const comps = place.address_components || [];
const postal = extract(comps, "postal_code");
const suffix = extract(comps, "postal_code_suffix");
return {
address1: [extract(comps, "street_number"), extract(comps, "route")]
.filter(Boolean).join(" ").trim(),
address2: "",
city: extract(comps, "locality") || extract(comps, "postal_town"),
province: extract(comps, "administrative_area_level_1") ||
extract(comps, "administrative_area_level_2"),
zipcode: suffix ? `${postal}-${suffix}` : postal,
country: extract(comps, "country"),
};
}
// =========================
// GOOGLE API CALLS
// =========================
// [NOTE: Functions that communicate directly with Google servers
// to fetch text predictions and exact location details]
function getPredictions(input) {
return new Promise((resolve) => {
autocompleteService.getPlacePredictions(
{ input, types: ["address"] },
(preds, status) => {
if (status !== google.maps.places.PlacesServiceStatus.OK || !preds) return resolve([]);
resolve(preds);
}
);
});
}
function getDetails(placeId) {
return new Promise((resolve, reject) => {
placesService.getDetails(
{ placeId, fields: ["address_components"] },
(place, status) => {
if (status !== google.maps.places.PlacesServiceStatus.OK || !place)
return reject(new Error(`PlacesService error: ${status}`));
resolve(place);
}
);
});
}
// =========================
// SUGGESTION MANAGEMENT
// =========================
// [NOTE: Takes the addresses returned by Google and injects them as options
// into the native dropdown list of the "Address_Search" field in Laserfiche]
async function setSuggestions(list) {
const key = list.join("\u0001");
if (key === lastSuggestionKey) return;
lastSuggestionKey = key;
await LFForm.changeFieldSettings(SEARCH_FIELD, { autoCompleteValues: list });
}
async function clearSuggestions() {
if (lastSuggestionKey === "") return;
lastSuggestionKey = "";
await LFForm.changeFieldSettings(SEARCH_FIELD, { autoCompleteValues: [] });
}
async function refreshSuggestions(input) {
const trimmed = (input || "").trim();
if (trimmed.length < MIN_CHARS) { await clearSuggestions(); return; }
const mySeq = ++requestSeq;
try {
const preds = await getPredictions(trimmed);
if (mySeq !== requestSeq) return;
const list = preds.slice(0, MAX_SUGGESTIONS).map(p => {
placeIdByDescription.set(p.description, p.place_id);
return p.description;
});
await setSuggestions(list);
} catch (e) {
if (mySeq !== requestSeq) return;
await clearSuggestions();
}
}
function scheduleRefresh(value) {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => refreshSuggestions(value), DEBOUNCE_MS);
}
// =========================
// SELECTION HANDLER
// =========================
// [NOTE: Executes when the user clicks an address from the list.
// It clears the native address block (to prevent React cache errors)
// and then injects the final formatted address]
async function applySelection(value) {
const text = (value || "").trim();
const empty = { address1: "", address2: "", city: "", province: "", zipcode: "", country: "" };
if (!text) { await LFForm.setFieldValues(ADDRESS_FIELD, empty); return; }
const placeId = placeIdByDescription.get(text);
if (!placeId) return;
try {
const place = await getDetails(placeId);
const addr = buildLFAddress(place);
await LFForm.setFieldValues(ADDRESS_FIELD, empty);
await LFForm.setFieldValues(ADDRESS_FIELD, addr);
} catch (e) {
console.error("[LF Autocomplete] Failed to get place details:", e);
}
}
// =========================
// INIT
// =========================
// [NOTE: Script entry point. Hooks into Laserfiche's native 'onFieldChange' event
// to listen to the user's typing and trigger the flow]
async function init() {
await loadGooglePlaces(GOOGLE_KEY);
autocompleteService = new google.maps.places.AutocompleteService();
const dummy = document.createElement("div");
document.body.appendChild(dummy);
placesService = new google.maps.places.PlacesService(dummy);
LFForm.onFieldChange(function () {
if (isSelecting) return;
const current = (LFForm.getFieldValues(SEARCH_FIELD) || "").trim();
// If the typed text exactly matches an address in our cache,
// it means the user selected it from the dropdown.
if (placeIdByDescription.has(current)) {
isSelecting = true;
applySelection(current).finally(() => { isSelecting = false; });
return;
}
// If not, it means they are still typing, so we fetch new suggestions.
scheduleRefresh(current);
}, SEARCH_FIELD);
}
init().catch(err => console.error("[LF Autocomplete] Init error:", err));
})();
How this code works (A breakdown for developers):
To make this code as plug-and-play as possible, it is divided into distinct logical blocks:
-
CONFIGURATION: This is the primary section you need to edit. Paste your Google API Key and ensure the variableName properties perfectly match the names of your Laserfiche fields (the single-line proxy search field and the main address block). You can also tweak how many characters trigger a search (MIN_CHARS) and the typing delay (DEBOUNCE_MS) to optimize API usage.
-
STATE: Because Modern Forms relies on React, the DOM can be unpredictable. We use a Map (placeIdByDescription) as a temporary memory cache to link the text you see in the dropdown with its unique Google place_id. This prevents data loss if React redraws the field in the background.
-
GOOGLE LOADER: Instead of pasting an external script tag into the form's custom HTML (which can cause issues in Laserfiche), this function dynamically and safely injects the Google Maps API into the page and ensures it only loads once.
-
ADDRESS PARSING (buildLFAddress): Google returns addresses in a complex array format. This function acts as a translator. It extracts the street, city, zip code, etc., and packages them into a clean JavaScript object that perfectly mirrors the internal structure of a Laserfiche Address block.
-
SUGGESTION MANAGEMENT: As the user types, the scheduleRefresh function acts as a "debouncer". It waits until the user pauses typing (for a set duration, like 300ms) before asking Google for suggestions. Once Google responds, setSuggestions uses the native Laserfiche API to populate the dropdown list of our proxy search field.
-
SELECTION HANDLER (applySelection): This is where the mapping happens. When the user clicks an address, this function fetches the full details from Google. To prevent React caching bugs where old data refuses to update, it first pushes a completely blank address into the form to wipe it clean, and then instantly pushes the newly formatted address data, populating the entire Address block simultaneously.