Make/Model Data Loading

This commit is contained in:
Eric Gullickson
2025-11-07 13:51:47 -06:00
parent 060867e796
commit daf1f71e2c
11 changed files with 817 additions and 97 deletions

View File

@@ -16,6 +16,13 @@ describe('Gas Stations Feature', () => {
cy.visit('/stations');
});
const enterSampleAddress = () => {
cy.get('input[name="street"]').clear().type('123 Main St');
cy.get('input[name="city"]').clear().type('San Francisco');
cy.get('select[name="state"]').select('CA');
cy.get('input[name="zip"]').clear().type('94105');
};
describe('Search for Nearby Stations', () => {
it('should allow searching with current location', () => {
// Mock geolocation
@@ -42,10 +49,9 @@ describe('Gas Stations Feature', () => {
cy.contains('Shell').or('Chevron').or('76').or('Exxon').should('be.visible');
});
it('should allow searching with manual coordinates', () => {
// Enter manual coordinates
cy.get('input[name="latitude"]').clear().type('37.7749');
cy.get('input[name="longitude"]').clear().type('-122.4194');
it('should allow searching with a manual address', () => {
// Enter manual address fields
enterSampleAddress();
// Adjust radius
cy.get('[data-testid="radius-slider"]').click();
@@ -57,16 +63,12 @@ describe('Gas Stations Feature', () => {
cy.get('[data-testid="station-card"]').should('exist');
});
it('should handle search errors gracefully', () => {
// Enter invalid coordinates
cy.get('input[name="latitude"]').clear().type('999');
cy.get('input[name="longitude"]').clear().type('999');
// Search
it('should require address details when location is unavailable', () => {
// Attempt to search without address or geolocation
cy.contains('button', 'Search').click();
// Verify error message
cy.contains('error', { matchCase: false }).should('be.visible');
cy.contains('Enter Street, City, State, and ZIP', { matchCase: false }).should('be.visible');
});
it('should display loading state during search', () => {
@@ -85,8 +87,7 @@ describe('Gas Stations Feature', () => {
describe('View Stations on Map', () => {
beforeEach(() => {
// Perform a search first
cy.get('input[name="latitude"]').clear().type('37.7749');
cy.get('input[name="longitude"]').clear().type('-122.4194');
enterSampleAddress();
cy.contains('button', 'Search').click();
cy.wait(2000);
});
@@ -122,8 +123,7 @@ describe('Gas Stations Feature', () => {
describe('Save Station to Favorites', () => {
beforeEach(() => {
// Search first
cy.get('input[name="latitude"]').clear().type('37.7749');
cy.get('input[name="longitude"]').clear().type('-122.4194');
enterSampleAddress();
cy.contains('button', 'Search').click();
cy.wait(1000);
});

View File

@@ -78,14 +78,14 @@ StationsMobileScreen (Mobile)
### StationsSearchForm
**Purpose**: Search input with geolocation and manual coordinate entry
**Purpose**: Search input with geolocation or manual street-level address entry
**Props**: None (uses hooks internally)
**Features**:
- Geolocation button (requests browser permission)
- Manual latitude/longitude inputs
- Radius slider (1-50 km)
- Manual Street / City / State / ZIP inputs with Google geocoding
- Radius slider (1-25 miles)
- Loading states
- Error handling

View File

@@ -2,7 +2,7 @@
* @ai-summary Form for searching nearby gas stations
*/
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useMemo } from 'react';
import {
Box,
TextField,
@@ -12,12 +12,95 @@ import {
FormLabel,
Alert,
CircularProgress,
InputAdornment
InputAdornment,
MenuItem
} from '@mui/material';
import LocationIcon from '@mui/icons-material/LocationOn';
import MyLocationIcon from '@mui/icons-material/MyLocation';
import { StationSearchRequest, GeolocationError } from '../types/stations.types';
import { useGeolocation } from '../hooks';
import { loadGoogleMaps, getGoogleMapsApi } from '../utils/maps-loader';
type Coordinates = {
latitude: number;
longitude: number;
};
type GeocoderStatus = 'OK' | 'ZERO_RESULTS' | string;
interface GeocoderResult {
geometry: {
location: {
lat: () => number;
lng: () => number;
};
};
}
interface GoogleMapsGeocoder {
geocode(
request: { address: string },
callback: (results: GeocoderResult[] | null, status: GeocoderStatus) => void
): void;
}
type GoogleMapsWithGeocoder = {
Geocoder: new () => GoogleMapsGeocoder;
};
const US_STATE_OPTIONS = [
{ value: 'AL', label: 'Alabama' },
{ value: 'AK', label: 'Alaska' },
{ value: 'AZ', label: 'Arizona' },
{ value: 'AR', label: 'Arkansas' },
{ value: 'CA', label: 'California' },
{ value: 'CO', label: 'Colorado' },
{ value: 'CT', label: 'Connecticut' },
{ value: 'DE', label: 'Delaware' },
{ value: 'DC', label: 'District of Columbia' },
{ value: 'FL', label: 'Florida' },
{ value: 'GA', label: 'Georgia' },
{ value: 'HI', label: 'Hawaii' },
{ value: 'ID', label: 'Idaho' },
{ value: 'IL', label: 'Illinois' },
{ value: 'IN', label: 'Indiana' },
{ value: 'IA', label: 'Iowa' },
{ value: 'KS', label: 'Kansas' },
{ value: 'KY', label: 'Kentucky' },
{ value: 'LA', label: 'Louisiana' },
{ value: 'ME', label: 'Maine' },
{ value: 'MD', label: 'Maryland' },
{ value: 'MA', label: 'Massachusetts' },
{ value: 'MI', label: 'Michigan' },
{ value: 'MN', label: 'Minnesota' },
{ value: 'MS', label: 'Mississippi' },
{ value: 'MO', label: 'Missouri' },
{ value: 'MT', label: 'Montana' },
{ value: 'NE', label: 'Nebraska' },
{ value: 'NV', label: 'Nevada' },
{ value: 'NH', label: 'New Hampshire' },
{ value: 'NJ', label: 'New Jersey' },
{ value: 'NM', label: 'New Mexico' },
{ value: 'NY', label: 'New York' },
{ value: 'NC', label: 'North Carolina' },
{ value: 'ND', label: 'North Dakota' },
{ value: 'OH', label: 'Ohio' },
{ value: 'OK', label: 'Oklahoma' },
{ value: 'OR', label: 'Oregon' },
{ value: 'PA', label: 'Pennsylvania' },
{ value: 'RI', label: 'Rhode Island' },
{ value: 'SC', label: 'South Carolina' },
{ value: 'SD', label: 'South Dakota' },
{ value: 'TN', label: 'Tennessee' },
{ value: 'TX', label: 'Texas' },
{ value: 'UT', label: 'Utah' },
{ value: 'VT', label: 'Vermont' },
{ value: 'VA', label: 'Virginia' },
{ value: 'WA', label: 'Washington' },
{ value: 'WV', label: 'West Virginia' },
{ value: 'WI', label: 'Wisconsin' },
{ value: 'WY', label: 'Wyoming' }
];
interface StationsSearchFormProps {
onSearch: (request: StationSearchRequest) => void;
@@ -32,10 +115,15 @@ export const StationsSearchForm: React.FC<StationsSearchFormProps> = ({
onSearch,
isSearching = false
}) => {
const [latitude, setLatitude] = useState<number | ''>('');
const [longitude, setLongitude] = useState<number | ''>('');
const [street, setStreet] = useState('');
const [city, setCity] = useState('');
const [stateCode, setStateCode] = useState('');
const [zip, setZip] = useState('');
const [radius, setRadius] = useState(5); // Miles
const [locationError, setLocationError] = useState<string | null>(null);
const [addressError, setAddressError] = useState<string | null>(null);
const [resolvedCoordinates, setResolvedCoordinates] = useState<Coordinates | null>(null);
const [isGeocoding, setIsGeocoding] = useState(false);
const {
coordinates,
@@ -48,9 +136,12 @@ export const StationsSearchForm: React.FC<StationsSearchFormProps> = ({
// Update form when geolocation succeeds
useEffect(() => {
if (coordinates) {
setLatitude(coordinates.latitude);
setLongitude(coordinates.longitude);
setResolvedCoordinates({
latitude: coordinates.latitude,
longitude: coordinates.longitude
});
setLocationError(null);
setAddressError(null);
}
}, [coordinates]);
@@ -64,29 +155,85 @@ export const StationsSearchForm: React.FC<StationsSearchFormProps> = ({
} else if (geoError === GeolocationError.POSITION_UNAVAILABLE) {
setLocationError('Location not available. Try a different device.');
} else {
setLocationError('Unable to get location. Please enter manually.');
setLocationError('Unable to get location. Please enter your address.');
}
}
}, [geoError]);
const handleUseCurrentLocation = () => {
clearGeoError();
setAddressError(null);
requestPermission();
};
const handleSearch = () => {
if (latitude === '' || longitude === '') {
setLocationError('Please enter coordinates or use current location');
const addressIsComplete = useMemo(
() => street.trim() !== '' && city.trim() !== '' && stateCode !== '' && zip.trim().length === 5,
[street, city, stateCode, zip]
);
const markManualAddressInput = () => {
setResolvedCoordinates(null);
setAddressError(null);
setLocationError(null);
};
const handleSearch = async (): Promise<void> => {
const submitWithCoordinates = (coords: Coordinates) => {
const request: StationSearchRequest = {
latitude: coords.latitude,
longitude: coords.longitude,
radius: radius * 1609.34 // Convert miles to meters
};
onSearch(request);
};
if (resolvedCoordinates) {
submitWithCoordinates(resolvedCoordinates);
return;
}
const request: StationSearchRequest = {
latitude: typeof latitude === 'number' ? latitude : 0,
longitude: typeof longitude === 'number' ? longitude : 0,
radius: radius * 1609.34 // Convert miles to meters
};
if (!addressIsComplete) {
setAddressError('Enter Street, City, State, and ZIP or use current location.');
return;
}
onSearch(request);
try {
setIsGeocoding(true);
await loadGoogleMaps();
const maps = getGoogleMapsApi() as unknown as GoogleMapsWithGeocoder;
const geocoder = new maps.Geocoder();
const formattedAddress = [street, city, stateCode, zip].filter(Boolean).join(', ');
const coords = await new Promise<Coordinates>((resolve, reject) => {
geocoder.geocode({ address: formattedAddress }, (results, status) => {
if (status === 'OK' && results && results[0]) {
const location = results[0].geometry.location;
resolve({
latitude: location.lat(),
longitude: location.lng()
});
return;
}
if (status === 'ZERO_RESULTS') {
reject(new Error('Address not found. Please double-check the details.'));
return;
}
reject(new Error('Unable to locate that address right now. Try again shortly.'));
});
});
setResolvedCoordinates(coords);
setLocationError(null);
setAddressError(null);
submitWithCoordinates(coords);
} catch (error) {
setAddressError(error instanceof Error ? error.message : 'Unable to locate that address.');
} finally {
setIsGeocoding(false);
}
};
const handleRadiusChange = (
@@ -103,7 +250,7 @@ export const StationsSearchForm: React.FC<StationsSearchFormProps> = ({
component="form"
onSubmit={(e) => {
e.preventDefault();
handleSearch();
void handleSearch();
}}
sx={{ padding: 2, display: 'flex', flexDirection: 'column', gap: 2 }}
>
@@ -121,17 +268,17 @@ export const StationsSearchForm: React.FC<StationsSearchFormProps> = ({
{/* Or Divider */}
<Box sx={{ textAlign: 'center', color: 'textSecondary' }}>or</Box>
{/* Manual Latitude Input */}
{/* Street Address Input */}
<TextField
label="Latitude"
type="number"
value={latitude}
label="Street"
name="street"
value={street}
onChange={(e) => {
const val = e.target.value;
setLatitude(val === '' ? '' : parseFloat(val));
setStreet(e.target.value);
markManualAddressInput();
}}
placeholder="37.7749"
inputProps={{ step: '0.0001', min: '-90', max: '90' }}
placeholder="123 Main St"
autoComplete="address-line1"
fullWidth
InputProps={{
startAdornment: (
@@ -142,31 +289,87 @@ export const StationsSearchForm: React.FC<StationsSearchFormProps> = ({
}}
/>
{/* Manual Longitude Input */}
{/* City Input */}
<TextField
label="Longitude"
type="number"
value={longitude}
label="City"
name="city"
value={city}
onChange={(e) => {
const val = e.target.value;
setLongitude(val === '' ? '' : parseFloat(val));
setCity(e.target.value);
markManualAddressInput();
}}
placeholder="-122.4194"
inputProps={{ step: '0.0001', min: '-180', max: '180' }}
placeholder="San Francisco"
autoComplete="address-level2"
fullWidth
InputProps={{
startAdornment: (
<InputAdornment position="start">
<LocationIcon />
</InputAdornment>
)
}}
/>
{/* State and ZIP */}
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr' },
gap: 2,
width: '100%'
}}
>
<TextField
select
label="State"
value={stateCode}
onChange={(e) => {
setStateCode(e.target.value);
markManualAddressInput();
}}
SelectProps={{
displayEmpty: true,
renderValue: (selected) => {
const value = selected as string;
if (!value) {
return 'Select state';
}
const option = US_STATE_OPTIONS.find((state) => state.value === value);
return option ? option.label : value;
}
}}
InputLabelProps={{ shrink: true }}
inputProps={{ name: 'state' }}
fullWidth
>
<MenuItem value="">
<em>Select state</em>
</MenuItem>
{US_STATE_OPTIONS.map((state) => (
<MenuItem key={state.value} value={state.value}>
{state.label}
</MenuItem>
))}
</TextField>
<TextField
label="ZIP"
name="zip"
value={zip}
onChange={(e) => {
const sanitized = e.target.value.replace(/[^0-9]/g, '').slice(0, 5);
setZip(sanitized);
markManualAddressInput();
}}
placeholder="94105"
inputProps={{
inputMode: 'numeric',
pattern: '[0-9]*',
maxLength: 5
}}
autoComplete="postal-code"
fullWidth
/>
</Box>
{/* Radius Slider */}
<FormControl fullWidth>
<FormLabel>Search Radius: {radius} mi</FormLabel>
<Slider
data-testid="radius-slider"
value={radius}
onChange={handleRadiusChange}
min={1}
@@ -183,22 +386,32 @@ export const StationsSearchForm: React.FC<StationsSearchFormProps> = ({
</FormControl>
{/* Error Messages */}
{locationError && (
<Alert severity="error">{locationError}</Alert>
{(locationError || addressError) && (
<Alert severity="error">{locationError || addressError}</Alert>
)}
{/* Search Button */}
<Button
variant="contained"
color="primary"
onClick={handleSearch}
disabled={isSearching || latitude === '' || longitude === ''}
onClick={() => {
void handleSearch();
}}
disabled={
isSearching ||
isGeocoding ||
(!resolvedCoordinates && !addressIsComplete)
}
sx={{
minHeight: '44px',
marginTop: 1
}}
>
{isSearching ? <CircularProgress size={24} /> : 'Search Stations'}
{isSearching || isGeocoding ? (
<CircularProgress size={24} />
) : (
'Search Stations'
)}
</Button>
</Box>
);