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 { RideCreditFilters as FilterTypes } from '@/types/ride-credits';
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 { Checkbox } from '@/components/ui/checkbox';
@@ -67,6 +67,48 @@ export function RideCreditFilters({
};
}, [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(() => {
return Math.max(...credits.map(c => c.ride_count), 1);
}, [credits]);
@@ -107,26 +149,22 @@ export function RideCreditFilters({
return (
<div className={compact ? 'p-4' : ''}>
<div className={spacingClass}>
{/* Search Bar - Always Visible */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search rides, parks, manufacturers..."
value={filters.searchQuery || ''}
onChange={(e) => onFilterChange('searchQuery', e.target.value || undefined)}
className="pl-10 pr-10"
/>
{filters.searchQuery && (
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7"
onClick={() => onFilterChange('searchQuery', undefined)}
>
<X className="w-3 h-3" />
</Button>
)}
</div>
{/* Quick Search - Multi-select Combobox */}
<div className={sectionSpacing}>
<Label className="text-sm font-medium">Quick Search</Label>
<MultiSelectCombobox
options={searchOptions}
value={filters.selectedSearchItems || []}
onValueChange={(values) =>
onFilterChange('selectedSearchItems', values.length > 0 ? values : undefined)
}
placeholder="Search for specific rides, parks, or manufacturers..."
searchPlaceholder="Type to search..."
emptyText="No matches found"
maxDisplay={3}
className={compact ? "h-9 text-sm" : ""}
/>
</div>
{/* Quick Category Chips */}
<div className="flex flex-wrap gap-2">
@@ -375,12 +413,21 @@ export function RideCreditFilters({
</div>
<div className="flex flex-wrap gap-2">
{filters.searchQuery && (
<Badge variant="secondary" className="gap-1">
Search: {filters.searchQuery}
<X className="w-3 h-3 cursor-pointer" onClick={() => removeFilter('searchQuery')} />
</Badge>
)}
{filters.selectedSearchItems?.map(item => {
const option = searchOptions.find(opt => opt.value === item);
return option ? (
<Badge key={item} variant="secondary" className="gap-1">
{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 => (
<Badge key={cat} variant="secondary" className="gap-1">
{categoryOptions.find(c => c.value === cat)?.icon} {cat}

View File

@@ -43,7 +43,7 @@ export function useRideCreditFilters(credits: UserRideCredit[]) {
const filteredCredits = useMemo(() => {
let result = [...credits];
// Search filter
// Search filter (text search)
if (debouncedSearchQuery) {
const search = debouncedSearchQuery.toLowerCase();
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
if (filters.categories && filters.categories.length > 0) {
result = result.filter(credit =>

View File

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