From ace0987647624346a9d1f41e1b3326b8927d70a8 Mon Sep 17 00:00:00 2001 From: Ingmar Stein Date: Sun, 9 Mar 2025 15:49:31 +0100 Subject: [PATCH] Reapply "Merge pull request #28 from tronbyt/location-picker" This reverts commit b493915bb23cf5c92ff58e75f8321df5f532dfa9. --- .../schema/fields/location/LocationBased.jsx | 413 ++++++++++++++---- .../schema/fields/location/LocationForm.jsx | 352 +++++++++++---- 2 files changed, 591 insertions(+), 174 deletions(-) diff --git a/src/features/schema/fields/location/LocationBased.jsx b/src/features/schema/fields/location/LocationBased.jsx index ce7bbe03fb..770aaac811 100644 --- a/src/features/schema/fields/location/LocationBased.jsx +++ b/src/features/schema/fields/location/LocationBased.jsx @@ -1,35 +1,272 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useSelector, useDispatch } from 'react-redux'; - import MenuItem from '@mui/material/MenuItem'; import FormControl from '@mui/material/FormControl'; import Select from '@mui/material/Select'; -import TextField from '@mui/material/TextField'; import Typography from '@mui/material/Typography'; -import InputSlider from './InputSlider'; import { set } from '../../../config/configSlice'; import { callHandler } from '../../../handlers/actions'; +// Helper function to extract all URL parameters +const getUrlParams = () => { + // Parse the query string from the URL + const queryString = window.location.search; + const params = new URLSearchParams(queryString); + + // Create an object with all parameters + const urlParams = {}; + for (const [key, value] of params.entries()) { + urlParams[key] = value; + } + + return urlParams; +}; + +// UI component library for the location picker dropdown +// Base container for the entire dropdown component +const Command = ({ children, className, onClick }) => ( +
+ {children} +
+); + +// Text input component for the search box +const CommandInput = ({ placeholder, value, onValueChange, onClick }) => ( + onValueChange(e.target.value)} + onClick={onClick} + /> +); + +// Container for the results dropdown list +const CommandList = ({ children }) => ( +
+ {children} +
+); + +// Individual selectable item in the dropdown list +const CommandItem = ({ children, onSelect }) => ( +
+ {children} +
+); + +// LocationPicker component that handles location search via Geoapify API +const LocationPicker = ({ onLocationSelect, initialLocation }) => { + // State management for the search and results + const [query, setQuery] = useState(initialLocation?.locality || ''); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [isOpen, setIsOpen] = useState(false); + + // Debounce utility to prevent excessive API calls during typing + const debounce = (func, wait) => { + let timeout; + return (...args) => { + clearTimeout(timeout); + timeout = setTimeout(() => func(...args), wait); + }; + }; + + // Function to search locations via Geoapify API + const searchLocations = async (searchQuery) => { + if (!searchQuery) { + setResults([]); + setIsOpen(false); // Close dropdown if query is empty + return; + } + + setLoading(true); + setError(null); + + try { + const apiKey = '49863bd631b84a169b347fafbf128ce6'; + const encodedQuery = encodeURIComponent(searchQuery); + const response = await fetch( + `https://api.geoapify.com/v1/geocode/search?text=${encodedQuery}&apiKey=${apiKey}` + ); + + if (!response.ok) { + throw new Error('Failed to fetch locations'); + } + + const data = await response.json(); + + // Transform the API response to our internal format + const locations = data.features.map(feature => ({ + formattedName: feature.properties.formatted, + locality: feature.properties.formatted, + lat: feature.properties.lat, + lng: feature.properties.lon, + timezone: feature.properties.timezone?.name || 'America/New_York' + })); + + setResults(locations); + } catch (err) { + setError('Failed to load locations'); + console.error('Error fetching locations:', err); + } finally { + setLoading(false); + } + }; + + // Create a debounced search function to delay API calls + const debouncedSearch = useCallback( + debounce(searchLocations, 300), + [] + ); + + // Handler for search input changes + const handleSearch = (value) => { + setQuery(value); + setIsOpen(true); // Open dropdown when searching + debouncedSearch(value); + }; + + // Effect to handle clicks outside the component to close the dropdown + useEffect(() => { + const handleClickOutside = () => { + setIsOpen(false); + }; + + // Add event listener when component mounts + document.addEventListener('click', handleClickOutside); + + // Clean up the event listener on unmount + return () => { + document.removeEventListener('click', handleClickOutside); + }; + }, []); + + // Prevent dropdown closure when clicking inside the component + const handleCommandClick = (e) => { + e.stopPropagation(); + }; + + return ( + + setIsOpen(true)} // Open dropdown when clicking input + /> + {isOpen && loading && ( +
+ Loading... +
+ )} + {isOpen && error && ( +
+ {error} +
+ )} + {isOpen && results.length > 0 && ( + + {results.map((location, index) => ( + { + onLocationSelect(location); + setIsOpen(false); // Close dropdown after selection + setResults([]); // Clear results + }} + > + 📍 + {location.formattedName} + + ))} + + )} +
+ ); +}; + +// Main component that extends the LocationForm with handler integration export default function LocationBased({ field }) { - const [value, setValue] = useState({ - // Default to Brooklyn, because that's where tidbyt folks - // are and we can only dispatch a location object which - // has all fields set. - 'lat': 40.678, - 'lng': -73.944, - 'locality': 'Brooklyn, New York', - 'timezone': 'America/New_York', - // But overwrite with app-specific defaults set in config. - 'display': '', - 'value': '', - ...field.default - }); + // Get URL parameters to check for location data + const urlParams = getUrlParams(); + + // Try to find location data in the URL parameters + let urlLocation = null; + + // First check if field.id exists as a parameter + if (urlParams[field.id]) { + try { + const parsed = JSON.parse(decodeURIComponent(urlParams[field.id])); + if (parsed && typeof parsed === 'object' && 'locality' in parsed) { + urlLocation = parsed; + } + } catch (err) { + console.error(`Error parsing ${field.id} from URL:`, err); + } + } + + // If location not found by field.id, search all URL parameters for valid location data + if (!urlLocation) { + for (const [key, value] of Object.entries(urlParams)) { + try { + const parsed = JSON.parse(decodeURIComponent(value)); + if (parsed && typeof parsed === 'object' && 'locality' in parsed) { + urlLocation = parsed; + break; // Found a valid location, stop searching + } + } catch (err) { + // Not a valid JSON, continue to next parameter + continue; + } + } + } + + // Function to calculate default values with fallbacks and overrides + const getDefaultValues = () => { + const defaultValues = { + // Default to Brooklyn as a fallback + 'lat': 40.678, + 'lng': -73.944, + 'locality': 'Brooklyn, New York', + 'timezone': 'America/New_York', + 'display': '', // For dropdown display value + 'value': '', // For dropdown selected value + }; + // Override with field.default if available + if (field.default) { + Object.assign(defaultValues, field.default); + } + + // Override with URL location if available + if (urlLocation) { + // Merge the URL location with defaults for any missing fields + return { + ...defaultValues, + ...urlLocation + }; + } + + return defaultValues; + }; + + // Component state management + const [value, setValue] = useState(getDefaultValues()); + const [initialized, setInitialized] = useState(false); + + // Redux hooks const config = useSelector(state => state.config); const dispatch = useDispatch(); - const handlerResults = useSelector(state => state.handlers) + const handlerResults = useSelector(state => state.handlers); + // Helper function to format location for handlers const getLocationAsJson = (v) => { return JSON.stringify({ lat: v.lat, @@ -37,73 +274,81 @@ export default function LocationBased({ field }) { locality: v.locality, timezone: v.timezone }); - } + }; + // Effect to initialize the component useEffect(() => { - if (field.id in config) { + // Set initialized to true + setInitialized(true); + }, []); + + // Effect to load configuration from Redux or initialize with defaults + useEffect(() => { + if (initialized && field.id in config) { + // Load saved configuration from Redux let v = JSON.parse(config[field.id].value); setValue(v); + + // Call the associated handler with the location data callHandler(field.id, field.handler, getLocationAsJson(v)); - } else if (field.default) { - value = field.default; + } else if (initialized) { + // Use default values (including URL params if available) + const defaultValues = getDefaultValues(); + + // Save to Redux dispatch(set({ id: field.id, - value: JSON.stringify(field.default), + value: JSON.stringify(defaultValues), })); - } - }, [config]); + setValue(defaultValues); - useEffect(() => { - if (!(field.id in config)) { - callHandler(field.id, field.handler, getLocationAsJson(value)); + // Call handler with initial location + callHandler(field.id, field.handler, getLocationAsJson(defaultValues)); } - }, []); + }, [config, dispatch, field.id, field.default, field.handler, initialized]); - const setLocationPart = (partName, partValue) => { - let newValue = { ...value }; - newValue[partName] = partValue; + // Handler for location selection from the picker + const handleLocationSelect = (location) => { + // Create new state with selected location + const newValue = { + ...value, + lat: location.lat, + lng: location.lng, + locality: location.locality || location.formattedName, + timezone: location.timezone + }; + + // Update state and Redux store setValue(newValue); dispatch(set({ id: field.id, value: JSON.stringify(newValue), })); - callHandler(field.id, field.handler, getLocationAsJson(newValue)); - } - - const truncateLatLng = (value) => { - return String(Number(value).toFixed(3)); - } - const onChangeLatitude = (event) => { - setLocationPart('lat', truncateLatLng(event.target.value)); - } - - const onChangeLongitude = (event) => { - setLocationPart('lng', truncateLatLng(event.target.value)); - } - - const onChangeLocality = (event) => { - setLocationPart('locality', event.target.value); - } - - const onChangeTimezone = (event) => { - setLocationPart('timezone', event.target.value); - } + // Call handler with new location to update location-dependent options + callHandler(field.id, field.handler, getLocationAsJson(newValue)); + }; + // Handler for dropdown option selection const onChangeOption = (event) => { let newValue = { ...value }; newValue.value = event.target.value; + + // Get display text from selected option const selectedOption = options.find(option => option.value === event.target.value); if (selectedOption) { newValue.display = selectedOption.display; } + + // Update state and Redux store setValue(newValue); dispatch(set({ id: field.id, value: JSON.stringify(newValue), })); - } + }; + // Get options from handler results for the dropdown let options = []; if (field.id in handlerResults.values) { options = handlerResults.values[field.id]; @@ -111,50 +356,32 @@ export default function LocationBased({ field }) { return ( - Latitude - - - Longitude - - - Locality - Location + - Timezone - - Options for chosen location + + {/* Display selected location details */} + {value && ( +
+ Selected: {value.locality} + Coordinates: {value.lat.toFixed(3)}, {value.lng.toFixed(3)} + Timezone: {value.timezone} +
+ )} + + {/* Options dropdown for the chosen location */} + Options for chosen location
); diff --git a/src/features/schema/fields/location/LocationForm.jsx b/src/features/schema/fields/location/LocationForm.jsx index f3da710c87..92ccd85074 100644 --- a/src/features/schema/fields/location/LocationForm.jsx +++ b/src/features/schema/fields/location/LocationForm.jsx @@ -1,110 +1,300 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useSelector, useDispatch } from 'react-redux'; - -import MenuItem from '@mui/material/MenuItem'; -import FormControl from '@mui/material/FormControl'; -import Select from '@mui/material/Select'; -import TextField from '@mui/material/TextField'; import Typography from '@mui/material/Typography'; +import FormControl from '@mui/material/FormControl'; -import InputSlider from './InputSlider'; import { set } from '../../../config/configSlice'; +// Command components for the location picker - these create the UI for the dropdown interface +// Container component that wraps the entire command interface +const Command = ({ children, className }) => ( +
+ {children} +
+); + +// Input field component for search queries +const CommandInput = ({ placeholder, value, onValueChange }) => ( + onValueChange(e.target.value)} + /> +); + +// Container for the dropdown list of location results +const CommandList = ({ children }) => ( +
+ {children} +
+); + +// Individual item in the dropdown list that user can select +const CommandItem = ({ children, onSelect }) => ( +
+ {children} +
+); + +// Helper function to extract URL parameters +const getUrlParams = () => { + // Parse the query string from the URL + const queryString = window.location.search; + const params = new URLSearchParams(queryString); + + // Create an object with all parameters + const urlParams = {}; + for (const [key, value] of params.entries()) { + urlParams[key] = value; + } + + return urlParams; +}; + +// Main location picker component that integrates with Geoapify API +const LocationPicker = ({ onLocationSelect, initialLocation }) => { + // State for search input, results, loading status, and dropdown visibility + const [query, setQuery] = useState(initialLocation?.locality || ''); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [isOpen, setIsOpen] = useState(false); + + // Debounce function to prevent excessive API calls during typing + const debounce = (func, wait) => { + let timeout; + return (...args) => { + clearTimeout(timeout); + timeout = setTimeout(() => func(...args), wait); + }; + }; + + // Function to fetch location results from Geoapify API + const searchLocations = async (searchQuery) => { + if (!searchQuery) { + setResults([]); + setIsOpen(false); // Close dropdown if query is empty + return; + } + + setLoading(true); + setError(null); + + try { + const apiKey = '49863bd631b84a169b347fafbf128ce6'; + const encodedQuery = encodeURIComponent(searchQuery); + const response = await fetch( + `https://api.geoapify.com/v1/geocode/search?text=${encodedQuery}&apiKey=${apiKey}` + ); + + if (!response.ok) { + throw new Error('Failed to fetch locations'); + } + + const data = await response.json(); + + // Transform API response to match our format + const locations = data.features.map(feature => ({ + formattedName: feature.properties.formatted, + locality: feature.properties.formatted, + lat: feature.properties.lat, + lng: feature.properties.lon, + timezone: feature.properties.timezone?.name || 'America/New_York' + })); + + setResults(locations); + } catch (err) { + setError('Failed to load locations'); + console.error('Error fetching locations:', err); + } finally { + setLoading(false); + } + }; + + // Create a debounced version of the search function (300ms delay) + const debouncedSearch = useCallback( + debounce(searchLocations, 300), + [] + ); + + // Handler for search input changes + const handleSearch = (value) => { + setQuery(value); + setIsOpen(true); // Open dropdown when searching + debouncedSearch(value); + }; + + // Close dropdown when clicking outside the component + useEffect(() => { + const handleClickOutside = () => { + setIsOpen(false); + }; + + // Add event listener when component mounts + document.addEventListener('click', handleClickOutside); + + // Return cleanup function + return () => { + document.removeEventListener('click', handleClickOutside); + }; + }, []); + + // Prevent dropdown from closing when clicking inside the component + const handleCommandClick = (e) => { + e.stopPropagation(); + }; + + return ( + + setIsOpen(true)} // Open dropdown when clicking input + /> + {isOpen && loading && ( +
+ Loading... +
+ )} + {isOpen && error && ( +
+ {error} +
+ )} + {isOpen && results.length > 0 && ( + + {results.map((location, index) => ( + { + onLocationSelect(location); + setIsOpen(false); // Close dropdown after selection + setResults([]); // Clear results + }} + > + 📍 + {location.formattedName} + + ))} + + )} +
+ ); +}; + +// Main exported component that wraps the LocationPicker with Redux integration export default function LocationForm({ field }) { - const [value, setValue] = useState({ - // Default to Brooklyn, because that's where tidbyt folks - // are and we can only dispatch a location object which - // has all fields set. - 'lat': 40.678, - 'lng': -73.944, - 'locality': 'Brooklyn, New York', - 'timezone': 'America/New_York', - // But overwrite with app-specific defaults set in config. - ...field.default - }); + // Get URL parameters to check for location data + const urlParams = getUrlParams(); - const config = useSelector(state => state.config); + // Check if location parameter exists in URL (it's a JSON string) + const urlLocationParam = urlParams.location; + let urlLocation = null; + + // Try to parse the URL location parameter if it exists + if (urlLocationParam) { + try { + urlLocation = JSON.parse(decodeURIComponent(urlLocationParam)); + } catch (err) { + console.error('Error parsing location from URL:', err); + } + } + + // Determine default values based on defaults, field props, and URL params + const getDefaultValues = () => { + const defaultValues = { + // Default to Brooklyn as fallback + 'lat': 40.678, + 'lng': -73.944, + 'locality': 'Brooklyn, New York', + 'timezone': 'America/New_York', + }; + // Override with field.default if available + if (field.default) { + Object.assign(defaultValues, field.default); + } + + // Override with URL location if available + if (urlLocation) { + return urlLocation; // Replace the entire location object + } + + return defaultValues; + }; + + // State for location value and component initialization tracking + const [value, setValue] = useState(getDefaultValues()); + const [initialized, setInitialized] = useState(false); + + // Redux hooks for state management + const config = useSelector(state => state.config); const dispatch = useDispatch(); + // Set component as initialized on first render useEffect(() => { - if (field.id in config) { + // Set initialized to true immediately - we don't need to search + // since we're getting the full location object from the URL + setInitialized(true); + }, []); + + // Load location from Redux store or initialize with defaults + useEffect(() => { + if (initialized && field.id in config) { + // If location exists in Redux, use that value setValue(JSON.parse(config[field.id].value)); - } else if (field.default) { + } else if (initialized) { + // Otherwise use default values and save to Redux + const defaultValues = getDefaultValues(); dispatch(set({ id: field.id, - value: field.default, + value: JSON.stringify(defaultValues), })); + setValue(defaultValues); } - }, [config]) + }, [config, dispatch, field.id, field.default, initialized]); + + // Handler for when user selects a location from the dropdown + const handleLocationSelect = (location) => { + const newValue = { + lat: location.lat, + lng: location.lng, + locality: location.locality || location.formattedName, + timezone: location.timezone + }; - const setPart = (partName, partValue) => { - let newValue = { ...value }; - newValue[partName] = partValue; + // Update local state and Redux store setValue(newValue); dispatch(set({ id: field.id, value: JSON.stringify(newValue), })); - } - - const truncateLatLng = (value) => { - return String(Number(value).toFixed(3)); - } - - const onChangeLatitude = (event) => { - setPart('lat', truncateLatLng(event.target.value)); - } - - const onChangeLongitude = (event) => { - setPart('lng', truncateLatLng(event.target.value)); - } - - const onChangeLocality = (event) => { - setPart('locality', event.target.value); - } - - const onChangeTimezone = (event) => { - setPart('timezone', event.target.value); - } + }; return ( - Latitude - - - Longitude - - - Locality - Location + + {/* Location search component */} + - Timezone - + + {/* Display the currently selected location details */} + {value && ( +
+ Selected: {value.locality} + Coordinates: {value.lat.toFixed(3)}, {value.lng.toFixed(3)} + Timezone: {value.timezone} +
+ )}
); } \ No newline at end of file