Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 72 additions & 2 deletions apps/app/src/components/plugin/PluginsOverview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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(
<MemoryRouter initialEntries={["/tools/plugins"]}>
<QueryClientWrapper>
<PluginsOverview />
</QueryClientWrapper>
</MemoryRouter>,
);

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();
Expand Down
62 changes: 59 additions & 3 deletions apps/app/src/components/plugin/PluginsOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand All @@ -55,6 +58,9 @@ export function PluginsOverview() {
);
const activeMode = modeFromSearchParams(searchParams.get("view"));
const [installedQuery, setInstalledQuery] = useState("");
const [installedCategory, setInstalledCategory] = useState<string | null>(
null,
);
const [installedViewport, setInstalledViewport] =
useState<HTMLDivElement | null>(null);
const installedPageSize = useResourceViewportPageSize(installedViewport);
Expand All @@ -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<string, string>();
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,
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -210,6 +257,11 @@ export function PluginsOverview() {
}
contentClassName="space-y-3"
>
<PluginCategoryFilterPills
categories={installedCategories}
value={installedCategory}
onValueChange={setInstalledCategory}
/>
{listQuery.isError ? (
<ResourceListState
state="error"
Expand All @@ -221,7 +273,11 @@ export function PluginsOverview() {
) : plugins.length > 0 && visiblePlugins.length === 0 ? (
<ResourceListState
state="empty"
message={`No plugins match "${installedQuery}"`}
message={
installedCategory === null
? `No plugins match "${installedQuery}"`
: `No installed plugins match ${installedCategory}.`
}
/>
) : (
<InstalledPluginsTab plugins={installedPagination.items} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand Down Expand Up @@ -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();
});

Expand Down Expand Up @@ -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();
Expand Down
58 changes: 35 additions & 23 deletions apps/app/src/components/plugin/management/BrowsePluginsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -37,15 +38,21 @@ export function BrowsePluginsTab({
onOpenPlugin: (pluginId: string) => void;
}) {
const [query, setQuery] = useState("");
const [category, setCategory] = useState<string | null>(null);
const [debouncedQuery] = useDebounceValue(query.trim(), 300);
const searchQuery = usePluginCatalogSearch(debouncedQuery, { enabled: true });
const entries = searchQuery.data ?? [];
const byCategory = new Map<string, PluginCatalogSearchEntry[]>();
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 (
<ResourceCollectionViewport
Expand Down Expand Up @@ -84,25 +91,30 @@ export function BrowsePluginsTab({
}
/>
) : (
<div className="space-y-5">
{[...byCategory.entries()].map(([category, categoryEntries]) => (
<section key={category} aria-label={category}>
<h2 className="mb-2 text-sm font-semibold text-foreground">
{category}
</h2>
<ResourceBrowseGrid className="grid-cols-[repeat(auto-fill,minmax(min(100%,23rem),1fr))]">
{categoryEntries.map((entry) => (
<BrowseCard
key={entry.entryId}
entry={entry}
installedPluginId={entry.installed ? entry.pluginId : null}
onInstall={onInstall}
onOpenPlugin={onOpenPlugin}
/>
))}
</ResourceBrowseGrid>
</section>
))}
<div className="space-y-3">
<PluginCategoryFilterPills
categories={categories}
value={category}
onValueChange={setCategory}
/>
{visibleEntries.length === 0 ? (
<ResourceListState
state="empty"
message="No plugins match this category."
/>
) : (
<ResourceBrowseGrid className="grid-cols-[repeat(auto-fill,minmax(min(100%,23rem),1fr))]">
{visibleEntries.map((entry) => (
<BrowseCard
key={entry.entryId}
entry={entry}
installedPluginId={entry.installed ? entry.pluginId : null}
onInstall={onInstall}
onOpenPlugin={onOpenPlugin}
/>
))}
</ResourceBrowseGrid>
)}
</div>
)}
</ResourceCollectionViewport>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<ToggleGroup
type="single"
value={value ?? ALL_CATEGORIES}
onValueChange={(next) => {
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"
>
<ToggleGroupItem
value={ALL_CATEGORIES}
aria-label="Show all plugin categories"
className="h-7 min-w-0 rounded-full border border-border bg-background px-3 text-xs shadow-none data-[state=on]:bg-state-active data-[state=on]:text-foreground"
>
All
</ToggleGroupItem>
{categories.map((category) => (
<ToggleGroupItem
key={category}
value={category}
className="h-7 min-w-0 rounded-full border border-border bg-background px-3 text-xs shadow-none data-[state=on]:bg-state-active data-[state=on]:text-foreground"
>
{category}
</ToggleGroupItem>
))}
</ToggleGroup>
);
}
Loading