forked from vanderbilt-redcap/address-autocomplete
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathAddressExternalModule.php
More file actions
executable file
·479 lines (432 loc) · 18.8 KB
/
AddressExternalModule.php
File metadata and controls
executable file
·479 lines (432 loc) · 18.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
<?php namespace Vanderbilt\AddressExternalModule;
use ExternalModules\AbstractExternalModule;
use ExternalModules\ExternalModules;
class AddressExternalModule extends AbstractExternalModule
{
function hook_survey_page($project_id, $record, $instrument, $event_id, $group_id) {
$this->addAddressAutoCompletion($project_id, $record, $instrument, $event_id, $group_id);
}
function hook_data_entry_form($project_id, $record, $instrument, $event_id, $group_id) {
$this->addAddressAutoCompletion($project_id, $record, $instrument, $event_id, $group_id);
}
function addAddressAutoCompletion($project_id, $record, $instrument, $event_id, $group_id) {
$key = $this->getProjectSetting('google-api-key',$project_id);
$autocomplete = $this->getProjectSetting('autocomplete',$project_id);
$streetNumber = $this->getProjectSetting('street-number',$project_id);
$street = $this->getProjectSetting('street',$project_id);
$city = $this->getProjectSetting('city',$project_id);
$county = $this->getProjectSetting('county',$project_id);
$state = $this->getProjectSetting('state',$project_id);
$zip = $this->getProjectSetting('zip',$project_id);
$country = $this->getProjectSetting('country',$project_id);
$latitude = $this->getProjectSetting('latitude',$project_id);
$longitude = $this->getProjectSetting('longitude',$project_id);
$import = $this->getProjectSetting('import-google-api',$project_id);
if ($key && $autocomplete) {
// Load Google Maps API using the official inline bootstrap.
// This defines google.maps.importLibrary immediately (synchronously)
// and defers the actual network load until importLibrary() is called.
// It is safe even if another module has already loaded the API.
if ($import) {
$escapedKey = htmlspecialchars($key, ENT_QUOTES, 'UTF-8');
// Use nowdoc (<<<'SCRIPT') so PHP does NOT interpolate JS template
// literals like ${c} as PHP variables. The API key is injected via
// a preceding <script> that sets a global.
echo '<script>var __addressAutoKey="' . $escapedKey . '";</script>';
echo <<<'SCRIPT'
<script>
(g=>{var h,a,k,p="The Google Maps JavaScript API",c="google",l="importLibrary",q="__ib__",m=document,b=window;b=b[c]||(b[c]={});var d=b.maps||(b.maps={}),r=new Set,e=new URLSearchParams,u=()=>h||(h=new Promise(async(f,n)=>{await (a=m.createElement("script"));e.set("libraries",[...r]+"");for(k in g)e.set(k.replace(/[A-Z]/g,t=>"_"+t[0].toLowerCase()),g[k]);e.set("callback",c+".maps."+q);a.src=`https://maps.${c}apis.com/maps/api/js?`+e;d[q]=f;a.onerror=()=>h=n(Error(p+" could not load."));a.nonce=m.querySelector("script[nonce]")?.nonce||"";m.head.append(a)}));d[l]?console.warn(p+" only loads once. Ignoring:",g):d[l]=(f,...n)=>r.add(f)&&u().then(()=>d[l](f,...n))})({key:__addressAutoKey,v:"weekly"});
</script>
SCRIPT;
}
?>
<style>
#locationField { position: relative; }
#locationField gmp-place-autocomplete {
width: 100%;
font-size: 13px;
}
</style>
<script>
(function() {
var autocompletePrefix = 'googleSearch_';
var autocompleteFieldName = <?php echo json_encode($autocomplete); ?>;
// Component mapping: Google address type -> format preference
var componentForm = {
<?php echo ($streetNumber ? "street_number: 'short_name'," : ""); ?>
<?php echo ($street ? "route: 'long_name'," : ""); ?>
<?php echo ($city ? "locality: 'long_name'," : ""); ?>
<?php echo ($county ? "administrative_area_level_2: 'short_name'," : ""); ?>
<?php echo ($state ? "administrative_area_level_1: 'short_name'," : ""); ?>
<?php echo ($country ? "country: 'long_name'," : ""); ?>
<?php echo ($zip ? "postal_code: 'short_name'," : ""); ?>
};
// Map legacy property names to the new Places API property names
var formatMap = {
'short_name': 'shortText',
'long_name': 'longText'
};
$(document).ready(function() {
// FIX #1: Guard — only proceed if the autocomplete target field
// exists on this particular instrument / form page.
var $autocompleteField = $('[name="' + autocompleteFieldName + '"]');
if ($autocompleteField.length === 0) {
return; // Field not on this form; do nothing.
}
// Set up component destination fields: assign IDs and disable them
<?php if ($streetNumber): ?>
$('[name="<?php echo $streetNumber; ?>"]').attr('id', autocompletePrefix + 'street_number').prop('disabled', true);
<?php endif; ?>
<?php if ($street): ?>
$('[name="<?php echo $street; ?>"]').attr('id', autocompletePrefix + 'route').prop('disabled', true);
<?php endif; ?>
<?php if ($city): ?>
$('[name="<?php echo $city; ?>"]').attr('id', autocompletePrefix + 'locality').prop('disabled', true);
<?php endif; ?>
<?php if ($county): ?>
$('[name="<?php echo $county; ?>"]').attr('id', autocompletePrefix + 'administrative_area_level_2').prop('disabled', true);
<?php endif; ?>
<?php if ($state): ?>
$('[name="<?php echo $state; ?>"]').attr('id', autocompletePrefix + 'administrative_area_level_1').prop('disabled', true);
<?php endif; ?>
<?php if ($zip): ?>
$('[name="<?php echo $zip; ?>"]').attr('id', autocompletePrefix + 'postal_code').prop('disabled', true);
<?php endif; ?>
<?php if ($country): ?>
$('[name="<?php echo $country; ?>"]').attr('id', autocompletePrefix + 'country').prop('disabled', true);
<?php endif; ?>
<?php if ($latitude): ?>
$('[name="<?php echo $latitude; ?>"]').prop('disabled', true);
<?php endif; ?>
<?php if ($longitude): ?>
$('[name="<?php echo $longitude; ?>"]').prop('disabled', true);
<?php endif; ?>
// Wrap original field and hide it; the PlaceAutocompleteElement will replace it visually
$autocompleteField.wrap('<div id="locationField"></div>');
$autocompleteField.hide();
// Initialize the autocomplete once the Google Maps API is available
initAutocomplete($autocompleteField);
});
/**
* Polls until we have a usable path to the Places library:
* - 'importLibrary' → google.maps.importLibrary exists
* - 'legacy' → google.maps.places exists
* Rejects after the timeout (default 15 s).
*/
function waitForPlacesReady(timeoutMs) {
timeoutMs = timeoutMs || 15000;
return new Promise(function(resolve, reject) {
function check() {
if (typeof google !== 'undefined' && google.maps) {
if (typeof google.maps.importLibrary === 'function') return 'importLibrary';
if (google.maps.places) return 'legacy';
}
return false;
}
var result = check();
if (result) { resolve(result); return; }
var elapsed = 0;
var interval = 150;
var poll = setInterval(function() {
elapsed += interval;
var r = check();
if (r) {
clearInterval(poll);
resolve(r);
} else if (elapsed >= timeoutMs) {
clearInterval(poll);
reject(new Error(
'Google Maps Places library did not become available within ' +
(timeoutMs / 1000) + 's. A browser extension (ad blocker) ' +
'may be blocking requests to googleapis.com.'
));
}
}, interval);
});
}
/**
* Load the Places library.
* Waits until either importLibrary or google.maps.places is available,
* then returns a Promise that resolves to the places namespace.
*/
function loadPlacesLibrary() {
return waitForPlacesReady().then(function(mode) {
console.log('[Address Autocomplete] Google Maps detected via: ' + mode);
if (mode === 'importLibrary') {
return google.maps.importLibrary('places').catch(function(err) {
// importLibrary call failed — last-ditch check for legacy namespace
if (google.maps.places) {
console.warn('[Address Autocomplete] importLibrary("places") failed; falling back to google.maps.places.', err);
return google.maps.places;
}
throw err;
});
}
// mode === 'legacy'
return google.maps.places;
});
}
/**
* Initialise autocomplete on the given field.
*
* Strategy (maximises forward-compatibility):
* 1. Prefer the new PlaceAutocompleteElement (New Places API)
* when available — this is Google's recommended path.
* 2. Fall back to the legacy google.maps.places.Autocomplete
* when the new class is not present.
*/
function initAutocomplete($field) {
loadPlacesLibrary()
.then(function(placesLib) {
if (typeof placesLib.PlaceAutocompleteElement === 'function') {
// New Places API available — preferred path
console.log('[Address Autocomplete] Using New Places API (PlaceAutocompleteElement)');
initWithNewApi(placesLib.PlaceAutocompleteElement, $field);
} else if (typeof placesLib.Autocomplete === 'function') {
// Legacy fallback
console.log('[Address Autocomplete] Using Legacy Places API (google.maps.places.Autocomplete)');
initWithLegacyApi(placesLib, $field);
} else {
showAutocompleteError($field,
'Neither Autocomplete nor PlaceAutocompleteElement found in the Places library.'
);
}
})
.catch(function(err) {
console.error('[Address Autocomplete] Failed to initialise.', err);
showAutocompleteError($field, err.message || 'Could not load Google Maps.');
});
}
/**
* Show a user-visible warning on the form when autocomplete cannot load.
*/
function showAutocompleteError($field, detail) {
$field.show(); // un-hide the original text input so the user can still type
$field.attr('placeholder', 'Address autocomplete unavailable — type manually');
$field.closest('#locationField').prepend(
'<div style="color:#c00;font-size:12px;margin-bottom:4px;">' +
'⚠ Address autocomplete could not load. ' +
'If you have an ad blocker, please allow <b>googleapis.com</b> and reload. ' +
'You can still type the address manually.' +
'</div>'
);
console.warn('[Address Autocomplete] ' + detail);
}
/**
* Modern path: PlaceAutocompleteElement (New Places API).
*/
function initWithNewApi(PlaceAutocompleteElement, $field) {
var placeAutocomplete = new PlaceAutocompleteElement({
types: ['address']
});
placeAutocomplete.id = autocompletePrefix + 'autocomplete';
placeAutocomplete.setAttribute('placeholder', 'Enter your address here');
// Insert the new element into the wrapper, before the hidden original field
$field.before(placeAutocomplete);
// Apply geolocation bias to improve relevance
applyGeolocationBias(placeAutocomplete);
// The modern event is "gmp-select"; the event carries a placePrediction
// which must be converted to a Place via .toPlace(), then fetched.
placeAutocomplete.addEventListener('gmp-select', async function(event) {
var place = null;
try {
var prediction = event.placePrediction;
if (prediction && typeof prediction.toPlace === 'function') {
place = prediction.toPlace();
} else if (event.place) {
place = event.place;
}
if (place) {
await place.fetchFields({
fields: ['addressComponents', 'location', 'formattedAddress']
});
}
} catch (e) {
console.warn('[Address Autocomplete] Could not process place.', e);
place = null;
}
fillInAddress(place, $field);
});
}
/**
* Legacy fallback: google.maps.places.Autocomplete (deprecated but
* still functional on pages that loaded the API the old way).
*/
function initWithLegacyApi(placesLib, $field) {
// Show the original input again — the legacy class attaches to it
$field.show();
$field.attr('id', autocompletePrefix + 'autocomplete');
$field.attr('placeholder', 'Enter your address here');
var inputEl = $field[0];
var autocompleteObj = new placesLib.Autocomplete(inputEl, {
types: ['address']
});
// Geolocation bias
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(function(position) {
var circle = new google.maps.Circle({
center: { lat: position.coords.latitude, lng: position.coords.longitude },
radius: position.coords.accuracy
});
autocompleteObj.setBounds(circle.getBounds());
});
}
autocompleteObj.addListener('place_changed', function() {
var place = autocompleteObj.getPlace();
fillInAddressLegacy(place, $field);
});
// If the user clears the field, wipe all components
inputEl.addEventListener('change', function() {
if (inputEl.value === '') { fillInAddressLegacy(undefined, $field); }
});
}
/**
* Fill address from the legacy Autocomplete Place result.
* Uses address_components[].short_name / long_name (old property names).
*/
function fillInAddressLegacy(place, $field) {
for (var component in componentForm) {
updateValue(autocompletePrefix + component, '');
}
if (place && place.address_components) {
$field.change();
if (place.geometry && place.geometry.location) {
<?php echo ($latitude ? "updateValue('latitude', place.geometry.location.lat());\n" : ""); ?>
<?php echo ($longitude ? "updateValue('longitude', place.geometry.location.lng());\n" : ""); ?>
}
for (var i = 0; i < place.address_components.length; i++) {
var addressType = place.address_components[i].types[0];
if (componentForm[addressType] && document.getElementById(autocompletePrefix + addressType)) {
var val = place.address_components[i][componentForm[addressType]];
if (addressType === 'administrative_area_level_2') {
val = $.trim(val.replace('County', ''));
}
updateValue(autocompletePrefix + addressType, val);
document.getElementById(autocompletePrefix + addressType).disabled = false;
}
}
} else {
$field.val('');
$field.change();
<?php echo ($latitude ? "updateValue('latitude', '');\n" : ""); ?>
<?php echo ($longitude ? "updateValue('longitude', '');\n" : ""); ?>
}
if (typeof doBranching === 'function') { doBranching(); }
}
/**
* Bias the autocomplete results toward the user's current location.
*/
function applyGeolocationBias(placeAutocomplete) {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(function(position) {
var circle = new google.maps.Circle({
center: {
lat: position.coords.latitude,
lng: position.coords.longitude
},
radius: position.coords.accuracy
});
placeAutocomplete.locationBias = circle.getBounds();
});
}
}
/**
* Helper: update a REDCap field value, handling radios, selects,
* and rc-autocomplete dropdowns. (Preserved from v1.0.0.)
*/
function updateValue(id, value) {
if (id == 'latitude') {
var element = $('[name="<?php echo $latitude; ?>"]');
}
else if (id == 'longitude') {
var element = $('[name="<?php echo $longitude; ?>"]');
}
else {
var element = $('#' + id);
}
if (element.length === 0) {
console.log('Could not find the element with the following id:', id);
return;
}
var eleType = element.prop('type');
element.val(value);
// Handle special REDCap field types
var eleName = element.attr('name');
if (element.hasClass('hiddenradio')) {
$('input[name="'+eleName+'___radio"][value="'+value+'"]').prop('checked', true);
} else if (eleType.indexOf("select") >= 0) {
if ($('#'+id+' option[value="'+value+'"]').length > 0) {
$('#'+id+' option[value="'+value+'"]').prop('selected', true);
} else {
var valUnderscore = value.replace(/\s+/g,"_");
if ($('#'+id+' option[value="'+valUnderscore+'"]').length > 0) {
$('#'+id+' option[value="'+valUnderscore+'"]').prop('selected', true);
} else if ($('#'+id+' option[value="Other"]').length > 0) {
$('#'+id+' option[value="Other"]').prop('selected', true);
} else {
var optionsWithMatchingContent = $('#'+id+' option').filter(function(){
return $(this).html() === value;
});
if (optionsWithMatchingContent.length === 1) {
optionsWithMatchingContent.prop('selected', true);
} else {
alert("The value '" + value + "' is not a valid value for the '" + eleName + "' field.");
$('#'+id+' option[value=""]').prop('selected', true);
}
}
}
}
element.change();
if (element.hasClass('rc-autocomplete')) {
var autocompleteField = element.closest('td').find('.ui-autocomplete-input');
autocompleteField.val(element.find('option:selected').text());
autocompleteField.change();
}
}
/**
* Populate (or clear) all address component fields from the selected Place.
* Uses the NEW Places API property names: addressComponents[].longText / shortText.
*/
function fillInAddress(place, $field) {
// Clear all component fields first
for (var component in componentForm) {
updateValue(autocompletePrefix + component, '');
}
if (place && place.addressComponents && place.addressComponents.length > 0) {
// Write the full formatted address into the hidden original REDCap field
$field.val(place.formattedAddress || '');
$field.change();
// Latitude & Longitude
if (place.location) {
<?php echo ($latitude ? "updateValue('latitude', place.location.lat());\n" : ""); ?>
<?php echo ($longitude ? "updateValue('longitude', place.location.lng());\n" : ""); ?>
}
// Map each address component into the configured REDCap fields
for (var i = 0; i < place.addressComponents.length; i++) {
var comp = place.addressComponents[i];
var addressType = comp.types[0];
if (componentForm[addressType] && document.getElementById(autocompletePrefix + addressType)) {
var formatKey = componentForm[addressType]; // 'short_name' or 'long_name'
var val = comp[formatMap[formatKey]]; // maps to 'shortText' or 'longText'
if (addressType === 'administrative_area_level_2') {
val = $.trim(val.replace('County', ''));
}
updateValue(autocompletePrefix + addressType, val);
document.getElementById(autocompletePrefix + addressType).disabled = false;
}
}
} else {
// No place selected — clear the original field and lat/lng
$field.val('');
$field.change();
<?php echo ($latitude ? "updateValue('latitude', '');\n" : ""); ?>
<?php echo ($longitude ? "updateValue('longitude', '');\n" : ""); ?>
}
if (typeof doBranching === 'function') { doBranching(); }
}
})();
</script>
<?php
}
}
}