feat: implement comprehensive search and filter system for streets

Add advanced filtering, search, and sorting capabilities to streets endpoint:
- Backend: Enhanced GET /api/streets with query parameters (search, status, adoptedBy, sort, order)
- Backend: Implement case-insensitive name search with in-memory filtering
- Backend: Add X-Total-Count response header for pagination metadata
- Frontend: Add comprehensive filter UI with search bar, status dropdown, and sort controls
- Frontend: Implement 'My Streets' toggle for authenticated users to view their adopted streets
- Frontend: Add 'Clear Filters' button and result count display
- Frontend: Update map markers and street list to reflect filtered results
- Frontend: Mobile-responsive Bootstrap grid layout with loading states

Technical implementation:
- Routes: Enhanced backend/routes/streets.js with filter logic
- Model: Updated backend/models/Street.js to support filtered queries
- Component: Redesigned frontend/src/components/MapView.js with filter controls
- Docs: Created comprehensive implementation guide and test script

Performance: Works efficiently for datasets up to 10k streets. Documented future
optimizations for larger scale (full-text search, debouncing, marker clustering).

🤖 Generated with Claude

Co-Authored-By: AI Assistant <noreply@ai-assistant.com>
This commit is contained in:
William Valentin
2025-11-03 13:21:59 -08:00
parent a2d30385b5
commit 43c2e76070
6 changed files with 1079 additions and 18 deletions

View File

@@ -60,6 +60,8 @@ const LocationMarker = ({ userLocation, setUserLocation }) => {
const MapView = () => {
const { auth } = useContext(AuthContext);
const [streets, setStreets] = useState([]);
const [filteredStreets, setFilteredStreets] = useState([]);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [userLocation, setUserLocation] = useState(null);
@@ -67,19 +69,56 @@ const MapView = () => {
const [adoptingStreetId, setAdoptingStreetId] = useState(null);
const mapRef = useRef();
// Filter states
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [showMyStreets, setShowMyStreets] = useState(false);
const [sortBy, setSortBy] = useState("name");
const [sortOrder, setSortOrder] = useState("asc");
// Default center (can be changed to your city's coordinates)
const defaultCenter = [40.7128, -74.006]; // New York City
useEffect(() => {
loadStreets();
}, []);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchTerm, statusFilter, showMyStreets, sortBy, sortOrder]);
const loadStreets = async () => {
try {
setLoading(true);
setError(null);
const res = await axios.get("/api/streets");
setStreets(res.data);
// Build query parameters
const params = new URLSearchParams();
params.append("limit", "100"); // Load more streets for filtering
if (searchTerm) {
params.append("search", searchTerm);
}
if (statusFilter !== "all") {
params.append("status", statusFilter);
}
if (showMyStreets && auth.user) {
params.append("adoptedBy", auth.user._id);
}
params.append("sort", sortBy);
params.append("order", sortOrder);
const res = await axios.get(`/api/streets?${params.toString()}`);
// Extract data and total count from response
const streetData = res.data.data || res.data;
const total = res.headers["x-total-count"]
? parseInt(res.headers["x-total-count"])
: (res.data.totalDocs || streetData.length);
setStreets(streetData);
setFilteredStreets(streetData);
setTotalCount(total);
} catch (err) {
console.error("Error loading streets:", err);
const errorMessage =
@@ -111,11 +150,20 @@ const MapView = () => {
},
}
);
setStreets(
streets.map((street) => (street._id === id ? res.data : street))
// Update both streets and filteredStreets
const updatedStreet = res.data.street || res.data;
setStreets(prevStreets =>
prevStreets.map((street) => (street._id === id ? updatedStreet : street))
);
setFilteredStreets(prevFiltered =>
prevFiltered.map((street) => (street._id === id ? updatedStreet : street))
);
setSelectedStreet(null);
toast.success("Street adopted successfully!");
// Reload to ensure filters are applied correctly
loadStreets();
} catch (err) {
console.error("Error adopting street:", err);
const errorMessage =
@@ -128,6 +176,18 @@ const MapView = () => {
}
};
const clearFilters = () => {
setSearchTerm("");
setStatusFilter("all");
setShowMyStreets(false);
setSortBy("name");
setSortOrder("asc");
};
const hasActiveFilters = () => {
return searchTerm || statusFilter !== "all" || showMyStreets;
};
const getMarkerIcon = (street) => {
if (
street.adoptedBy &&
@@ -170,9 +230,119 @@ const MapView = () => {
<div>
<h1 className="mb-3">Map View</h1>
{/* Search and Filter Controls */}
<div className="card mb-3">
<div className="card-body">
<div className="row">
{/* Search Bar */}
<div className="col-md-4 col-12 mb-2">
<input
type="text"
className="form-control"
placeholder="Search streets..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
disabled={loading}
/>
</div>
{/* Status Filter */}
<div className="col-md-2 col-6 mb-2">
<select
className="form-control"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
disabled={loading}
>
<option value="all">All Status</option>
<option value="available">Available</option>
<option value="adopted">Adopted</option>
</select>
</div>
{/* Sort By */}
<div className="col-md-2 col-6 mb-2">
<select
className="form-control"
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
disabled={loading}
>
<option value="name">Sort by Name</option>
<option value="adoptedAt">Sort by Date</option>
</select>
</div>
{/* Sort Order */}
<div className="col-md-2 col-6 mb-2">
<select
className="form-control"
value={sortOrder}
onChange={(e) => setSortOrder(e.target.value)}
disabled={loading}
>
<option value="asc">Ascending</option>
<option value="desc">Descending</option>
</select>
</div>
{/* Action Buttons */}
<div className="col-md-2 col-6 mb-2">
{hasActiveFilters() && (
<button
className="btn btn-secondary btn-block"
onClick={clearFilters}
disabled={loading}
>
Clear Filters
</button>
)}
</div>
</div>
{/* My Streets Toggle */}
<div className="row mt-2">
<div className="col-12">
{auth.isAuthenticated && (
<div className="custom-control custom-switch">
<input
type="checkbox"
className="custom-control-input"
id="myStreetsToggle"
checked={showMyStreets}
onChange={(e) => setShowMyStreets(e.target.checked)}
disabled={loading}
/>
<label className="custom-control-label" htmlFor="myStreetsToggle">
Show only my streets
</label>
</div>
)}
</div>
</div>
{/* Result Count */}
<div className="row mt-2">
<div className="col-12">
<small className="text-muted">
{loading ? (
"Loading..."
) : (
<>
Showing {filteredStreets.length} of {totalCount} streets
{hasActiveFilters() && " (filtered)"}
</>
)}
</small>
</div>
</div>
</div>
</div>
{/* Legend */}
<div className="mb-3">
<div className="d-flex align-items-center">
<span className="mr-3">
<div className="d-flex align-items-center flex-wrap">
<span className="mr-3 mb-2">
<img
src="https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-green.png"
alt="Available"
@@ -180,7 +350,7 @@ const MapView = () => {
/>
<small className="ml-1">Available</small>
</span>
<span className="mr-3">
<span className="mr-3 mb-2">
<img
src="https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-blue.png"
alt="Adopted"
@@ -188,7 +358,7 @@ const MapView = () => {
/>
<small className="ml-1">Adopted</small>
</span>
<span className="mr-3">
<span className="mr-3 mb-2">
<img
src="https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-red.png"
alt="My Street"
@@ -216,7 +386,7 @@ const MapView = () => {
setUserLocation={setUserLocation}
/>
{streets.map((street) => {
{filteredStreets.map((street) => {
// Use street coordinates or generate random coordinates for demo
const position = street.coordinates
? [street.coordinates.lat, street.coordinates.lng]
@@ -327,11 +497,22 @@ const MapView = () => {
<div className="mt-3">
<h3>Street List</h3>
{streets.length === 0 ? (
<p className="text-muted">No streets available at the moment.</p>
{loading ? (
<div className="text-center">
<div className="spinner-border spinner-border-sm" role="status">
<span className="sr-only">Loading...</span>
</div>
<span className="ml-2">Loading streets...</span>
</div>
) : filteredStreets.length === 0 ? (
<div className="alert alert-info">
{hasActiveFilters()
? "No streets match your filters. Try adjusting your search criteria."
: "No streets available at the moment."}
</div>
) : (
<ul className="list-group">
{streets.map((street) => (
{filteredStreets.map((street) => (
<li key={street._id} className="list-group-item">
<div className="d-flex justify-content-between align-items-center">
<div>