diff --git a/src/components/server-catalog/CatalogToolbar.tsx b/src/components/server-catalog/CatalogToolbar.tsx index 86615f4..b7acf77 100644 --- a/src/components/server-catalog/CatalogToolbar.tsx +++ b/src/components/server-catalog/CatalogToolbar.tsx @@ -1,32 +1,49 @@ -import { useId } from "react"; +import { useCallback, useId, useState } from "react"; import { Filter } from "lucide-react"; import { useIntl } from "react-intl"; import { Button } from "@/components/ui/button"; import { CardTag } from "@/components/ui/card-tag"; import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; import { Label } from "@/components/ui/label"; import { ListSearch } from "@/components/ui/list-search"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; + +const ALL_MODE = "all"; +const SELECT_MODE = "select"; + +export interface CatalogFilterDraft { + category: string[]; + provider: string[]; + tags: string[]; +} -const OPEN_AUTH_TYPE = "Open"; -const ALL_FILTER_VALUE = "__all__"; +type CatalogFilterSection = keyof CatalogFilterDraft; +type CatalogSectionMode = typeof ALL_MODE | typeof SELECT_MODE; +type CatalogSectionModes = Record; -export type CatalogSingleFilterKey = "category" | "provider" | "auth_type"; +function getSectionModes(draft: CatalogFilterDraft): CatalogSectionModes { + return { + category: draft.category.length > 0 ? SELECT_MODE : ALL_MODE, + provider: draft.provider.length > 0 ? SELECT_MODE : ALL_MODE, + tags: draft.tags.length > 0 ? SELECT_MODE : ALL_MODE, + }; +} interface CatalogToolbarProps { search: string; installedOnly: boolean; - category: string; - provider: string; - authType: string; + category: string[]; + provider: string[]; selectedTags: string[]; categories: string[]; providers: string[]; @@ -34,9 +51,7 @@ interface CatalogToolbarProps { activeFilterCount: number; onSearchChange: (value: string) => void; onInstalledChange: (installedOnly: boolean) => void; - onSetSingleFilter: (key: CatalogSingleFilterKey, value: string | null) => void; - onToggleTag: (tag: string, checked: boolean) => void; - onClear: () => void; + onApply: (draft: CatalogFilterDraft) => void; } function CatalogViewToggle({ @@ -76,29 +91,140 @@ function CatalogViewToggle({ ); } -function CatalogFiltersPopover({ +function CatalogFilterSectionFields({ + idPrefix, + legendId, + legend, + options, + selected, + mode, + allLabel, + selectLabel, + onModeChange, + onToggle, +}: { + idPrefix: string; + legendId: string; + legend: string; + options: string[]; + selected: string[]; + mode: CatalogSectionMode; + allLabel: string; + selectLabel: string; + onModeChange: (mode: string) => void; + onToggle: (option: string, checked: boolean) => void; +}) { + return ( +
+ + {legend} + + + +
+ + +
+
+ + +
+
+ + {mode === SELECT_MODE && ( + // Multi-column rather than a grid so options read alphabetically down + // each column, as the design lays them out. +
+ {options.map((option, index) => { + const checkboxId = `${idPrefix}-option-${index}`; + return ( +
+ onToggle(option, checked === true)} + /> + +
+ ); + })} +
+ )} +
+ ); +} + +function CatalogFiltersDialog({ category, provider, - authType, selectedTags, categories, providers, availableTags, activeFilterCount, - onSetSingleFilter, - onToggleTag, - onClear, + onApply, }: Omit) { const intl = useIntl(); const id = useId(); - const filtersTitleId = `${id}-title`; - const categoryTriggerId = `${id}-category`; - const providerTriggerId = `${id}-provider`; - const authTriggerId = `${id}-auth`; + const [open, setOpen] = useState(false); + const initialDraft: CatalogFilterDraft = { category, provider, tags: selectedTags }; + const [draft, setDraft] = useState(initialDraft); + const [modes, setModes] = useState(() => getSectionModes(initialDraft)); + + // Seeded only when the dialog opens. The page re-renders on every debounced + // search keystroke, so syncing the draft in an effect would discard edits that + // are still in progress. + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + if (nextOpen) { + const committed: CatalogFilterDraft = { category, provider, tags: selectedTags }; + setDraft(committed); + setModes(getSectionModes(committed)); + } + setOpen(nextOpen); + }, + [category, provider, selectedTags], + ); + + const setSectionMode = useCallback((section: CatalogFilterSection, mode: string) => { + const nextMode: CatalogSectionMode = mode === SELECT_MODE ? SELECT_MODE : ALL_MODE; + setModes((previous) => ({ ...previous, [section]: nextMode })); + // Switching a section back to All clears that section and leaves the others + // untouched. Switching to Select keeps whatever was already ticked. + if (nextMode === ALL_MODE) { + setDraft((previous) => ({ ...previous, [section]: [] })); + } + }, []); + + const toggleSectionOption = useCallback( + (section: CatalogFilterSection, option: string, checked: boolean) => { + // Ticking a box always implies Select mode for that section. + if (checked) setModes((previous) => ({ ...previous, [section]: SELECT_MODE })); + setDraft((previous) => { + const current = previous[section]; + return { + ...previous, + [section]: checked ? [...current, option] : current.filter((item) => item !== option), + }; + }); + }, + [], + ); + + const handleApply = useCallback(() => { + onApply(draft); + setOpen(false); + }, [draft, onApply]); return ( - - + + - - -
-

- {intl.formatMessage({ id: "mcpServer.catalog.filters" })} -

- {activeFilterCount > 0 && ( - - )} -
+ -
- - -
+ + + + + -
- - -
+
+ setSectionMode("provider", mode)} + onToggle={(option, checked) => toggleSectionOption("provider", option, checked)} + /> -
- - + setSectionMode("category", mode)} + onToggle={(option, checked) => toggleSectionOption("category", option, checked)} + /> + + {availableTags.length > 0 && ( + setSectionMode("tags", mode)} + onToggle={(option, checked) => toggleSectionOption("tags", option, checked)} + /> + )}
- {availableTags.length > 0 && ( -
- - {intl.formatMessage({ id: "mcpServer.catalog.tags" })} - -
- {availableTags.map((tag, index) => { - const checkboxId = `${id}-tag-${index}`; - return ( -
- onToggleTag(tag, checked === true)} - /> - -
- ); - })} -
-
- )} - - + + + + + + + + ); } @@ -256,7 +334,7 @@ export function CatalogToolbar({ expandedWidthClassName="w-full sm:w-[432px]" /> - +
); diff --git a/src/i18n/locales/en-US/mcpServer.json b/src/i18n/locales/en-US/mcpServer.json index 89ba9ac..7215ead 100644 --- a/src/i18n/locales/en-US/mcpServer.json +++ b/src/i18n/locales/en-US/mcpServer.json @@ -9,14 +9,19 @@ "mcpServer.catalog.searchLabel": "Search MCP servers", "mcpServer.catalog.filters": "Filters", "mcpServer.catalog.filtersActive": "{count, plural, =0 {Filters} one {Filters, # active} other {Filters, # active}}", - "mcpServer.catalog.clearFilters": "Clear", + "mcpServer.catalog.addFilters": "Add filters", "mcpServer.catalog.category": "Category", "mcpServer.catalog.provider": "Provider", "mcpServer.catalog.authentication": "Authentication", + "mcpServer.catalog.categories": "Categories", + "mcpServer.catalog.providers": "Providers", "mcpServer.catalog.tags": "Tags", - "mcpServer.catalog.allCategories": "All categories", - "mcpServer.catalog.allProviders": "All providers", - "mcpServer.catalog.allAuthTypes": "All authentication types", + "mcpServer.catalog.allCategoriesOption": "All", + "mcpServer.catalog.selectCategories": "Select...", + "mcpServer.catalog.allProvidersOption": "All", + "mcpServer.catalog.selectProviders": "Select...", + "mcpServer.catalog.allTagsOption": "All", + "mcpServer.catalog.selectTags": "Select...", "mcpServer.catalog.connected": "Connected", "mcpServer.catalog.notConnected": "Not connected", "mcpServer.catalog.view": "View", diff --git a/src/i18n/locales/es-ES/mcpServer.json b/src/i18n/locales/es-ES/mcpServer.json index ae04dba..5d7d329 100644 --- a/src/i18n/locales/es-ES/mcpServer.json +++ b/src/i18n/locales/es-ES/mcpServer.json @@ -9,14 +9,19 @@ "mcpServer.catalog.searchLabel": "Buscar servidores MCP", "mcpServer.catalog.filters": "Filtros", "mcpServer.catalog.filtersActive": "{count, plural, =0 {Filtros} one {Filtros, # activo} other {Filtros, # activos}}", - "mcpServer.catalog.clearFilters": "Limpiar", + "mcpServer.catalog.addFilters": "Añadir filtros", "mcpServer.catalog.category": "Categoría", "mcpServer.catalog.provider": "Proveedor", "mcpServer.catalog.authentication": "Autenticación", + "mcpServer.catalog.categories": "Categorías", + "mcpServer.catalog.providers": "Proveedores", "mcpServer.catalog.tags": "Etiquetas", - "mcpServer.catalog.allCategories": "Todas las categorías", - "mcpServer.catalog.allProviders": "Todos los proveedores", - "mcpServer.catalog.allAuthTypes": "Todos los tipos de autenticación", + "mcpServer.catalog.allCategoriesOption": "Todas", + "mcpServer.catalog.selectCategories": "Seleccionar...", + "mcpServer.catalog.allProvidersOption": "Todos", + "mcpServer.catalog.selectProviders": "Seleccionar...", + "mcpServer.catalog.allTagsOption": "Todas", + "mcpServer.catalog.selectTags": "Seleccionar...", "mcpServer.catalog.connected": "Conectado", "mcpServer.catalog.notConnected": "No conectado", "mcpServer.catalog.view": "Ver", diff --git a/src/i18n/locales/pt-BR/mcpServer.json b/src/i18n/locales/pt-BR/mcpServer.json index 6d5a4bc..5eaa49f 100644 --- a/src/i18n/locales/pt-BR/mcpServer.json +++ b/src/i18n/locales/pt-BR/mcpServer.json @@ -9,14 +9,19 @@ "mcpServer.catalog.searchLabel": "Pesquisar servidores MCP", "mcpServer.catalog.filters": "Filtros", "mcpServer.catalog.filtersActive": "{count, plural, =0 {Filtros} one {Filtros, # ativo} other {Filtros, # ativos}}", - "mcpServer.catalog.clearFilters": "Limpar", + "mcpServer.catalog.addFilters": "Adicionar filtros", "mcpServer.catalog.category": "Categoria", "mcpServer.catalog.provider": "Provedor", "mcpServer.catalog.authentication": "Autenticação", + "mcpServer.catalog.categories": "Categorias", + "mcpServer.catalog.providers": "Provedores", "mcpServer.catalog.tags": "Tags", - "mcpServer.catalog.allCategories": "Todas as categorias", - "mcpServer.catalog.allProviders": "Todos os provedores", - "mcpServer.catalog.allAuthTypes": "Todos os tipos de autenticação", + "mcpServer.catalog.allCategoriesOption": "Todas", + "mcpServer.catalog.selectCategories": "Selecionar...", + "mcpServer.catalog.allProvidersOption": "Todos", + "mcpServer.catalog.selectProviders": "Selecionar...", + "mcpServer.catalog.allTagsOption": "Todas", + "mcpServer.catalog.selectTags": "Selecionar...", "mcpServer.catalog.connected": "Conectado", "mcpServer.catalog.notConnected": "Não conectado", "mcpServer.catalog.view": "Ver", diff --git a/src/pages/ServerCatalog.test.tsx b/src/pages/ServerCatalog.test.tsx index d1a8551..92e8eec 100644 --- a/src/pages/ServerCatalog.test.tsx +++ b/src/pages/ServerCatalog.test.tsx @@ -73,6 +73,31 @@ function queryResult(overrides: Partial> = {}) { } as ReturnType; } +type UserEvent = ReturnType; + +function getFilterSection(name: string): HTMLElement { + return screen.getByRole("group", { name }); +} + +async function openFilters(user: UserEvent) { + await user.click(screen.getByRole("button", { name: /^Filters(, \d+ active)?$/ })); +} + +async function applyFilters(user: UserEvent) { + const dialog = screen.getByRole("dialog", { name: "Add filters" }); + await user.click(within(dialog).getByRole("button", { name: "Add filters" })); +} + +// Sections start in All mode; ticking an option requires switching to Select first. +async function selectSectionOption(user: UserEvent, section: string, option: string) { + const fields = getFilterSection(section); + const selectRadio = within(fields).getByRole("radio", { name: "Select..." }); + if (selectRadio.getAttribute("aria-checked") !== "true") { + await user.click(selectRadio); + } + await user.click(within(getFilterSection(section)).getByRole("checkbox", { name: option })); +} + function renderWithRouter(ui: ReactElement, path = "/app/server-catalog") { window.history.pushState({}, "", path); return render( @@ -231,24 +256,62 @@ describe("ServerCatalog", () => { const user = userEvent.setup(); renderWithRouter(); - await user.click(screen.getByRole("button", { name: /^Filters$/ })); - expect(screen.getByRole("dialog", { name: "Filters" })).toBeInTheDocument(); - await user.click(screen.getByRole("combobox", { name: "Category" })); - expect(screen.queryByRole("option", { name: "Security" })).not.toBeInTheDocument(); - await user.click(screen.getByRole("option", { name: "Productivity" })); + await openFilters(user); + expect(screen.getByRole("dialog", { name: "Add filters" })).toBeInTheDocument(); + + await selectSectionOption(user, "Categories", "Productivity"); + expect(screen.queryByRole("checkbox", { name: "Security" })).not.toBeInTheDocument(); + + await applyFilters(user); await waitFor(() => expect(window.location.search).toContain("category=Productivity")); expect(screen.getByRole("heading", { name: "Public Notes" })).toBeInTheDocument(); expect(screen.queryByRole("heading", { name: "Globalping" })).not.toBeInTheDocument(); }); + it("leaves the URL and results untouched until filters are applied", async () => { + const user = userEvent.setup(); + renderWithRouter(); + + await openFilters(user); + await selectSectionOption(user, "Categories", "Productivity"); + + // The open modal marks the page behind it aria-hidden, so the grid has to be + // queried with hidden: true while the draft is still uncommitted. + expect(window.location.search).not.toContain("category"); + expect(screen.getByRole("heading", { name: "Globalping", hidden: true })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Public Notes", hidden: true })).toBeInTheDocument(); + }); + + it("discards the draft when the dialog is cancelled or closed", async () => { + const user = userEvent.setup(); + renderWithRouter(); + + await openFilters(user); + await selectSectionOption(user, "Categories", "Productivity"); + await user.click(screen.getByRole("button", { name: "Cancel" })); + + expect(window.location.search).not.toContain("category"); + expect(screen.getByRole("heading", { name: "Globalping" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Public Notes" })).toBeInTheDocument(); + + await openFilters(user); + await selectSectionOption(user, "Categories", "Productivity"); + await user.click(screen.getByRole("button", { name: "Close" })); + + expect(window.location.search).not.toContain("category"); + expect(screen.getByRole("heading", { name: "Globalping" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Public Notes" })).toBeInTheDocument(); + }); + it("supports repeatable OR tag filters", async () => { const user = userEvent.setup(); renderWithRouter(); - await user.click(screen.getByRole("button", { name: /^Filters$/ })); - await user.click(screen.getByRole("checkbox", { name: "network" })); - await user.click(screen.getByRole("checkbox", { name: "documents" })); + await openFilters(user); + await selectSectionOption(user, "Tags", "network"); + await selectSectionOption(user, "Tags", "documents"); + await applyFilters(user); const params = new URLSearchParams(window.location.search); expect(params.getAll("tags")).toEqual(["network", "documents"]); @@ -261,38 +324,120 @@ describe("ServerCatalog", () => { const user = userEvent.setup(); renderWithRouter(); - await user.click(screen.getByRole("button", { name: /^Filters$/ })); + await openFilters(user); + await user.click(within(getFilterSection("Tags")).getByRole("radio", { name: "Select..." })); expect(screen.queryByRole("checkbox", { name: "security" })).not.toBeInTheDocument(); - await user.click(screen.getByRole("combobox", { name: "Provider" })); - expect(screen.queryByRole("option", { name: "SecureCo" })).not.toBeInTheDocument(); + await user.click( + within(getFilterSection("Providers")).getByRole("radio", { name: "Select..." }), + ); + expect(screen.queryByRole("checkbox", { name: "SecureCo" })).not.toBeInTheDocument(); }); - it("filters by provider and auth type, then clears filters", async () => { + it("filters by several providers at once", async () => { const user = userEvent.setup(); renderWithRouter(); - await user.click(screen.getByRole("button", { name: /^Filters$/ })); - await user.click(screen.getByRole("combobox", { name: "Provider" })); - await user.click(screen.getByRole("option", { name: "jsDelivr" })); - await user.click(screen.getByRole("combobox", { name: "Authentication" })); - await user.click(screen.getByRole("option", { name: "Open" })); + await openFilters(user); + await selectSectionOption(user, "Providers", "jsDelivr"); + await applyFilters(user); let params = new URLSearchParams(window.location.search); - expect(params.get("provider")).toBe("jsDelivr"); - expect(params.get("auth_type")).toBe("Open"); + expect(params.getAll("provider")).toEqual(["jsDelivr"]); expect(screen.getByRole("heading", { name: "Globalping" })).toBeInTheDocument(); expect(screen.queryByRole("heading", { name: "Public Notes" })).not.toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Clear" })); + await openFilters(user); + await selectSectionOption(user, "Providers", "Example"); + await applyFilters(user); params = new URLSearchParams(window.location.search); - expect(params.has("provider")).toBe(false); - expect(params.has("auth_type")).toBe(false); + expect(params.getAll("provider")).toEqual(["jsDelivr", "Example"]); expect(screen.getByRole("heading", { name: "Globalping" })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "Public Notes" })).toBeInTheDocument(); }); + it("restores repeated category and provider params from the URL", async () => { + const user = userEvent.setup(); + renderWithRouter( + , + "/app/server-catalog?category=Monitoring&category=Productivity&provider=jsDelivr", + ); + + expect(screen.getByRole("button", { name: "Filters, 3 active" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Globalping" })).toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "Public Notes" })).not.toBeInTheDocument(); + + await openFilters(user); + expect( + within(getFilterSection("Categories")).getByRole("checkbox", { name: "Monitoring" }), + ).toBeChecked(); + expect( + within(getFilterSection("Categories")).getByRole("checkbox", { name: "Productivity" }), + ).toBeChecked(); + expect( + within(getFilterSection("Providers")).getByRole("checkbox", { name: "jsDelivr" }), + ).toBeChecked(); + expect( + within(getFilterSection("Providers")).getByRole("checkbox", { name: "Example" }), + ).not.toBeChecked(); + }); + + it("clears only the section switched back to All", async () => { + const user = userEvent.setup(); + renderWithRouter(, "/app/server-catalog?provider=jsDelivr&tags=network"); + + await openFilters(user); + await user.click(within(getFilterSection("Providers")).getByRole("radio", { name: "All" })); + await applyFilters(user); + + const params = new URLSearchParams(window.location.search); + expect(params.has("provider")).toBe(false); + expect(params.getAll("tags")).toEqual(["network"]); + expect(screen.getByRole("button", { name: "Filters, 1 active" })).toBeInTheDocument(); + }); + + it("shows no active filter count on a fresh page", () => { + renderWithRouter(); + + expect(screen.getByRole("button", { name: /^Filters$/ })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Filters, \d+ active/ })).not.toBeInTheDocument(); + }); + + it("applies every filter section in a single history entry", async () => { + const user = userEvent.setup(); + renderWithRouter(); + const replaceState = vi.spyOn(window.history, "replaceState"); + + await openFilters(user); + await selectSectionOption(user, "Providers", "jsDelivr"); + await selectSectionOption(user, "Categories", "Monitoring"); + await selectSectionOption(user, "Tags", "network"); + expect(replaceState).not.toHaveBeenCalled(); + + await applyFilters(user); + expect(replaceState).toHaveBeenCalledTimes(1); + + replaceState.mockRestore(); + }); + + it("drops a legacy auth_type param on any navigation", async () => { + const user = userEvent.setup(); + renderWithRouter(, "/app/server-catalog?auth_type=Open"); + + await openFilters(user); + await selectSectionOption(user, "Providers", "jsDelivr"); + await applyFilters(user); + + expect(new URLSearchParams(window.location.search).has("auth_type")).toBe(false); + + renderWithRouter(, "/app/server-catalog?auth_type=Open"); + await user.type(screen.getAllByRole("searchbox", { name: "Search MCP servers" })[0], "notes"); + + await waitFor(() => expect(window.location.search).toContain("search=notes")); + expect(new URLSearchParams(window.location.search).has("auth_type")).toBe(false); + }); + it("shows explicit disabled and generic error states with retry", async () => { const user = userEvent.setup(); mockUseQuery.mockReturnValue( diff --git a/src/pages/ServerCatalog.tsx b/src/pages/ServerCatalog.tsx index 19264f5..b982b6f 100644 --- a/src/pages/ServerCatalog.tsx +++ b/src/pages/ServerCatalog.tsx @@ -8,7 +8,7 @@ import { } from "@/components/server-catalog/CatalogResults"; import { CatalogToolbar, - type CatalogSingleFilterKey, + type CatalogFilterDraft, } from "@/components/server-catalog/CatalogToolbar"; import { EmptyStatePlaceholder } from "@/components/dashboard/EmptyStatePlaceholder"; import { Button } from "@/components/ui/button"; @@ -27,9 +27,8 @@ const PAGE_HEADING_ID = "server-catalog-heading"; interface CatalogFilters { search: string; - category: string; - provider: string; - authType: string; + category: string[]; + provider: string[]; tags: string[]; installedOnly: boolean; } @@ -41,15 +40,18 @@ function getQuery(path: string): string { return queryIndex === -1 ? "" : path.slice(queryIndex + 1); } +function readMulti(params: URLSearchParams, key: string): string[] { + return [...new Set(params.getAll(key).filter(Boolean))]; +} + function parseFilters(path: string): CatalogFilters { const params = new URLSearchParams(getQuery(path)); return { search: params.get("search") ?? "", - category: params.get("category") ?? "", - provider: params.get("provider") ?? "", - authType: params.get("auth_type") === OPEN_AUTH_TYPE ? OPEN_AUTH_TYPE : "", - tags: [...new Set(params.getAll("tags").filter(Boolean))], + category: readMulti(params, "category"), + provider: readMulti(params, "provider"), + tags: readMulti(params, "tags"), installedOnly: params.get("show_registered_only") === "true", }; } @@ -73,31 +75,30 @@ function useCatalogFilters() { } }); + // Transitional: the auth type filter was removed while the catalog only + // offers Open servers. Drop any auth_type left over in existing URLs so it + // cannot survive later navigations. Remove once auth types ship again. + params.delete("auth_type"); + const query = params.toString(); navigate(query ? `${PAGE_PATH}?${query}` : PAGE_PATH, { replace: true }); }, [navigate, path], ); - const setSingleFilter = useCallback( - (key: CatalogSingleFilterKey, value: string | null) => updateQuery({ [key]: value }), - [updateQuery], - ); - - const toggleTag = useCallback( - (tag: string, checked: boolean) => + // Commits every dialog filter in a single navigation so applying filters adds + // exactly one history entry. + const applyFilters = useCallback( + (draft: CatalogFilterDraft) => updateQuery({ - tags: checked ? [...filters.tags, tag] : filters.tags.filter((item) => item !== tag), + category: draft.category, + provider: draft.provider, + tags: draft.tags, }), - [filters.tags, updateQuery], - ); - - const clearFilters = useCallback( - () => updateQuery({ category: null, provider: null, auth_type: null, tags: [] }), [updateQuery], ); - return { filters, updateQuery, setSingleFilter, toggleTag, clearFilters }; + return { filters, updateQuery, applyFilters }; } function getOpenServers(servers: CatalogServer[]): CatalogServer[] { @@ -109,9 +110,12 @@ function filterOpenServers(openServers: CatalogServer[], filters: CatalogFilters const search = filters.search.trim().toLocaleLowerCase(); return openServers.filter((server) => { - if (filters.authType && server.auth_type !== filters.authType) return false; - if (filters.category && server.category !== filters.category) return false; - if (filters.provider && server.provider !== filters.provider) return false; + if (filters.category.length > 0 && !filters.category.includes(server.category ?? "")) { + return false; + } + if (filters.provider.length > 0 && !filters.provider.includes(server.provider ?? "")) { + return false; + } if (filters.installedOnly && !server.is_registered) return false; if (filters.tags.length > 0 && !filters.tags.some((tag) => server.tags?.includes(tag))) { return false; @@ -144,7 +148,7 @@ export function ServerCatalog() { const [selectedServer, setSelectedServer] = useState(null); const lastViewTriggerRef = useRef(null); const { data, error, isLoading, refetch } = useQuery(CATALOG_PATH); - const { filters, updateQuery, setSingleFilter, toggleTag, clearFilters } = useCatalogFilters(); + const { filters, updateQuery, applyFilters } = useCatalogFilters(); const [search, setSearch] = useState(filters.search); const debouncedSearch = useDebouncedValue(search, 300); @@ -158,6 +162,9 @@ export function ServerCatalog() { } }, [debouncedSearch, filters.search, updateQuery]); + // Only the debounced search box filters ahead of the URL. Category, provider + // and tag selections stay committed here: the dialog holds them as a draft + // until Add filters is pressed, so an unapplied draft must never reach the grid. const activeFilters = useMemo(() => ({ ...filters, search }), [filters, search]); const openServers = useMemo(() => getOpenServers(data?.servers ?? []), [data?.servers]); @@ -184,11 +191,7 @@ export function ServerCatalog() { : filters.installedOnly && !hasConnectedServers ? "mcpServer.catalog.noneConnected" : "mcpServer.catalog.noResults"; - const activeFilterCount = - Number(Boolean(filters.category)) + - Number(Boolean(filters.provider)) + - Number(Boolean(filters.authType)) + - filters.tags.length; + const activeFilterCount = filters.category.length + filters.provider.length + filters.tags.length; const handleView = useCallback((server: CatalogServer, trigger: HTMLButtonElement) => { lastViewTriggerRef.current = trigger; @@ -249,7 +252,6 @@ export function ServerCatalog() { installedOnly={filters.installedOnly} category={filters.category} provider={filters.provider} - authType={filters.authType} selectedTags={filters.tags} categories={categoryOptions} providers={providerOptions} @@ -257,9 +259,7 @@ export function ServerCatalog() { activeFilterCount={activeFilterCount} onSearchChange={setSearch} onInstalledChange={(installedOnly) => updateQuery({ show_registered_only: installedOnly })} - onSetSingleFilter={setSingleFilter} - onToggleTag={toggleTag} - onClear={clearFilters} + onApply={applyFilters} />