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
Original file line number Diff line number Diff line change
Expand Up @@ -118,12 +118,9 @@ describe("PluginSidebarFooterActions", () => {
);
});

it.each([
[false, "/settings/plugins/remote"],
[true, "/tools/plugins/remote"],
] as const)(
"opens the %s Tools Hub plugin settings destination",
(toolsHubEnabled, expectedPath) => {
it.each([false, true] as const)(
"opens Settings with Tools Hub set to %s",
(toolsHubEnabled) => {
setPluginSlotRegistrations(
"remote",
registrationSet({
Expand All @@ -142,7 +139,7 @@ describe("PluginSidebarFooterActions", () => {
fireEvent.click(screen.getByRole("button", { name: "Remote settings" }));

expect(screen.getByLabelText("Current path").textContent).toBe(
expectedPath,
"/settings/plugins/remote",
);
},
);
Expand Down
16 changes: 2 additions & 14 deletions apps/app/src/components/plugin/PluginSidebarFooterActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,7 @@ import {
usePluginSlots,
type PluginSidebarFooterActionSlot,
} from "@/lib/plugin-slots";
import { useToolsHubExperiment } from "@/components/tools/tools-experiment-context";
import {
getPluginDetailRoutePath,
getSettingsPluginRoutePath,
} from "@/lib/route-paths";
import { getSettingsPluginRoutePath } from "@/lib/route-paths";

const SIDEBAR_FOOTER_ACTION_CLASS = cn(
COARSE_POINTER_CHILD_ICON_BUTTON_CLASS,
Expand Down Expand Up @@ -43,7 +39,6 @@ function PluginSidebarFooterActionList({
onNavigate?: () => void;
}) {
const navigate = useNavigate();
const toolsHubEnabled = useToolsHubExperiment();
return (
<>
{actions.map((action) => (
Expand All @@ -65,7 +60,6 @@ function PluginSidebarFooterActionList({
runSidebarFooterAction({
action,
navigate,
toolsHubEnabled,
});
}}
>
Expand All @@ -81,18 +75,12 @@ function PluginSidebarFooterActionList({
function runSidebarFooterAction({
action,
navigate,
toolsHubEnabled,
}: {
action: PluginSidebarFooterActionSlot;
navigate: ReturnType<typeof useNavigate>;
toolsHubEnabled: boolean;
}): void {
const openSettings = () => {
void navigate(
toolsHubEnabled
? getPluginDetailRoutePath({ pluginId: action.pluginId })
: getSettingsPluginRoutePath(action.pluginId),
);
void navigate(getSettingsPluginRoutePath(action.pluginId));
};
const warn = (error: unknown) => {
console.warn(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
// @vitest-environment jsdom

import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import {
cleanup,
fireEvent,
render,
screen,
within,
} from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { PluginCatalogSearchEntry } from "@/hooks/queries/plugin-catalog-queries";
import { createQueryClientTestHarness } from "@/test/queryClientTestHarness";
Expand Down Expand Up @@ -109,6 +115,9 @@ describe("BrowsePluginsTab", () => {
);

expect(await screen.findByText("BB Official plugins")).toBeTruthy();
const officialCatalog = screen.getByRole("region", {
name: "BB Official plugins",
});
const memoryCard = (await screen.findByText("Memory")).closest("div");
expect(memoryCard).not.toBeNull();
// Scoped to the card on purpose: INCOMPATIBLE_ENTRY spreads MEMORY_ENTRY
Expand All @@ -124,6 +133,17 @@ describe("BrowsePluginsTab", () => {
.getByRole("button", { name: "Open GitHub details" })
.closest('[class*="auto-fill"]');
expect(githubGrid?.className).toContain("auto-fill");
expect(
within(officialCatalog).getByRole("heading", { name: "Productivity" }),
).toBeTruthy();
expect(
within(officialCatalog).getByRole("heading", {
name: "Developer tools",
}),
).toBeTruthy();
expect(
within(officialCatalog).getByRole("button", { name: "Install Memory" }),
).toBeTruthy();

expect(screen.queryByText(MEMORY_ENTRY.source)).toBeNull();
expect(screen.getByText("Requires a newer BB version")).toBeTruthy();
Expand Down
156 changes: 84 additions & 72 deletions apps/app/src/components/plugin/management/BrowsePluginsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,32 +65,11 @@ export function BrowsePluginsTab({
scrollId="plugins-browse-results"
contentClassName="space-y-4"
toolbar={
<div className="space-y-4">
<div className="rounded-lg border border-border bg-card px-3.5 py-3">
<p className="text-sm font-medium text-foreground">
BB Official plugins
</p>
{status === undefined ? (
<p className="mt-0.5 text-xs text-muted-foreground">
{statusQuery.isPending
? "Loading plugins…"
: "Plugin list unavailable."}
</p>
) : (
<p className="mt-0.5 text-xs text-muted-foreground">
{status.pluginCount} plugin
{status.pluginCount === 1 ? "" : "s"} · bundled with BB and
installed with one click
</p>
)}
</div>

<ResourceToolbar
searchValue={query}
searchPlaceholder="Search plugins"
onSearchChange={setQuery}
/>
</div>
<ResourceToolbar
searchValue={query}
searchPlaceholder="Search plugins"
onSearchChange={setQuery}
/>
}
footer={
pagination.total > pagination.pageSize ? (
Expand All @@ -105,54 +84,87 @@ export function BrowsePluginsTab({
) : undefined
}
>
{searchQuery.isError && entries.length > 0 ? (
<p className="text-xs text-warning-text" role="status">
Showing cached catalog results because the latest search failed.
</p>
) : null}
<section
aria-labelledby="bb-official-plugins-heading"
className="space-y-4 rounded-lg border border-border bg-card px-3.5 py-3"
>
<div>
<h2
id="bb-official-plugins-heading"
className="text-sm font-medium text-foreground"
>
BB Official plugins
</h2>
{status === undefined ? (
<p className="mt-0.5 text-xs text-muted-foreground">
{statusQuery.isPending
? "Loading plugins…"
: "Plugin list unavailable."}
</p>
) : (
<p className="mt-0.5 text-xs text-muted-foreground">
{status.pluginCount} plugin
{status.pluginCount === 1 ? "" : "s"} · bundled with BB and
installed with one click
</p>
)}
</div>

{searchQuery.isPending ? (
<ResourceListState state="loading" message="Loading plugins" />
) : entries.length === 0 ? (
<ResourceListState
state={searchQuery.isError ? "error" : "empty"}
message={
searchQuery.isError
? "BB's official plugins are unavailable."
: "No plugins match this search."
}
onRetry={
searchQuery.isError
? () => {
void searchQuery.refetch();
}
: undefined
}
/>
) : (
<div className="space-y-4">
{[...byCategory.entries()].map(([category, categoryEntries]) => (
<div key={category}>
<h3 className="mb-2 text-sm font-semibold text-foreground">
{category}
</h3>
<ResourceBrowseGrid
className="grid-cols-[repeat(auto-fill,minmax(min(100%,23rem),1fr))]"
{searchQuery.isError && entries.length > 0 ? (
<p className="text-xs text-warning-text" role="status">
Showing cached catalog results because the latest search failed.
</p>
) : null}

{searchQuery.isPending ? (
<ResourceListState state="loading" message="Loading plugins" />
) : entries.length === 0 ? (
<ResourceListState
state={searchQuery.isError ? "error" : "empty"}
message={
searchQuery.isError
? "BB's official plugins are unavailable."
: "No plugins match this search."
}
onRetry={
searchQuery.isError
? () => {
void searchQuery.refetch();
}
: undefined
}
/>
) : (
<div className="space-y-4">
{[...byCategory.entries()].map(([category, categoryEntries]) => (
<section
key={category}
aria-labelledby={`plugin-category-${category}`}
>
{categoryEntries.map((entry) => (
<BrowseCard
key={entry.entryId}
entry={entry}
installedPluginId={entry.installed ? entry.pluginId : null}
onInstall={onInstall}
onOpenPlugin={onOpenPlugin}
/>
))}
</ResourceBrowseGrid>
</div>
))}
</div>
)}
<h3
id={`plugin-category-${category}`}
className="mb-2 text-sm font-semibold text-foreground"
>
{category}
</h3>
<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>
)}
</section>
</ResourceCollectionViewport>
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,29 +1,11 @@
// @vitest-environment jsdom

import { cleanup, render, screen } from "@testing-library/react";
import { defaultExperiments } from "@bb/domain";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { PluginSettingsCompatibilityRoute } from "./PluginSettingsCompatibilityRoute";

const mocks = vi.hoisted(() => ({
useSystemConfig: vi.fn(),
}));

vi.mock("@/hooks/queries/system-queries", () => ({
useSystemConfig: mocks.useSystemConfig,
}));

function renderRoute(path: string, toolsHub: boolean | undefined) {
mocks.useSystemConfig.mockReturnValue({
data:
toolsHub === undefined
? undefined
: {
experiments: { ...defaultExperiments, toolsHub },
},
});

function renderRoute(path: string) {
render(
<MemoryRouter initialEntries={[path]}>
<Routes>
Expand Down Expand Up @@ -54,37 +36,20 @@ function renderRoute(path: string, toolsHub: boolean | undefined) {
}

describe("PluginSettingsCompatibilityRoute", () => {
beforeEach(() => {
mocks.useSystemConfig.mockReset();
});

afterEach(cleanup);

it("keeps the existing Settings manager available while Tools Hub is off", () => {
renderRoute("/settings/plugins", false);
it("keeps the Settings manager available", () => {
renderRoute("/settings/plugins");

expect(screen.getByText("Settings plugin manager")).toBeTruthy();
expect(screen.queryByText("Tools plugins")).toBeNull();
});

it("keeps existing Settings plugin detail routes available while Tools Hub is off", () => {
renderRoute("/settings/plugins/example", false);
it("keeps Settings plugin detail routes available", () => {
renderRoute("/settings/plugins/example");

expect(screen.getByText("Settings plugin detail")).toBeTruthy();
expect(screen.queryByText("Tools plugin detail")).toBeNull();
});

it("redirects legacy Settings plugin routes to Tools Hub when enabled", () => {
renderRoute("/settings/plugins/example", true);

expect(screen.getByText("Tools plugin detail")).toBeTruthy();
expect(screen.queryByText("Settings plugin detail")).toBeNull();
});

it("renders neither management surface while configuration is loading", () => {
renderRoute("/settings/plugins", undefined);

expect(screen.queryByText("Settings plugin manager")).toBeNull();
expect(screen.queryByText("Tools plugins")).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -1,32 +1,9 @@
import type { ReactNode } from "react";
import { Navigate, useParams } from "react-router-dom";
import { useSystemConfig } from "@/hooks/queries/system-queries";
import {
getPluginDetailRoutePath,
getPluginsRoutePath,
} from "@/lib/route-paths";

/** Keeps the existing Settings manager available while Tools Hub is off. */
/** Plugin configuration always belongs to Settings, independent of Tools Hub. */
export function PluginSettingsCompatibilityRoute({
children,
}: {
children: ReactNode;
}) {
const { pluginId } = useParams<{ pluginId?: string }>();
const systemConfig = useSystemConfig();
const toolsHubEnabled = systemConfig.data?.experiments.toolsHub;

if (toolsHubEnabled === undefined) return null;
if (!toolsHubEnabled) return children;

return (
<Navigate
to={
pluginId
? getPluginDetailRoutePath({ pluginId })
: getPluginsRoutePath()
}
replace
/>
);
return children;
}
Loading
Loading