[Frontend] Marc/frontend granular city/state geo proxy (#4156)
This commit is contained in:
207
skyvern-frontend/src/components/GeoTargetSelector.tsx
Normal file
207
skyvern-frontend/src/components/GeoTargetSelector.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import * as React from "react";
|
||||
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
|
||||
import { cn } from "@/util/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { GeoTarget } from "@/api/types";
|
||||
import { formatGeoTargetCompact } from "@/util/geoData";
|
||||
import {
|
||||
GroupedSearchResults,
|
||||
searchGeoData,
|
||||
SearchResultItem,
|
||||
} from "@/util/geoSearch";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
|
||||
interface GeoTargetSelectorProps {
|
||||
value: GeoTarget | null;
|
||||
onChange: (value: GeoTarget) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function GeoTargetSelector({
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}: GeoTargetSelectorProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [results, setResults] = React.useState<GroupedSearchResults>({
|
||||
countries: [],
|
||||
subdivisions: [],
|
||||
cities: [],
|
||||
});
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
|
||||
const handleSearch = useDebouncedCallback(async (searchQuery: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await searchGeoData(searchQuery);
|
||||
setResults(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to search geo data", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
// Initial load of countries
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
handleSearch("");
|
||||
}
|
||||
}, [open, handleSearch]);
|
||||
|
||||
const onInput = (val: string) => {
|
||||
setQuery(val);
|
||||
handleSearch(val);
|
||||
};
|
||||
|
||||
const handleSelect = (item: SearchResultItem) => {
|
||||
onChange(item.value);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const isSelected = (itemValue: GeoTarget) => {
|
||||
if (!value) return false;
|
||||
return (
|
||||
value.country === itemValue.country &&
|
||||
value.subdivision === itemValue.subdivision &&
|
||||
value.city === itemValue.city
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn("w-full justify-between", className)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{value ? formatGeoTargetCompact(value) : "Select proxy location..."}
|
||||
</span>
|
||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[400px] p-0" align="start">
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder="Search country, state, or city..."
|
||||
value={query}
|
||||
onValueChange={onInput}
|
||||
/>
|
||||
<CommandList>
|
||||
{loading && (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
{!loading &&
|
||||
results.countries.length === 0 &&
|
||||
results.subdivisions.length === 0 &&
|
||||
results.cities.length === 0 && (
|
||||
<CommandEmpty>No location found.</CommandEmpty>
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<>
|
||||
{results.countries.length > 0 && (
|
||||
<CommandGroup heading="Countries">
|
||||
{results.countries.map((item) => (
|
||||
<CommandItem
|
||||
key={`country-${item.value.country}`}
|
||||
value={JSON.stringify(item.value)}
|
||||
onSelect={() => handleSelect(item)}
|
||||
>
|
||||
<span className="mr-2 text-lg">{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
isSelected(item.value)
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{results.subdivisions.length > 0 && (
|
||||
<CommandGroup heading="States / Regions">
|
||||
{results.subdivisions.map((item) => (
|
||||
<CommandItem
|
||||
key={`sub-${item.value.country}-${item.value.subdivision}`}
|
||||
value={JSON.stringify(item.value)}
|
||||
onSelect={() => handleSelect(item)}
|
||||
>
|
||||
<span className="mr-2 text-lg">{item.icon}</span>
|
||||
<div className="flex flex-col">
|
||||
<span>{item.label}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{item.description}
|
||||
</span>
|
||||
</div>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
isSelected(item.value)
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{results.cities.length > 0 && (
|
||||
<CommandGroup heading="Cities">
|
||||
{results.cities.map((item) => (
|
||||
<CommandItem
|
||||
key={`city-${item.value.country}-${item.value.subdivision}-${item.value.city}`}
|
||||
value={JSON.stringify(item.value)}
|
||||
onSelect={() => handleSelect(item)}
|
||||
>
|
||||
<span className="mr-2 text-lg">{item.icon}</span>
|
||||
<div className="flex flex-col">
|
||||
<span>{item.label}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{item.description}
|
||||
</span>
|
||||
</div>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
isSelected(item.value)
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,82 +1,31 @@
|
||||
import { ProxyLocation } from "@/api/types";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "./ui/select";
|
||||
geoTargetToProxyLocationInput,
|
||||
proxyLocationToGeoTarget,
|
||||
} from "@/util/geoData";
|
||||
import { GeoTargetSelector } from "./GeoTargetSelector";
|
||||
|
||||
type Props = {
|
||||
value: ProxyLocation | null;
|
||||
value: ProxyLocation;
|
||||
onChange: (value: ProxyLocation) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function ProxySelector({ value, onChange, className }: Props) {
|
||||
// Convert input (string enum or object) to GeoTarget for the selector
|
||||
const geoTargetValue = proxyLocationToGeoTarget(value);
|
||||
|
||||
return (
|
||||
<Select value={value ?? ""} onValueChange={onChange}>
|
||||
<SelectTrigger className={className}>
|
||||
<SelectValue placeholder="Proxy Location" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ProxyLocation.Residential}>Residential</SelectItem>
|
||||
<SelectItem value={ProxyLocation.ResidentialISP}>
|
||||
Residential ISP (US)
|
||||
</SelectItem>
|
||||
<SelectItem value={ProxyLocation.ResidentialAR}>
|
||||
Residential (Argentina)
|
||||
</SelectItem>
|
||||
<SelectItem value={ProxyLocation.ResidentialAU}>
|
||||
Residential (Australia)
|
||||
</SelectItem>
|
||||
<SelectItem value={ProxyLocation.ResidentialBR}>
|
||||
Residential (Brazil)
|
||||
</SelectItem>
|
||||
<SelectItem value={ProxyLocation.ResidentialCA}>
|
||||
Residential (Canada)
|
||||
</SelectItem>
|
||||
<SelectItem value={ProxyLocation.ResidentialFR}>
|
||||
Residential (France)
|
||||
</SelectItem>
|
||||
<SelectItem value={ProxyLocation.ResidentialDE}>
|
||||
Residential (Germany)
|
||||
</SelectItem>
|
||||
<SelectItem value={ProxyLocation.ResidentialIN}>
|
||||
Residential (India)
|
||||
</SelectItem>
|
||||
<SelectItem value={ProxyLocation.ResidentialIE}>
|
||||
Residential (Ireland)
|
||||
</SelectItem>
|
||||
<SelectItem value={ProxyLocation.ResidentialIT}>
|
||||
Residential (Italy)
|
||||
</SelectItem>
|
||||
<SelectItem value={ProxyLocation.ResidentialJP}>
|
||||
Residential (Japan)
|
||||
</SelectItem>
|
||||
<SelectItem value={ProxyLocation.ResidentialMX}>
|
||||
Residential (Mexico)
|
||||
</SelectItem>
|
||||
<SelectItem value={ProxyLocation.ResidentialNL}>
|
||||
Residential (Netherlands)
|
||||
</SelectItem>
|
||||
<SelectItem value={ProxyLocation.ResidentialNZ}>
|
||||
Residential (New Zealand)
|
||||
</SelectItem>
|
||||
<SelectItem value={ProxyLocation.ResidentialZA}>
|
||||
Residential (South Africa)
|
||||
</SelectItem>
|
||||
<SelectItem value={ProxyLocation.ResidentialES}>
|
||||
Residential (Spain)
|
||||
</SelectItem>
|
||||
<SelectItem value={ProxyLocation.ResidentialTR}>
|
||||
Residential (Turkey)
|
||||
</SelectItem>
|
||||
<SelectItem value={ProxyLocation.ResidentialGB}>
|
||||
Residential (United Kingdom)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<GeoTargetSelector
|
||||
className={className}
|
||||
value={geoTargetValue}
|
||||
onChange={(newTarget) => {
|
||||
// Convert back to ProxyLocation enum if possible (for simple countries)
|
||||
// or keep as GeoTarget object
|
||||
const newValue = geoTargetToProxyLocationInput(newTarget);
|
||||
onChange(newValue);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user