From 5bfe3472b7d1042b1601ad6d95845deef97afa03 Mon Sep 17 00:00:00 2001 From: Pratik Gandhi Date: Wed, 12 Aug 2026 16:28:03 +0100 Subject: [PATCH 1/2] feat: move catalog filters into a dialog Replace the server catalog filter popover with the "Add filters" dialog from the design, and drop the authentication type selector while the catalog only offers Open servers. Providers, categories and tags each become an All / Select radio pair over a checkbox grid, so category and provider are now multi-valued and read from repeatable query params. Selections are held as a draft inside the dialog and committed in a single navigation when Add filters is pressed; Cancel and the close button discard them. Implements IBM/mcp-context-forge#6179 Signed-off-by: Pratik Gandhi --- .../server-catalog/CatalogToolbar.tsx | 352 +++++++++++------- src/i18n/locales/en-US/mcpServer.json | 13 +- src/i18n/locales/es-ES/mcpServer.json | 13 +- src/i18n/locales/pt-BR/mcpServer.json | 13 +- src/pages/ServerCatalog.test.tsx | 189 ++++++++-- src/pages/ServerCatalog.tsx | 70 ++-- 6 files changed, 443 insertions(+), 207 deletions(-) diff --git a/src/components/server-catalog/CatalogToolbar.tsx b/src/components/server-catalog/CatalogToolbar.tsx index 86615f4..54e4774 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,138 @@ 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 && ( +
+ {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 +332,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} /> Date: Wed, 12 Aug 2026 17:28:22 +0100 Subject: [PATCH 2/2] fix: flow catalog filter options down columns The design lists providers, categories and tags alphabetically down each column. A CSS grid fills across rows instead, so switch the option lists to multi-column flow. Signed-off-by: Pratik Gandhi --- src/components/server-catalog/CatalogToolbar.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/server-catalog/CatalogToolbar.tsx b/src/components/server-catalog/CatalogToolbar.tsx index 54e4774..b7acf77 100644 --- a/src/components/server-catalog/CatalogToolbar.tsx +++ b/src/components/server-catalog/CatalogToolbar.tsx @@ -136,11 +136,13 @@ function CatalogFilterSectionFields({ {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 ( -
+