Implement multi-select search combobox

This commit is contained in:
gpt-engineer-app[bot]
2025-10-16 15:56:19 +00:00
parent d9e05125fe
commit 294019f7bd
3 changed files with 91 additions and 28 deletions

View File

@@ -7,7 +7,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component
import { MultiSelectCombobox } from '@/components/ui/multi-select-combobox'; import { MultiSelectCombobox } from '@/components/ui/multi-select-combobox';
import { RideCreditFilters as FilterTypes } from '@/types/ride-credits'; import { RideCreditFilters as FilterTypes } from '@/types/ride-credits';
import { UserRideCredit } from '@/types/database'; import { UserRideCredit } from '@/types/database';
import { Search, X, ChevronDown, MapPin, Building2, Factory, Calendar, Star, MessageSquare, Image } from 'lucide-react'; import { X, ChevronDown, MapPin, Building2, Factory, Calendar, Star, MessageSquare, Image } from 'lucide-react';
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo } from 'react';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
@@ -67,6 +67,48 @@ export function RideCreditFilters({
}; };
}, [credits]); }, [credits]);
const searchOptions = useMemo(() => {
const rides = new Map<string, { name: string, park: string }>();
const parks = new Map<string, string>();
const manufacturers = new Map<string, string>();
credits.forEach(credit => {
// Rides with their park context
if (credit.rides?.id && credit.rides?.name) {
rides.set(credit.rides.id, {
name: credit.rides.name,
park: credit.rides.parks?.name || 'Unknown Park'
});
}
// Parks
if (credit.rides?.parks?.id && credit.rides?.parks?.name) {
parks.set(credit.rides.parks.id, credit.rides.parks.name);
}
// Manufacturers
if (credit.rides?.manufacturer?.id && credit.rides?.manufacturer?.name) {
manufacturers.set(credit.rides.manufacturer.id, credit.rides.manufacturer.name);
}
});
// Combine into single options array with type prefixes
return [
...Array.from(rides.entries()).map(([id, data]) => ({
label: `🎢 ${data.name} (at ${data.park})`,
value: `ride:${id}`,
})),
...Array.from(parks.entries()).map(([id, name]) => ({
label: `🏰 ${name}`,
value: `park:${id}`,
})),
...Array.from(manufacturers.entries()).map(([id, name]) => ({
label: `🏭 ${name}`,
value: `manufacturer:${id}`,
}))
].sort((a, b) => a.label.localeCompare(b.label));
}, [credits]);
const maxRideCount = useMemo(() => { const maxRideCount = useMemo(() => {
return Math.max(...credits.map(c => c.ride_count), 1); return Math.max(...credits.map(c => c.ride_count), 1);
}, [credits]); }, [credits]);
@@ -107,26 +149,22 @@ export function RideCreditFilters({
return ( return (
<div className={compact ? 'p-4' : ''}> <div className={compact ? 'p-4' : ''}>
<div className={spacingClass}> <div className={spacingClass}>
{/* Search Bar - Always Visible */} {/* Quick Search - Multi-select Combobox */}
<div className="relative"> <div className={sectionSpacing}>
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <Label className="text-sm font-medium">Quick Search</Label>
<Input <MultiSelectCombobox
placeholder="Search rides, parks, manufacturers..." options={searchOptions}
value={filters.searchQuery || ''} value={filters.selectedSearchItems || []}
onChange={(e) => onFilterChange('searchQuery', e.target.value || undefined)} onValueChange={(values) =>
className="pl-10 pr-10" onFilterChange('selectedSearchItems', values.length > 0 ? values : undefined)
/> }
{filters.searchQuery && ( placeholder="Search for specific rides, parks, or manufacturers..."
<Button searchPlaceholder="Type to search..."
variant="ghost" emptyText="No matches found"
size="icon" maxDisplay={3}
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7" className={compact ? "h-9 text-sm" : ""}
onClick={() => onFilterChange('searchQuery', undefined)} />
> </div>
<X className="w-3 h-3" />
</Button>
)}
</div>
{/* Quick Category Chips */} {/* Quick Category Chips */}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -375,12 +413,21 @@ export function RideCreditFilters({
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{filters.searchQuery && ( {filters.selectedSearchItems?.map(item => {
<Badge variant="secondary" className="gap-1"> const option = searchOptions.find(opt => opt.value === item);
Search: {filters.searchQuery} return option ? (
<X className="w-3 h-3 cursor-pointer" onClick={() => removeFilter('searchQuery')} /> <Badge key={item} variant="secondary" className="gap-1">
</Badge> {option.label}
)} <X
className="w-3 h-3 cursor-pointer"
onClick={() => {
const updated = filters.selectedSearchItems!.filter(i => i !== item);
onFilterChange('selectedSearchItems', updated.length > 0 ? updated : undefined);
}}
/>
</Badge>
) : null;
})}
{filters.categories?.map(cat => ( {filters.categories?.map(cat => (
<Badge key={cat} variant="secondary" className="gap-1"> <Badge key={cat} variant="secondary" className="gap-1">
{categoryOptions.find(c => c.value === cat)?.icon} {cat} {categoryOptions.find(c => c.value === cat)?.icon} {cat}

View File

@@ -43,7 +43,7 @@ export function useRideCreditFilters(credits: UserRideCredit[]) {
const filteredCredits = useMemo(() => { const filteredCredits = useMemo(() => {
let result = [...credits]; let result = [...credits];
// Search filter // Search filter (text search)
if (debouncedSearchQuery) { if (debouncedSearchQuery) {
const search = debouncedSearchQuery.toLowerCase(); const search = debouncedSearchQuery.toLowerCase();
result = result.filter(credit => result = result.filter(credit =>
@@ -52,6 +52,21 @@ export function useRideCreditFilters(credits: UserRideCredit[]) {
); );
} }
// Selected search items filter (multi-select combobox)
if (filters.selectedSearchItems && filters.selectedSearchItems.length > 0) {
result = result.filter(credit => {
return filters.selectedSearchItems!.some(item => {
const [type, id] = item.split(':');
if (type === 'ride' && credit.rides?.id === id) return true;
if (type === 'park' && credit.rides?.parks?.id === id) return true;
if (type === 'manufacturer' && credit.rides?.manufacturer?.id === id) return true;
return false;
});
});
}
// Categories // Categories
if (filters.categories && filters.categories.length > 0) { if (filters.categories && filters.categories.length > 0) {
result = result.filter(credit => result = result.filter(credit =>

View File

@@ -1,6 +1,7 @@
export interface RideCreditFilters { export interface RideCreditFilters {
// Search // Search
searchQuery?: string; searchQuery?: string;
selectedSearchItems?: string[]; // Format: "ride:id", "park:id", "manufacturer:id"
// Category (Multi-select) // Category (Multi-select)
categories?: string[]; categories?: string[];