diff --git a/django_google_maps/static/django_google_maps/css/google-maps-admin.css b/django_google_maps/static/django_google_maps/css/google-maps-admin.css index 5ba4d8c7..b98181de 100644 --- a/django_google_maps/static/django_google_maps/css/google-maps-admin.css +++ b/django_google_maps/static/django_google_maps/css/google-maps-admin.css @@ -1,11 +1,36 @@ @media (min-width: 848px) { - .map_canvas_wrapper {margin-left: 170px;} - .main.shifted .map_canvas_wrapper {margin-left: 0;} + .main.shifted .map_canvas_wrapper { + margin-left: 0; + } } -@media (min-width: 1118px) { - .main.shifted .map_canvas_wrapper {margin-left: 170px;} + +#id_address { + width: 40em; +} + +.map_canvas_wrapper { + width: 100%; +} + +#map_canvas { + width: 100%; + height: 40em; +} + +#map_message_box { + background-color: #eff6ff; + color: #1e40af; + padding: 12px 20px; + border-radius: 8px; + border: 1px solid #bfdbfe; + display: none; /* Hidden by default */ + font-size: 0.9rem; + opacity: 0; + transition: opacity 0.3s ease-in-out; + float: none; } -#id_address {width: 40em;} -.map_canvas_wrapper {width: 100%;} -#map_canvas {width: 100%; height: 40em;} +#map_message_box.show { + display: block; + opacity: 1; +} diff --git a/django_google_maps/static/django_google_maps/js/google-maps-admin.js b/django_google_maps/static/django_google_maps/js/google-maps-admin.js index d6f6f736..51305c4d 100644 --- a/django_google_maps/static/django_google_maps/js/google-maps-admin.js +++ b/django_google_maps/static/django_google_maps/js/google-maps-admin.js @@ -23,110 +23,185 @@ This script expects: function googleMapAdmin() { var autocomplete; - var geocoder = new google.maps.Geocoder(); + var geocoder; var map; var marker; var geolocationId = 'id_geolocation'; var addressId = 'id_address'; + var messageBoxId = 'map_message_box'; var self = { + /** + * Initializes the Google Map, Autocomplete, and sets up event listeners. + */ initialize: function () { + // Initialize Geocoder + geocoder = new google.maps.Geocoder(); var lat = 0; var lng = 0; - var zoom = 2; - // set up initial map to be world view. also, add change - // event so changing address will update the map - var existinglocation = self.getExistingLocation(); - - if (existinglocation) { - lat = existinglocation[0]; - lng = existinglocation[1]; - zoom = 18; + var zoom = 2; // Default to world view + + // Get existing location from the geolocation input field + var existingLocation = self.getExistingLocation(); + + if (existingLocation) { + lat = parseFloat(existingLocation[0]); // Ensure latitude is a number + lng = parseFloat(existingLocation[1]); // Ensure longitude is a number + zoom = 18; // Zoom in if a location already exists } - var latlng = new google.maps.LatLng(lat, lng); + // Create a LatLng object for the map center + var latlng = {lat: lat, lng: lng}; var myOptions = { zoom: zoom, center: latlng, - mapTypeId: self.getMapType() + mapTypeId: self.getMapType(), + streetViewControl: false, + mapTypeControl: true, + fullscreenControl: false, + mapId: "dj-google-maps-admin" }; + + // Create the map instance map = new google.maps.Map(document.getElementById("map_canvas"), myOptions); - if (existinglocation) { + + // If an existing location is present, set a marker + if (existingLocation) { self.setMarker(latlng); } - autocomplete = new google.maps.places.Autocomplete( - /** @type {!HTMLInputElement} */(document.getElementById(addressId)), - self.getAutoCompleteOptions() - ); - - // this only triggers on enter, or if a suggested location is chosen - // todo: if a user doesn't choose a suggestion and presses tab, the map doesn't update - autocomplete.addListener("place_changed", self.codeAddress); - - // don't make enter submit the form, let it just trigger the place_changed event - // which triggers the map update & geocode - document.getElementById(addressId).addEventListener("keydown", function (e) { - if (e.key === "Enter") { - e.preventDefault(); - return false; + // Initialize Google Places Autocomplete Element + const addressInput = document.getElementById(addressId); + if (addressInput) { + // Create the new Place Autocomplete web component + autocomplete = new google.maps.places.PlaceAutocompleteElement(); + + // Copy essential properties from the old input to the new element + autocomplete.id = addressInput.id; + autocomplete.name = addressInput.name; + autocomplete.className = addressInput.className; + autocomplete.placeholder = addressInput.placeholder || 'Enter an address'; + + // *** THE FIX: Attach the element to the DOM *before* configuring it. *** + // 1. Replace the original input with the new component. + addressInput.parentNode.replaceChild(autocomplete, addressInput); + + // 2. Now that the element is live, apply Google-specific properties. + const autocompleteOptions = self.getAutoCompleteOptions(); + if (autocompleteOptions.types) { + autocomplete.types = autocompleteOptions.types; } - }); + if (autocompleteOptions.componentRestrictions) { + autocomplete.componentRestrictions = autocompleteOptions.componentRestrictions; + } + + // 3. Add the event listener. + autocomplete.addEventListener("gmp-placechange", self.codeAddress); + } }, + /** + * Determines the map type based on a 'data-map-type' attribute on the address input. + * Falls back to 'hybrid' if not specified or invalid. + * @returns {string} The map type string (e.g., 'roadmap', 'satellite'). + */ getMapType: function () { // https://developers.google.com/maps/documentation/javascript/maptypes - var geolocation = document.getElementById(addressId); - var allowedType = ['roadmap', 'satellite', 'hybrid', 'terrain']; - var mapType = geolocation.getAttribute('data-map-type'); + var addressInput = document.getElementById(addressId); + var allowedTypes = ['roadmap', 'satellite', 'hybrid', 'terrain']; + var mapType = addressInput.getAttribute('data-map-type'); - if (mapType && -1 !== allowedType.indexOf(mapType)) { + if (mapType && allowedTypes.includes(mapType)) { return mapType; } - return google.maps.MapTypeId.HYBRID; + return 'hybrid'; // Default to hybrid map type }, + /** + * Retrieves autocomplete options from a 'data-autocomplete-options' attribute. + * Defaults to geocode type if not specified. + * @returns {object} Autocomplete options object. + */ getAutoCompleteOptions: function () { - var geolocation = document.getElementById(addressId); - var autocompleteOptions = geolocation.getAttribute('data-autocomplete-options'); + // The original input is gone, so we get attributes from our new element. + var autocompleteElement = document.getElementById(addressId); + var autocompleteOptions = autocompleteElement.getAttribute('data-autocomplete-options'); if (!autocompleteOptions) { return { - types: ['geocode'] + types: ['address'] }; } - return JSON.parse(autocompleteOptions); + try { + let parsedOptions = JSON.parse(autocompleteOptions); + + // Robustly cleanse the 'types' array for the old 'geocode' value. + if (parsedOptions.types && Array.isArray(parsedOptions.types)) { + const typeIndex = parsedOptions.types.indexOf('geocode'); + if (typeIndex > -1) { + console.warn( + "Google Maps Admin: The 'geocode' autocomplete type is deprecated for this component and was automatically replaced with 'address'. Please update the 'data-autocomplete-options' attribute in your HTML template." + ); + parsedOptions.types[typeIndex] = 'address'; + } + } + + return parsedOptions; + } catch (e) { + console.error("Error parsing data-autocomplete-options:", e); + self.showMessage("Error: Invalid autocomplete options format. Using default.", 'error'); + return {types: ['address']}; + } }, + /** + * Retrieves existing latitude and longitude from the geolocation input field. + * @returns {Array|undefined} An array [latitude, longitude] or undefined if empty. + */ getExistingLocation: function () { - var geolocation = document.getElementById(geolocationId).value; - if (geolocation) { - return geolocation.split(','); + var geolocationInput = document.getElementById(geolocationId).value; + if (geolocationInput) { + return geolocationInput.split(','); } + return undefined; }, + /** + * Geocodes the address from the autocomplete element. + * Updates the map and marker based on the geocoded location. + */ codeAddress: function () { - var place = autocomplete.getPlace(); + // For PlaceAutocompleteElement, the result is on the `.place` property + var place = autocomplete.place; - if (place.geometry !== undefined) { + // Check if a place with geometry (location) was found + if (place && place.geometry && place.geometry.location) { self.updateWithCoordinates(place.geometry.location); - } else { - geocoder.geocode({'address': place.name}, function (results, status) { - if (status == google.maps.GeocoderStatus.OK && results.length > 0) { // Add results.length check + } else if (place && place.displayName) { + // If no geometry, but a place name exists, try to geocode it. + // The new Place object uses `displayName`. + geocoder.geocode({'address': place.displayName}, function (results, status) { + if (status === 'OK' && results.length > 0) { var latlng = results[0].geometry.location; self.updateWithCoordinates(latlng); - } else if (status == google.maps.GeocoderStatus.ZERO_RESULTS) { - alert("No results found for " + place.name + "."); + } else if (status === 'ZERO_RESULTS') { + self.showMessage("No results found for '" + place.displayName + "'.", 'warning'); } else { - alert("Geocode was not successful for the following reason: " + status); + self.showMessage("Geocode was not successful for the following reason: " + status, 'error'); } }); + } else { + self.showMessage("Please enter a valid address.", 'warning'); } }, + /** + * Updates the map center, zoom, marker, and geolocation input with new coordinates. + * @param {google.maps.LatLng} latlng - The new LatLng object. + */ updateWithCoordinates: function (latlng) { map.setCenter(latlng); map.setZoom(18); @@ -134,6 +209,10 @@ function googleMapAdmin() { self.updateGeolocation(latlng); }, + /** + * Sets or updates the map marker at the given LatLng. + * @param {google.maps.LatLng} latlng - The LatLng for the marker. + */ setMarker: function (latlng) { if (marker) { self.updateMarker(latlng); @@ -142,42 +221,96 @@ function googleMapAdmin() { } }, + /** + * Adds a new marker to the map. + * @param {object} Options - Marker options, including latlng and draggable. + */ addMarker: function (Options) { - marker = new google.maps.Marker({ + var draggable = Options.draggable || false; + marker = new google.maps.marker.AdvancedMarkerElement({ map: map, - position: Options.latlng + position: Options.latlng, + gmpDraggable: draggable }); - var draggable = Options.draggable || false; if (draggable) { self.addMarkerDrag(marker); } }, - addMarkerDrag: function () { - marker.setDraggable(true); - google.maps.event.addListener(marker, 'dragend', function (new_location) { - self.updateGeolocation(new_location.latLng); + /** + * Adds a 'dragend' listener to the marker to update geolocation when dragged. + */ + addMarkerDrag: function (draggableMarker) { + // Use the modern addListener method + draggableMarker.addListener('dragend', function (event) { + self.updateGeolocation(event.latLng); }); }, + /** + * Updates the position of the existing marker. + * @param {google.maps.LatLng} latlng - The new LatLng for the marker. + */ updateMarker: function (latlng) { - marker.setPosition(latlng); + marker.position = latlng; }, + /** + * Updates the geolocation input field with the new latitude and longitude. + * Manually dispatches a 'change' event for compatibility with other scripts. + * @param {google.maps.LatLng} latlng - The LatLng object to extract coordinates from. + */ updateGeolocation: function (latlng) { document.getElementById(geolocationId).value = latlng.lat() + "," + latlng.lng(); - // manually trigger a change event + // Manually trigger a change event on the geolocation input var event = new Event('change', {bubbles: true}); document.getElementById(geolocationId).dispatchEvent(event); + }, + + /** + * Displays a temporary message in the message box. + * @param {string} message - The message to display. + * @param {string} type - 'info', 'warning', or 'error' to apply styling. + */ + showMessage: function (message, type = 'info') { + var messageBox = document.getElementById(messageBoxId); + if (!messageBox) return; // Guard against missing message box + messageBox.textContent = message; + + // Clear previous styling classes + messageBox.className = ''; + messageBox.classList.add('rounded-md', 'p-3', 'text-sm', 'font-medium', 'transition-opacity', 'duration-300', 'ease-in-out'); + + // Apply type-specific styling + if (type === 'error') { + messageBox.classList.add('bg-red-100', 'text-red-800', 'border-red-400'); + } else if (type === 'warning') { + messageBox.classList.add('bg-yellow-100', 'text-yellow-800', 'border-yellow-400'); + } else { // info + messageBox.classList.add('bg-blue-100', 'text-blue-800', 'border-blue-400'); + } + + messageBox.style.opacity = 1; // Make it visible + + // Hide the message after 5 seconds + setTimeout(function () { + messageBox.style.opacity = 0; + }, 5000); } }; return self; } -document.addEventListener("DOMContentLoaded", function () { + +async function initGoogleMap() { + await google.maps.importLibrary("maps"); + await google.maps.importLibrary("marker"); + await google.maps.importLibrary("places"); + await google.maps.importLibrary("geocoding"); + var googlemap = googleMapAdmin(); googlemap.initialize(); -}); +} \ No newline at end of file diff --git a/django_google_maps/templates/django_google_maps/widgets/map_widget.html b/django_google_maps/templates/django_google_maps/widgets/map_widget.html index a6aaea04..7e2e6a8d 100644 --- a/django_google_maps/templates/django_google_maps/widgets/map_widget.html +++ b/django_google_maps/templates/django_google_maps/widgets/map_widget.html @@ -1,2 +1,7 @@ -{% include "django/forms/widgets/text.html" %} -
\ No newline at end of file +
+ {% include "django/forms/widgets/text.html" %} +
+
+
+ +
\ No newline at end of file diff --git a/django_google_maps/tests/test_widget.py b/django_google_maps/tests/test_widget.py index 15a6332e..6a978848 100644 --- a/django_google_maps/tests/test_widget.py +++ b/django_google_maps/tests/test_widget.py @@ -7,24 +7,26 @@ class WidgetTests(test.TestCase): def test_render_returns_xxxxxxx(self): widget = GoogleMapsAddressWidget() results = widget.render("name", "value", attrs={"a1": 1, "a2": 2}) - expected = '' + expected = '
' + expected += '' expected += '
' expected += '
' + expected += '
' self.assertHTMLEqual(expected, results) def test_render_returns_blank_for_value_when_none(self): widget = GoogleMapsAddressWidget() results = widget.render("name", None, attrs={"a1": 1, "a2": 2}) - expected = '' + expected = '
' + expected += '' expected += '
' expected += '
' + expected += '
' self.assertHTMLEqual(expected, results) def test_maps_js_uses_api_key(self): widget = GoogleMapsAddressWidget() google_maps_js = ( - "https://maps.google.com/maps/api/js?key={}&libraries=places".format( - settings.GOOGLE_MAPS_API_KEY - ) + f"https://maps.googleapis.com/maps/api/js?key={settings.GOOGLE_MAPS_API_KEY}&loading=async&callback=initGoogleMap" ) self.assertEqual(google_maps_js, widget.Media().js[0]) diff --git a/django_google_maps/widgets.py b/django_google_maps/widgets.py index 08d0de84..e7d8bb64 100644 --- a/django_google_maps/widgets.py +++ b/django_google_maps/widgets.py @@ -10,8 +10,6 @@ class GoogleMapsAddressWidget(widgets.TextInput): class Media: css = {"all": ("django_google_maps/css/google-maps-admin.css",)} js = ( - "https://maps.google.com/maps/api/js?key={}&libraries=places".format( - settings.GOOGLE_MAPS_API_KEY - ), + f"https://maps.googleapis.com/maps/api/js?key={settings.GOOGLE_MAPS_API_KEY}&loading=async&callback=initGoogleMap", "django_google_maps/js/google-maps-admin.js", )