diff --git a/apps/app/src/components/plugin/PluginsOverview.test.tsx b/apps/app/src/components/plugin/PluginsOverview.test.tsx index b4e9a87e2..0e98af141 100644 --- a/apps/app/src/components/plugin/PluginsOverview.test.tsx +++ b/apps/app/src/components/plugin/PluginsOverview.test.tsx @@ -84,6 +84,30 @@ const GITHUB_CATALOG_ENTRY = { incompatibleReason: null, }; +const AUTOMATIONS_CATALOG_ENTRY = { + ...GITHUB_CATALOG_ENTRY, + entryId: "automations", + pluginId: "automations", + displayName: "Automations", + description: AUTOMATIONS_PLUGIN.description, + icon: AUTOMATIONS_PLUGIN.icon, + category: "Workflow management", + source: AUTOMATIONS_PLUGIN.source, + installed: true, +}; + +const DOCS_CATALOG_ENTRY = { + ...GITHUB_CATALOG_ENTRY, + entryId: "docs", + pluginId: "simple-notes", + displayName: "Docs", + description: "Create and edit Markdown documents.", + icon: "NotebookText", + category: "Context & knowledge", + source: "builtin:docs", + installed: true, +}; + function installFetch(plugins: readonly unknown[] = [AUTOMATIONS_PLUGIN]) { vi.stubGlobal( "fetch", @@ -111,7 +135,13 @@ function installFetch(plugins: readonly unknown[] = [AUTOMATIONS_PLUGIN]) { }); } if (url.pathname === "/api/v1/plugin-catalog/search") { - return responseJson({ results: [GITHUB_CATALOG_ENTRY] }); + return responseJson({ + results: [ + AUTOMATIONS_CATALOG_ENTRY, + DOCS_CATALOG_ENTRY, + GITHUB_CATALOG_ENTRY, + ], + }); } if (url.pathname === "/api/v1/plugin-catalog/install") { return responseJson({ @@ -172,12 +202,52 @@ describe("PluginsOverview", () => { fireEvent.click(screen.getByRole("tab", { name: "Browse" })); expect(await screen.findByText("GitHub")).toBeTruthy(); expect( - screen.getByRole("heading", { name: "Developer tools" }), + screen.getByRole("radio", { name: "Developer tools" }), ).toBeTruthy(); expect(screen.queryByText("BB Official plugins")).toBeNull(); expect(screen.getByRole("button", { name: "New plugin" })).toBeTruthy(); }); + it("filters installed plugins with the catalog categories", async () => { + installFetch([ + AUTOMATIONS_PLUGIN, + { + ...AUTOMATIONS_PLUGIN, + id: "simple-notes", + source: "builtin:docs", + name: "Docs", + description: DOCS_CATALOG_ENTRY.description, + icon: DOCS_CATALOG_ENTRY.icon, + provenance: "catalog", + catalogEntryId: "docs", + }, + ]); + const { wrapper: QueryClientWrapper } = createQueryClientTestHarness(); + render( + + + + + , + ); + + expect(await screen.findByText("Automations")).toBeTruthy(); + expect(screen.getByText("Docs")).toBeTruthy(); + expect( + screen.getByRole("radio", { name: "Developer tools" }), + ).toBeTruthy(); + fireEvent.click( + screen.getByRole("radio", { name: "Context & knowledge" }), + ); + + expect(screen.getByText("Docs")).toBeTruthy(); + expect(screen.queryByText("Automations")).toBeNull(); + fireEvent.click( + screen.getByRole("radio", { name: "Show all plugin categories" }), + ); + expect(screen.getByText("Automations")).toBeTruthy(); + }); + it("opens installed resources on the canonical Tools detail route", async () => { installFetch(); const { wrapper: QueryClientWrapper } = createQueryClientTestHarness(); diff --git a/apps/app/src/components/plugin/PluginsOverview.tsx b/apps/app/src/components/plugin/PluginsOverview.tsx index 9c92f9480..954e9c100 100644 --- a/apps/app/src/components/plugin/PluginsOverview.tsx +++ b/apps/app/src/components/plugin/PluginsOverview.tsx @@ -23,6 +23,8 @@ import { } from "@/components/plugin/management/AddPluginDialog"; import { BrowsePluginsTab } from "@/components/plugin/management/BrowsePluginsTab"; import { InstalledPluginsTab } from "@/components/plugin/management/InstalledPluginsTab"; +import { PluginCategoryFilterPills } from "@/components/plugin/management/PluginCategoryFilterPills"; +import { usePluginCatalogSearch } from "@/hooks/queries/plugin-catalog-queries"; import { usePluginList } from "@/hooks/queries/plugin-settings-queries"; import { getPluginDetailRoutePath, @@ -46,6 +48,7 @@ export function PluginsOverview() { const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const listQuery = usePluginList({ enabled: true }); + const catalogQuery = usePluginCatalogSearch("", { enabled: true }); const plugins = useMemo( () => (listQuery.data?.plugins ?? []).filter( @@ -55,6 +58,9 @@ export function PluginsOverview() { ); const activeMode = modeFromSearchParams(searchParams.get("view")); const [installedQuery, setInstalledQuery] = useState(""); + const [installedCategory, setInstalledCategory] = useState( + null, + ); const [installedViewport, setInstalledViewport] = useState(null); const installedPageSize = useResourceViewportPageSize(installedViewport); @@ -78,10 +84,41 @@ export function PluginsOverview() { { id: "browse" as const, label: "Browse" }, ]; const normalizedInstalledQuery = installedQuery.trim().toLowerCase(); + const categoryByPluginId = useMemo(() => { + const categories = new Map(); + for (const entry of catalogQuery.data ?? []) { + categories.set(entry.entryId, entry.category); + categories.set(entry.pluginId, entry.category); + } + return categories; + }, [catalogQuery.data]); + const installedCategories = useMemo(() => { + const categories: string[] = []; + for (const entry of catalogQuery.data ?? []) { + if (!categories.includes(entry.category)) { + categories.push(entry.category); + } + } + if ( + installedCategory !== null && + !categories.includes(installedCategory) + ) { + categories.push(installedCategory); + } + return categories; + }, [catalogQuery.data, installedCategory]); const visiblePlugins = useMemo( () => plugins .filter((plugin) => { + if (installedCategory !== null) { + const category = + (plugin.catalogEntryId === null + ? undefined + : categoryByPluginId.get(plugin.catalogEntryId)) ?? + categoryByPluginId.get(plugin.id); + if (category !== installedCategory) return false; + } if (normalizedInstalledQuery.length === 0) return true; return [ plugin.id, @@ -114,11 +151,21 @@ export function PluginsOverview() { } return left.id.localeCompare(right.id); }), - [installedSortDirection, normalizedInstalledQuery, plugins], + [ + categoryByPluginId, + installedCategory, + installedSortDirection, + normalizedInstalledQuery, + plugins, + ], ); const installedPagination = useResourcePagination(visiblePlugins, { pageSize: installedPageSize, - resetKey: [normalizedInstalledQuery, installedSortDirection].join("\u0000"), + resetKey: [ + normalizedInstalledQuery, + installedCategory ?? "all", + installedSortDirection, + ].join("\u0000"), }); const hasInstalledPagination = !listQuery.isError && @@ -210,6 +257,11 @@ export function PluginsOverview() { } contentClassName="space-y-3" > + {listQuery.isError ? ( 0 && visiblePlugins.length === 0 ? ( ) : ( diff --git a/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx b/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx index f4cae5c63..32ef854e9 100644 --- a/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx +++ b/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx @@ -90,7 +90,7 @@ afterEach(() => { }); describe("BrowsePluginsTab", () => { - it("renders every returned catalog entry exactly once in direct categories", async () => { + it("renders every catalog entry once and filters the grid with category pills", async () => { const entries = Array.from( { length: CATALOG_STATUS.pluginCount }, (_, index) => ({ @@ -127,6 +127,25 @@ describe("BrowsePluginsTab", () => { expect( screen.getAllByRole("button", { name: /^Open Official \d+ details$/ }), ).toHaveLength(CATALOG_STATUS.pluginCount); + expect( + screen.getByRole("radiogroup", { name: "Filter plugins by category" }), + ).toBeTruthy(); + expect( + screen.queryByRole("heading", { name: "Context & knowledge" }), + ).toBeNull(); + fireEvent.click( + screen.getByRole("radio", { name: "Developer tools" }), + ); + expect( + screen.getAllByRole("button", { name: /^Open Official \d+ details$/ }), + ).toHaveLength(6); + expect(screen.queryByText("Official 1")).toBeNull(); + fireEvent.click( + screen.getByRole("radio", { name: "Show all plugin categories" }), + ); + expect( + screen.getAllByRole("button", { name: /^Open Official \d+ details$/ }), + ).toHaveLength(CATALOG_STATUS.pluginCount); expect(screen.queryByText("BB Official plugins")).toBeNull(); }); @@ -174,11 +193,12 @@ describe("BrowsePluginsTab", () => { .closest('[class*="auto-fill"]'); expect(githubGrid?.className).toContain("auto-fill"); expect( - screen.getByRole("heading", { name: "Context & knowledge" }), + screen.getByRole("radio", { name: "Context & knowledge" }), ).toBeTruthy(); expect( - screen.getByRole("heading", { name: "Developer tools" }), + screen.getByRole("radio", { name: "Developer tools" }), ).toBeTruthy(); + expect(screen.queryByRole("heading", { level: 2 })).toBeNull(); expect( screen.getByRole("button", { name: "Install Memory" }), ).toBeTruthy(); diff --git a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx index e37a4b350..bbe8ff95d 100644 --- a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx +++ b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx @@ -26,6 +26,7 @@ import { } from "@/hooks/queries/plugin-catalog-queries"; import { removePlugin } from "@/hooks/queries/plugin-settings-queries"; import type { AddPluginInitial } from "./AddPluginDialog"; +import { PluginCategoryFilterPills } from "./PluginCategoryFilterPills"; import { PlaceholderBadge } from "./plugin-ui"; /** Browse BB's official plugins, bundled with the app. */ @@ -37,15 +38,21 @@ export function BrowsePluginsTab({ onOpenPlugin: (pluginId: string) => void; }) { const [query, setQuery] = useState(""); + const [category, setCategory] = useState(null); const [debouncedQuery] = useDebounceValue(query.trim(), 300); const searchQuery = usePluginCatalogSearch(debouncedQuery, { enabled: true }); const entries = searchQuery.data ?? []; - const byCategory = new Map(); + const categories: string[] = []; for (const entry of entries) { - const bucket = byCategory.get(entry.category); - if (bucket === undefined) byCategory.set(entry.category, [entry]); - else bucket.push(entry); + if (!categories.includes(entry.category)) categories.push(entry.category); } + if (category !== null && !categories.includes(category)) { + categories.push(category); + } + const visibleEntries = + category === null + ? entries + : entries.filter((entry) => entry.category === category); return ( ) : ( -
- {[...byCategory.entries()].map(([category, categoryEntries]) => ( -
-

- {category} -

- - {categoryEntries.map((entry) => ( - - ))} - -
- ))} +
+ + {visibleEntries.length === 0 ? ( + + ) : ( + + {visibleEntries.map((entry) => ( + + ))} + + )}
)} diff --git a/apps/app/src/components/plugin/management/PluginCategoryFilterPills.tsx b/apps/app/src/components/plugin/management/PluginCategoryFilterPills.tsx new file mode 100644 index 000000000..48a4df78b --- /dev/null +++ b/apps/app/src/components/plugin/management/PluginCategoryFilterPills.tsx @@ -0,0 +1,45 @@ +import { ToggleGroup, ToggleGroupItem } from "@bb/shared-ui/toggle-group"; + +const ALL_CATEGORIES = "all"; + +export function PluginCategoryFilterPills({ + categories, + value, + onValueChange, +}: { + categories: readonly string[]; + value: string | null; + onValueChange: (category: string | null) => void; +}) { + if (categories.length === 0) return null; + + return ( + { + if (next.length === 0) return; + onValueChange(next === ALL_CATEGORIES ? null : next); + }} + aria-label="Filter plugins by category" + className="flex-wrap justify-start gap-1.5" + > + + All + + {categories.map((category) => ( + + {category} + + ))} + + ); +}