From c1aa6c391743a49e6c76d91d3ed36491c7318ec8 Mon Sep 17 00:00:00 2001 From: Peng Steam Date: Thu, 11 Jun 2026 17:14:53 +0800 Subject: [PATCH] feat(providers): add preset search and sorting (#3975) --- .../forms/ProviderPresetSelector.tsx | 233 ++++++++++- src/i18n/locales/en.json | 9 +- src/i18n/locales/ja.json | 9 +- src/i18n/locales/zh-TW.json | 9 +- src/i18n/locales/zh.json | 9 +- .../ProviderPresetSelector.test.tsx | 372 +++++++++++++++--- 6 files changed, 568 insertions(+), 73 deletions(-) diff --git a/src/components/providers/forms/ProviderPresetSelector.tsx b/src/components/providers/forms/ProviderPresetSelector.tsx index aac1674dd..c24c5a87e 100644 --- a/src/components/providers/forms/ProviderPresetSelector.tsx +++ b/src/components/providers/forms/ProviderPresetSelector.tsx @@ -1,7 +1,21 @@ +import { useMemo, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { FormLabel } from "@/components/ui/form"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { ClaudeIcon, CodexIcon, GeminiIcon } from "@/components/BrandIcons"; -import { Zap, Star, Layers, Settings2 } from "lucide-react"; +import { ArrowUpAZ, Search, Zap, Star, Layers, Settings2 } from "lucide-react"; import type { ProviderPreset } from "@/config/claudeProviderPresets"; import type { CodexProviderPreset } from "@/config/codexProviderPresets"; import type { GeminiProviderPreset } from "@/config/geminiProviderPresets"; @@ -16,7 +30,17 @@ import { } from "@/config/universalProviderPresets"; import { ProviderIcon } from "@/components/ProviderIcon"; -type AnyPreset = +type PresetTranslator = (key: string) => unknown; + +export const PresetSortMode = { + Original: "original", + NameAsc: "nameAsc", +} as const; + +export type PresetSortMode = + (typeof PresetSortMode)[keyof typeof PresetSortMode]; + +export type AnyPreset = | ProviderPreset | CodexProviderPreset | GeminiProviderPreset @@ -25,11 +49,91 @@ type AnyPreset = | OpenClawProviderPreset | HermesProviderPreset; -type PresetEntry = { +export type PresetEntry = { id: string; preset: AnyPreset; }; +export function getPresetDisplayName( + preset: AnyPreset, + t: PresetTranslator, +): string { + return preset.nameKey ? String(t(preset.nameKey)) : preset.name; +} + +export function getPresetSearchText( + entry: PresetEntry, + presetCategoryLabels: Record, + t: PresetTranslator, +): string { + const presetCategory = entry.preset.category ?? "others"; + const categoryLabel = + presetCategoryLabels[presetCategory] ?? String(t("providerPreset.other")); + + return [ + getPresetDisplayName(entry.preset, t), + entry.preset.name, + entry.preset.websiteUrl, + categoryLabel, + ] + .join(" ") + .toLowerCase(); +} + +export function filterPresetEntries( + entries: PresetEntry[], + query: string, + presetCategoryLabels: Record, + t: PresetTranslator, +): PresetEntry[] { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) { + return entries; + } + + return entries.filter((entry) => + getPresetSearchText(entry, presetCategoryLabels, t).includes( + normalizedQuery, + ), + ); +} + +export function sortPresetEntries( + entries: PresetEntry[], + sortMode: PresetSortMode, + t: PresetTranslator, +): PresetEntry[] { + if (sortMode === PresetSortMode.Original) { + return [...entries]; + } + + return [...entries].sort((a, b) => + getPresetDisplayName(a.preset, t).localeCompare( + getPresetDisplayName(b.preset, t), + ), + ); +} + +export interface PresetVisibilityOptions { + query: string; + sortMode: PresetSortMode; + presetCategoryLabels: Record; + t: PresetTranslator; +} + +export function getVisiblePresetEntries( + entries: PresetEntry[], + options: PresetVisibilityOptions, +): PresetEntry[] { + const { query, sortMode, presetCategoryLabels, t } = options; + + return sortPresetEntries( + filterPresetEntries(entries, query, presetCategoryLabels, t), + sortMode, + t, + ); +} + interface ProviderPresetSelectorProps { selectedPresetId: string | null; presetEntries: PresetEntry[]; @@ -50,8 +154,24 @@ export function ProviderPresetSelector({ category, }: ProviderPresetSelectorProps) { const { t } = useTranslation(); + const [searchOpen, setSearchOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [sortMode, setSortMode] = useState( + PresetSortMode.Original, + ); - const getCategoryHint = (): React.ReactNode => { + const visiblePresetEntries = useMemo( + () => + getVisiblePresetEntries(presetEntries, { + query: searchQuery, + sortMode, + presetCategoryLabels, + t, + }), + [presetEntries, presetCategoryLabels, searchQuery, sortMode, t], + ); + + const getCategoryHint = (): ReactNode => { switch (category) { case "official": return t("providerForm.officialHint", { @@ -85,6 +205,14 @@ export function ProviderPresetSelector({ } }; + const toggleSortMode = () => { + setSortMode((current) => + current === PresetSortMode.Original + ? PresetSortMode.NameAsc + : PresetSortMode.Original, + ); + }; + const renderPresetIcon = (preset: AnyPreset) => { const iconType = preset.theme?.icon; if (!iconType) return null; @@ -130,7 +258,88 @@ export function ProviderPresetSelector({ return (
- {t("providerPreset.label")} +
+ {t("providerPreset.label")} + +
+ + + + + + + + + {t("providerPreset.searchTooltip", { + defaultValue: "Search presets", + })} + + + + setSearchQuery(event.target.value)} + placeholder={t("providerPreset.searchPlaceholder", { + defaultValue: "Search presets...", + })} + aria-label={t("providerPreset.searchAriaLabel", { + defaultValue: "Search provider presets", + })} + autoFocus + /> + + + + + + + + + {sortMode === PresetSortMode.NameAsc + ? t("providerPreset.sortOriginalTooltip", { + defaultValue: "Restore original order", + }) + : t("providerPreset.sortNameAscTooltip", { + defaultValue: "Sort A-Z", + })} + + +
+
+