From e04f3fa084ae714d3f0ebfdec47c12a6a479a89a Mon Sep 17 00:00:00 2001 From: Marek Dano Date: Fri, 14 Aug 2026 12:16:23 +0100 Subject: [PATCH] feat: move plugin catalog filters into a dialog Signed-off-by: Marek Dano --- src/components/plugins/PluginToolbar.tsx | 310 +++++++++++++----- .../server-catalog/CatalogToolbar.tsx | 14 +- src/i18n/locales/en-US/common.json | 2 + src/i18n/locales/en-US/mcpServer.json | 4 - src/i18n/locales/en-US/plugins.json | 5 +- src/i18n/locales/es-ES/common.json | 2 + src/i18n/locales/es-ES/mcpServer.json | 4 - src/i18n/locales/es-ES/plugins.json | 5 +- src/i18n/locales/pt-BR/common.json | 2 + src/i18n/locales/pt-BR/mcpServer.json | 4 - src/i18n/locales/pt-BR/plugins.json | 5 +- src/pages/Plugins.test.tsx | 54 ++- src/pages/Plugins.tsx | 44 +-- 13 files changed, 307 insertions(+), 148 deletions(-) diff --git a/src/components/plugins/PluginToolbar.tsx b/src/components/plugins/PluginToolbar.tsx index 679c436..d2335c4 100644 --- a/src/components/plugins/PluginToolbar.tsx +++ b/src/components/plugins/PluginToolbar.tsx @@ -1,38 +1,53 @@ -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_FILTER_VALUE = "__all__"; +const ALL_MODE = "all"; +const SELECT_MODE = "select"; -export type PluginSingleFilterKey = "hook"; +export interface PluginFilterDraft { + hook: string[]; + tags: string[]; +} + +type PluginFilterSection = keyof PluginFilterDraft; +type PluginSectionMode = typeof ALL_MODE | typeof SELECT_MODE; +type PluginSectionModes = Record; + +function getSectionModes(draft: PluginFilterDraft): PluginSectionModes { + return { + hook: draft.hook.length > 0 ? SELECT_MODE : ALL_MODE, + tags: draft.tags.length > 0 ? SELECT_MODE : ALL_MODE, + }; +} interface PluginToolbarProps { search: string; enabledOnly: boolean; - hook: string; + hook: string[]; selectedTags: string[]; hooks: string[]; availableTags: string[]; activeFilterCount: number; onSearchChange: (value: string) => void; onEnabledOnlyChange: (enabledOnly: boolean) => void; - onSetSingleFilter: (key: PluginSingleFilterKey, value: string | null) => void; - onToggleTag: (tag: string, checked: boolean) => void; - onClear: () => void; + onApply: (draft: PluginFilterDraft) => void; } function PluginViewToggle({ @@ -72,24 +87,157 @@ function PluginViewToggle({ ); } -function PluginFiltersPopover({ +// Tailwind's scanner needs literal class names, so column counts are looked up +// rather than interpolated (e.g. `md:columns-${n}` would be silently dropped). +const COLUMNS_CLASS: Record<3 | 4, string> = { + 3: "columns-2 gap-x-4 pl-6 md:columns-3", + 4: "columns-2 gap-x-4 pl-6 md:columns-4", +}; + +function PluginFilterSectionFields({ + idPrefix, + legendId, + legend, + options, + selected, + mode, + allLabel, + selectLabel, + onModeChange, + onToggle, + maxColumns = 4, +}: { + idPrefix: string; + legendId: string; + legend: string; + options: string[]; + selected: string[]; + mode: PluginSectionMode; + allLabel: string; + selectLabel: string; + onModeChange: (mode: string) => void; + onToggle: (option: string, checked: boolean) => void; + maxColumns?: 3 | 4; +}) { + return ( +
+ + {legend} + + + +
+ + +
+
+ + +
+
+ + {mode === SELECT_MODE && ( + // Multi-column rather than a grid so options read alphabetically down + // each column, as the design lays them out. min-w-0 plus + // overflow-wrap:anywhere lets long, space-free option names (e.g. hook + // identifiers like "http_auth_resolve_user") wrap inside their column + // instead of forcing the column — and the dialog — wider, which used to + // push a horizontal scrollbar onto the whole dialog. +
+ {options.map((option, index) => { + const checkboxId = `${idPrefix}-option-${index}`; + return ( +
+ onToggle(option, checked === true)} + className="mt-0.5" + /> + +
+ ); + })} +
+ )} +
+ ); +} + +function PluginFiltersDialog({ hook, selectedTags, hooks, availableTags, activeFilterCount, - onSetSingleFilter, - onToggleTag, - onClear, + onApply, }: Omit) { const intl = useIntl(); const id = useId(); - const filtersTitleId = `${id}-title`; - const hookTriggerId = `${id}-hook`; + const [open, setOpen] = useState(false); + const initialDraft: PluginFilterDraft = { hook, tags: selectedTags }; + const [draft, setDraft] = useState(initialDraft); + const [sectionModes, setSectionModes] = 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: PluginFilterDraft = { hook, tags: selectedTags }; + setDraft(committed); + setSectionModes(getSectionModes(committed)); + } + setOpen(nextOpen); + }, + [hook, selectedTags], + ); + + const setSectionMode = useCallback((section: PluginFilterSection, nextMode: string) => { + const resolvedMode: PluginSectionMode = nextMode === SELECT_MODE ? SELECT_MODE : ALL_MODE; + setSectionModes((previous) => ({ ...previous, [section]: resolvedMode })); + // Switching a section back to All clears that section and leaves the others + // untouched. Switching to Select keeps whatever was already ticked. + if (resolvedMode === ALL_MODE) { + setDraft((previous) => ({ ...previous, [section]: [] })); + } + }, []); + + const toggleSectionOption = useCallback( + (section: PluginFilterSection, option: string, checked: boolean) => { + // Ticking a box always implies Select mode for that section. + if (checked) setSectionModes((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: "plugins.catalog.filters" })} -

- {activeFilterCount > 0 && ( - - )} -
+ -
- - + + + + + + {intl.formatMessage({ id: "common.addFilters" })} + + + +
+ setSectionMode("hook", nextMode)} + onToggle={(option, checked) => toggleSectionOption("hook", option, checked)} + maxColumns={3} + /> + + {availableTags.length > 0 && ( + setSectionMode("tags", nextMode)} + onToggle={(option, checked) => toggleSectionOption("tags", option, checked)} + /> + )}
- {availableTags.length > 0 && ( -
- - {intl.formatMessage({ id: "plugins.catalog.tags" })} - -
- {availableTags.map((tag, index) => { - const checkboxId = `${id}-tag-${index}`; - return ( -
- onToggleTag(tag, checked === true)} - /> - -
- ); - })} -
-
- )} - - + + + + + + +
+ ); } @@ -199,7 +337,7 @@ export function PluginToolbar({ expandedWidthClassName="w-full sm:w-[432px]" /> - +
); diff --git a/src/components/server-catalog/CatalogToolbar.tsx b/src/components/server-catalog/CatalogToolbar.tsx index b7acf77..85a818b 100644 --- a/src/components/server-catalog/CatalogToolbar.tsx +++ b/src/components/server-catalog/CatalogToolbar.tsx @@ -248,8 +248,10 @@ function CatalogFiltersDialog({ - @@ -262,7 +264,7 @@ function CatalogFiltersDialog({ selected={draft.provider} mode={modes.provider} allLabel={intl.formatMessage({ id: "mcpServer.catalog.allProvidersOption" })} - selectLabel={intl.formatMessage({ id: "mcpServer.catalog.selectProviders" })} + selectLabel={intl.formatMessage({ id: "common.selectOption" })} onModeChange={(mode) => setSectionMode("provider", mode)} onToggle={(option, checked) => toggleSectionOption("provider", option, checked)} /> @@ -275,7 +277,7 @@ function CatalogFiltersDialog({ selected={draft.category} mode={modes.category} allLabel={intl.formatMessage({ id: "mcpServer.catalog.allCategoriesOption" })} - selectLabel={intl.formatMessage({ id: "mcpServer.catalog.selectCategories" })} + selectLabel={intl.formatMessage({ id: "common.selectOption" })} onModeChange={(mode) => setSectionMode("category", mode)} onToggle={(option, checked) => toggleSectionOption("category", option, checked)} /> @@ -289,7 +291,7 @@ function CatalogFiltersDialog({ selected={draft.tags} mode={modes.tags} allLabel={intl.formatMessage({ id: "mcpServer.catalog.allTagsOption" })} - selectLabel={intl.formatMessage({ id: "mcpServer.catalog.selectTags" })} + selectLabel={intl.formatMessage({ id: "common.selectOption" })} onModeChange={(mode) => setSectionMode("tags", mode)} onToggle={(option, checked) => toggleSectionOption("tags", option, checked)} /> @@ -303,7 +305,7 @@ function CatalogFiltersDialog({ diff --git a/src/i18n/locales/en-US/common.json b/src/i18n/locales/en-US/common.json index e2d5dab..73c10c3 100644 --- a/src/i18n/locales/en-US/common.json +++ b/src/i18n/locales/en-US/common.json @@ -33,6 +33,8 @@ "common.search.startTyping": "Type at least {count} characters to search.", "common.searchLabel": "Search {entity}", "common.filter": "Filter", + "common.addFilters": "Add filters", + "common.selectOption": "Select...", "common.export": "Export", "common.import": "Import", "common.refresh": "Refresh", diff --git a/src/i18n/locales/en-US/mcpServer.json b/src/i18n/locales/en-US/mcpServer.json index 7215ead..f757ae6 100644 --- a/src/i18n/locales/en-US/mcpServer.json +++ b/src/i18n/locales/en-US/mcpServer.json @@ -9,7 +9,6 @@ "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.addFilters": "Add filters", "mcpServer.catalog.category": "Category", "mcpServer.catalog.provider": "Provider", "mcpServer.catalog.authentication": "Authentication", @@ -17,11 +16,8 @@ "mcpServer.catalog.providers": "Providers", "mcpServer.catalog.tags": "Tags", "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/en-US/plugins.json b/src/i18n/locales/en-US/plugins.json index e76b185..517c4cc 100644 --- a/src/i18n/locales/en-US/plugins.json +++ b/src/i18n/locales/en-US/plugins.json @@ -5,12 +5,11 @@ "plugins.catalog.searchLabel": "Search plugins", "plugins.catalog.filters": "Filters", "plugins.catalog.filtersActive": "{count, plural, =0 {Filters} one {Filters, # active} other {Filters, # active}}", - "plugins.catalog.clearFilters": "Clear", "plugins.catalog.mode": "Mode", "plugins.catalog.hook": "Hook", "plugins.catalog.tags": "Tags", - "plugins.catalog.allHooks": "All hooks", - "plugins.catalog.allTags": "All tags", + "plugins.catalog.allHooksOption": "All", + "plugins.catalog.allTagsOption": "All", "plugins.catalog.enabled": "Enabled", "plugins.catalog.view": "View", "plugins.catalog.viewPlugin": "View {name}", diff --git a/src/i18n/locales/es-ES/common.json b/src/i18n/locales/es-ES/common.json index cbbff44..5ead192 100644 --- a/src/i18n/locales/es-ES/common.json +++ b/src/i18n/locales/es-ES/common.json @@ -33,6 +33,8 @@ "common.search.startTyping": "Escribe al menos {count} caracteres para buscar.", "common.searchLabel": "Buscar {entity}", "common.filter": "Filtrar", + "common.addFilters": "Añadir filtros", + "common.selectOption": "Seleccionar...", "common.export": "Exportar", "common.import": "Importar", "common.refresh": "Actualizar", diff --git a/src/i18n/locales/es-ES/mcpServer.json b/src/i18n/locales/es-ES/mcpServer.json index 5d7d329..36c5474 100644 --- a/src/i18n/locales/es-ES/mcpServer.json +++ b/src/i18n/locales/es-ES/mcpServer.json @@ -9,7 +9,6 @@ "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.addFilters": "Añadir filtros", "mcpServer.catalog.category": "Categoría", "mcpServer.catalog.provider": "Proveedor", "mcpServer.catalog.authentication": "Autenticación", @@ -17,11 +16,8 @@ "mcpServer.catalog.providers": "Proveedores", "mcpServer.catalog.tags": "Etiquetas", "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/es-ES/plugins.json b/src/i18n/locales/es-ES/plugins.json index 74b5399..156e8bf 100644 --- a/src/i18n/locales/es-ES/plugins.json +++ b/src/i18n/locales/es-ES/plugins.json @@ -5,12 +5,11 @@ "plugins.catalog.searchLabel": "Buscar plugins", "plugins.catalog.filters": "Filtros", "plugins.catalog.filtersActive": "{count, plural, =0 {Filtros} one {Filtros, # activo} other {Filtros, # activos}}", - "plugins.catalog.clearFilters": "Borrar", "plugins.catalog.mode": "Modo", "plugins.catalog.hook": "Hook", "plugins.catalog.tags": "Etiquetas", - "plugins.catalog.allHooks": "Todos los hooks", - "plugins.catalog.allTags": "Todas las etiquetas", + "plugins.catalog.allHooksOption": "Todos", + "plugins.catalog.allTagsOption": "Todas", "plugins.catalog.enabled": "Habilitado", "plugins.catalog.view": "Ver", "plugins.catalog.viewPlugin": "Ver {name}", diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index 017a5b6..71b85be 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -33,6 +33,8 @@ "common.search.startTyping": "Digite pelo menos {count} caracteres para buscar.", "common.searchLabel": "Buscar {entity}", "common.filter": "Filtrar", + "common.addFilters": "Adicionar filtros", + "common.selectOption": "Selecionar...", "common.export": "Exportar", "common.import": "Importar", "common.refresh": "Atualizar", diff --git a/src/i18n/locales/pt-BR/mcpServer.json b/src/i18n/locales/pt-BR/mcpServer.json index 5eaa49f..9946b04 100644 --- a/src/i18n/locales/pt-BR/mcpServer.json +++ b/src/i18n/locales/pt-BR/mcpServer.json @@ -9,7 +9,6 @@ "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.addFilters": "Adicionar filtros", "mcpServer.catalog.category": "Categoria", "mcpServer.catalog.provider": "Provedor", "mcpServer.catalog.authentication": "Autenticação", @@ -17,11 +16,8 @@ "mcpServer.catalog.providers": "Provedores", "mcpServer.catalog.tags": "Tags", "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/i18n/locales/pt-BR/plugins.json b/src/i18n/locales/pt-BR/plugins.json index 3239120..8eee7a0 100644 --- a/src/i18n/locales/pt-BR/plugins.json +++ b/src/i18n/locales/pt-BR/plugins.json @@ -5,12 +5,11 @@ "plugins.catalog.searchLabel": "Pesquisar plugins", "plugins.catalog.filters": "Filtros", "plugins.catalog.filtersActive": "{count, plural, =0 {Filtros} one {Filtros, # ativo} other {Filtros, # ativos}}", - "plugins.catalog.clearFilters": "Limpar", "plugins.catalog.mode": "Modo", "plugins.catalog.hook": "Hook", "plugins.catalog.tags": "Tags", - "plugins.catalog.allHooks": "Todos os hooks", - "plugins.catalog.allTags": "Todas as tags", + "plugins.catalog.allHooksOption": "Todos", + "plugins.catalog.allTagsOption": "Todas", "plugins.catalog.enabled": "Habilitado", "plugins.catalog.view": "Ver", "plugins.catalog.viewPlugin": "Ver {name}", diff --git a/src/pages/Plugins.test.tsx b/src/pages/Plugins.test.tsx index ece955c..a8ab93f 100644 --- a/src/pages/Plugins.test.tsx +++ b/src/pages/Plugins.test.tsx @@ -60,6 +60,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/plugins") { window.history.pushState({}, "", path); return render( @@ -140,38 +165,41 @@ describe("Plugins", () => { const user = userEvent.setup(); renderWithRouter(); - await user.click(screen.getByRole("button", { name: /^Filters$/ })); + await openFilters(user); - expect(screen.getByRole("combobox", { name: "Hook" })).toBeInTheDocument(); - expect(screen.queryByRole("combobox", { name: "Mode" })).not.toBeInTheDocument(); + expect(getFilterSection("Hooks")).toBeInTheDocument(); + expect(screen.queryByRole("group", { name: "Modes" })).not.toBeInTheDocument(); }); - it("filters by hook and tag, then clears filters", async () => { + it("filters by hook and tag, then clears the hook filter", async () => { const user = userEvent.setup(); renderWithRouter(); - await user.click(screen.getByRole("button", { name: /^Filters$/ })); - await user.click(screen.getByRole("combobox", { name: "Hook" })); - await user.click(screen.getByRole("option", { name: "http_pre_request" })); + await openFilters(user); + await selectSectionOption(user, "Hooks", "http_pre_request"); + await applyFilters(user); let params = new URLSearchParams(window.location.search); - expect(params.get("hook")).toBe("http_pre_request"); + expect(params.getAll("hook")).toEqual(["http_pre_request"]); expect(screen.getByRole("heading", { name: "Request Logger" })).toBeInTheDocument(); expect(screen.queryByRole("heading", { name: "PII Guardrails" })).not.toBeInTheDocument(); - await user.click(screen.getByRole("checkbox", { name: "security" })); + await openFilters(user); + await selectSectionOption(user, "Tags", "security"); + await applyFilters(user); params = new URLSearchParams(window.location.search); expect(params.getAll("tags")).toContain("security"); expect(screen.getByText("No plugins match the active search and filters.")).toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Clear" })); + await openFilters(user); + await user.click(within(getFilterSection("Hooks")).getByRole("radio", { name: "All" })); + await applyFilters(user); params = new URLSearchParams(window.location.search); expect(params.has("hook")).toBe(false); - expect(params.has("tags")).toBe(false); - expect(screen.getByRole("heading", { name: "PII Guardrails" })).toBeInTheDocument(); - expect(screen.getByRole("heading", { name: "Request Logger" })).toBeInTheDocument(); + expect(params.getAll("tags")).toEqual(["security"]); + expect(screen.getByRole("button", { name: "Filters, 1 active" })).toBeInTheDocument(); }); it("updates URL state while typing a search and can toggle back to all", async () => { diff --git a/src/pages/Plugins.tsx b/src/pages/Plugins.tsx index 4b29a6e..d1e6b44 100644 --- a/src/pages/Plugins.tsx +++ b/src/pages/Plugins.tsx @@ -4,7 +4,7 @@ import { useIntl } from "react-intl"; import { EmptyStatePlaceholder } from "@/components/dashboard/EmptyStatePlaceholder"; import { PluginDetailsDialog, PluginResults } from "@/components/plugins/PluginResults"; -import { PluginToolbar, type PluginSingleFilterKey } from "@/components/plugins/PluginToolbar"; +import { PluginToolbar, type PluginFilterDraft } from "@/components/plugins/PluginToolbar"; import { Button } from "@/components/ui/button"; import { InlineNotification } from "@/components/ui/inline-notification"; import { Loading } from "@/components/ui/loading"; @@ -20,7 +20,7 @@ const PAGE_HEADING_ID = "plugins-catalog-heading"; interface PluginFilters { search: string; - hook: string; + hook: string[]; tags: string[]; enabledOnly: boolean; } @@ -32,13 +32,17 @@ 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): PluginFilters { const params = new URLSearchParams(getQuery(path)); return { search: params.get("search") ?? "", - hook: params.get("hook") ?? "", - tags: [...new Set(params.getAll("tags").filter(Boolean))], + hook: readMulti(params, "hook"), + tags: readMulti(params, "tags"), enabledOnly: params.get("status") === ENABLED_STATUS, }; } @@ -68,29 +72,27 @@ function usePluginFilters() { [navigate, path], ); - const setSingleFilter = useCallback( - (key: PluginSingleFilterKey, 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: PluginFilterDraft) => updateQuery({ - tags: checked ? [...filters.tags, tag] : filters.tags.filter((item) => item !== tag), + hook: draft.hook, + tags: draft.tags, }), - [filters.tags, updateQuery], + [updateQuery], ); - const clearFilters = useCallback(() => updateQuery({ hook: null, tags: [] }), [updateQuery]); - - return { filters, updateQuery, setSingleFilter, toggleTag, clearFilters }; + return { filters, updateQuery, applyFilters }; } function filterPlugins(plugins: PluginSummary[], filters: PluginFilters): PluginSummary[] { const search = filters.search.trim().toLocaleLowerCase(); return plugins.filter((plugin) => { - if (filters.hook && !plugin.hooks?.includes(filters.hook)) return false; + if (filters.hook.length > 0 && !filters.hook.some((hook) => plugin.hooks?.includes(hook))) { + return false; + } if (filters.tags.length > 0 && !filters.tags.some((tag) => plugin.tags?.includes(tag))) { return false; } @@ -123,7 +125,7 @@ export function Plugins() { const [selectedPlugin, setSelectedPlugin] = useState(null); const lastViewTriggerRef = useRef(null); const { data, error, isLoading, refetch } = useQuery(PLUGINS_PATH); - const { filters, updateQuery, setSingleFilter, toggleTag, clearFilters } = usePluginFilters(); + const { filters, updateQuery, applyFilters } = usePluginFilters(); const [search, setSearch] = useState(filters.search); const debouncedSearch = useDebouncedValue(search, 300); @@ -159,7 +161,7 @@ export function Plugins() { : filters.enabledOnly && !hasEnabledPlugins ? "plugins.catalog.noneEnabled" : "plugins.catalog.noResults"; - const activeFilterCount = Number(Boolean(filters.hook)) + filters.tags.length; + const activeFilterCount = filters.hook.length + filters.tags.length; const handleView = useCallback((plugin: PluginSummary, trigger: HTMLButtonElement) => { lastViewTriggerRef.current = trigger; @@ -225,9 +227,7 @@ export function Plugins() { activeFilterCount={activeFilterCount} onSearchChange={setSearch} onEnabledOnlyChange={(enabledOnly) => updateQuery({ status: enabledOnly })} - onSetSingleFilter={setSingleFilter} - onToggleTag={toggleTag} - onClear={clearFilters} + onApply={applyFilters} />