diff --git a/apps/app/src/components/plugin/PluginNavSidebarItems.test.tsx b/apps/app/src/components/plugin/PluginNavSidebarItems.test.tsx index 41a6e06f51..58a8365a89 100644 --- a/apps/app/src/components/plugin/PluginNavSidebarItems.test.tsx +++ b/apps/app/src/components/plugin/PluginNavSidebarItems.test.tsx @@ -6,11 +6,13 @@ import { render, screen, waitFor, + within, } from "@testing-library/react"; import { useEffect, type ComponentType } from "react"; import { createStore, Provider } from "jotai"; import { MemoryRouter, useLocation } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { CompactViewportOverrideProvider } from "@bb/shared-ui/hooks/use-compact-viewport"; import { AUTOMATIONS_PLUGIN_ID } from "@/lib/route-paths"; import { SidebarProvider } from "@/components/ui/sidebar.js"; @@ -28,6 +30,54 @@ import { PluginNavSidebarItems, } from "./PluginNavSidebarItems"; import { pluginNavPanelOrderAtom } from "./pluginNavSidebarAtoms"; +import { appToast } from "@/components/ui/app-toast"; + +vi.mock("@/components/ui/app-toast", () => ({ + appToast: { + dismiss: vi.fn(), + error: vi.fn(), + loading: vi.fn(), + message: vi.fn(), + success: vi.fn(), + warning: vi.fn(), + }, +})); + +function disabledPluginMutationResponse(id: string) { + return { + ok: true, + plugin: { + id, + source: `npm:${id}`, + rootDir: `/managed/plugins/${id}`, + version: "1.0.0", + provenance: "catalog", + isOrphanedBuiltin: false, + catalogEntryId: id, + publisherLabel: "BB Community", + sourceDisplay: `BB Community · ${id}`, + updateState: {}, + enabled: false, + description: null, + name: id, + icon: "Puzzle", + iconUrl: null, + status: "disabled", + statusDetail: null, + handlerStats: { count: 0, totalMs: 0, maxMs: 0, errorCount: 0 }, + services: [], + schedules: [], + cliCommand: null, + capabilities: [], + hasSettings: false, + app: { hasApp: true, bundle: null }, + logoUrl: null, + logoDarkUrl: null, + providerIds: [], + icons: {}, + }, + }; +} function registrationSet( overrides: Partial, @@ -74,27 +124,38 @@ function renderSidebarItems( options: { storedOrder?: string[]; compactViewport?: boolean; + initialEntry?: string; + splitEnabled?: boolean; } = {}, ) { const store = createStore(); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); // Seed the store rather than localStorage: the storage atom captured its // initial value when this module was imported, before the test could write. if (options.storedOrder) { store.set(pluginNavPanelOrderAtom, options.storedOrder); } - return render( + const view = render( - - - - - - - + + + + + + + + + + , ); + return { ...view, queryClient }; } function panelRowNames(labels: readonly string[]): string[] { @@ -106,6 +167,7 @@ function panelRowNames(labels: readonly string[]): string[] { } beforeEach(() => { + vi.clearAllMocks(); window.localStorage.clear(); resetAllCrashedPluginSlotsForTest(); // React reports errors caught by the slot boundary; keep expected crashes @@ -119,6 +181,7 @@ afterEach(() => { resetPluginSlotStoreForTest(); resetAllCrashedPluginSlotsForTest(); vi.restoreAllMocks(); + vi.unstubAllGlobals(); window.localStorage.clear(); }); @@ -228,6 +291,135 @@ describe("PluginNavSidebarItems", () => { expect(unmounts).toBe(0); }); + it("uses one complete icon-labelled menu for the options button and right-click", async () => { + registerPanel("docs", "Docs"); + renderSidebarItems({ + splitEnabled: true, + }); + + fireEvent.pointerDown( + screen.getByRole("button", { name: "Docs panel options" }), + { button: 0 }, + ); + const dropdownMenu = await screen.findByRole("menu"); + const expected = [ + ["Move to top", "ArrowUp"], + ["Move to overflow", "ArrowDown"], + ["Open in split", "Columns2"], + ["Detail page", "Info"], + ["Disable", "Pause"], + ] as const; + expect( + within(dropdownMenu) + .getAllByRole("menuitem") + .map((item) => item.textContent?.trim()), + ).toEqual(expected.map(([label]) => label)); + for (const [label, icon] of expected) { + expect( + within(dropdownMenu) + .getByRole("menuitem", { name: label }) + .querySelector(`[data-icon="${icon}"]`), + ).not.toBeNull(); + } + fireEvent.keyDown(dropdownMenu, { key: "Escape" }); + await waitFor(() => expect(screen.queryByRole("menu")).toBeNull()); + + fireEvent.contextMenu(screen.getByRole("button", { name: "Docs" })); + const contextMenu = await screen.findByRole("menu"); + expect( + within(contextMenu) + .getAllByRole("menuitem") + .map((item) => item.textContent?.trim()), + ).toEqual(expected.map(([label]) => label)); + + expect( + screen.queryByRole("menuitem", { name: /uninstall|remove/i }), + ).toBeNull(); + }); + + it("opens plugin details and omits split when the layout cannot split", async () => { + registerPanel("docs", "Docs"); + renderSidebarItems(); + + fireEvent.pointerDown( + screen.getByRole("button", { name: "Docs panel options" }), + { button: 0 }, + ); + expect( + screen.queryByRole("menuitem", { name: "Open in split" }), + ).toBeNull(); + fireEvent.click( + await screen.findByRole("menuitem", { name: "Detail page" }), + ); + expect(screen.getByTestId("location-path").textContent).toBe( + "/extensions/plugins/docs", + ); + }); + + it("disables a plugin, refreshes the plugin list, and leaves its active panel", async () => { + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify(disabledPluginMutationResponse("docs")), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + registerPanel("docs", "Docs"); + const { queryClient } = renderSidebarItems({ + initialEntry: "/plugins/docs/main", + }); + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries"); + + fireEvent.pointerDown( + screen.getByRole("button", { name: "Docs panel options" }), + { button: 0 }, + ); + fireEvent.click(await screen.findByRole("menuitem", { name: "Disable" })); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + const [input, init] = fetchMock.mock.calls[0] ?? []; + expect(String(input)).toBe("/api/v1/plugins/docs/disable"); + expect(init).toMatchObject({ method: "POST", body: "{}" }); + await waitFor(() => + expect(screen.getByTestId("location-path").textContent).toBe( + "/extensions/plugins", + ), + ); + expect(appToast.success).toHaveBeenCalledWith("Docs disabled"); + expect(invalidateQueries).toHaveBeenCalled(); + }); + + it("reports a disable failure without leaving the active panel", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response(JSON.stringify({ ok: false, error: "disable failed" }), { + status: 500, + headers: { "content-type": "application/json" }, + }), + ), + ); + registerPanel("docs", "Docs"); + renderSidebarItems({ initialEntry: "/plugins/docs/main" }); + + fireEvent.pointerDown( + screen.getByRole("button", { name: "Docs panel options" }), + { button: 0 }, + ); + fireEvent.click(await screen.findByRole("menuitem", { name: "Disable" })); + + await waitFor(() => + expect(appToast.error).toHaveBeenCalledWith("Failed to disable Docs", { + description: "HTTP 500: disable failed", + }), + ); + expect(screen.getByTestId("location-path").textContent).toBe( + "/plugins/docs/main", + ); + }); + it("does not mount sidebar accessories on compact viewports", () => { let mounts = 0; registerPanel("tasks", "Tasks", () => { diff --git a/apps/app/src/components/plugin/PluginNavSidebarItems.tsx b/apps/app/src/components/plugin/PluginNavSidebarItems.tsx index 8c1301731c..81756ab225 100644 --- a/apps/app/src/components/plugin/PluginNavSidebarItems.tsx +++ b/apps/app/src/components/plugin/PluginNavSidebarItems.tsx @@ -11,6 +11,7 @@ import { import { useLocation, useNavigate } from "react-router-dom"; import { useAtom } from "jotai"; import { DndContext, type DragEndEvent } from "@dnd-kit/core"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { SortableContext, verticalListSortingStrategy, @@ -22,12 +23,14 @@ import { ContextMenu, ContextMenuContent, ContextMenuItem, + ContextMenuSeparator, ContextMenuTrigger, } from "@bb/shared-ui/context-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@bb/shared-ui/dropdown-menu"; import { COARSE_POINTER_ICON_SIZE_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; @@ -37,7 +40,10 @@ import { PluginSlotMount } from "@/components/plugin/PluginSlotMount"; import { PROJECT_LIST_ACTION_BUTTON_CLASS } from "@/components/sidebar/ProjectList"; import { AUTOMATIONS_PLUGIN_ID, + getPluginDetailRoutePath, getPluginPanelRoutePath, + getPluginPanelRoutePluginId, + getPluginsRoutePath, } from "@/lib/route-paths"; import { usePluginNavPanelChrome, @@ -59,6 +65,9 @@ import { import { useSidebarSortable } from "@/components/sidebar/sortableMotion"; import { useSidebarReorderDnd } from "@/components/sidebar/useSidebarReorderDnd"; import type { SidebarSortableDragBindings } from "@/components/sidebar/sortableMotion"; +import { appToast } from "@/components/ui/app-toast"; +import { invalidatePluginList } from "@/hooks/cache-owners/plugin-cache-owner"; +import { setPluginEnabled } from "@/hooks/queries/plugin-settings-queries"; import { pluginNavPanelOrderAtom } from "./pluginNavSidebarAtoms"; import { arrangePluginNavPanels, @@ -132,8 +141,27 @@ function PluginNavSidebarItemList({ splitEnabled?: boolean; }) { const location = useLocation(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); const [storedOrder, setStoredOrder] = useAtom(pluginNavPanelOrderAtom); const [isOverflowOpen, setIsOverflowOpen] = useState(false); + const disable = useMutation({ + mutationFn: (row: SidebarNavRow) => + setPluginEnabled(fetch, row.pluginId, false), + onSuccess: (_result, row) => { + appToast.success(`${row.title} disabled`); + if (getPluginPanelRoutePluginId(location.pathname) === row.pluginId) { + onNavigate?.(); + void navigate(getPluginsRoutePath()); + } + }, + onError: (error, row) => { + appToast.error(`Failed to disable ${row.title}`, { + description: error instanceof Error ? error.message : String(error), + }); + }, + onSettled: () => invalidatePluginList({ queryClient }), + }); const traditionalStoredOrder = useMemo( () => storedOrder.filter((key) => !key.startsWith(`${AUTOMATIONS_PLUGIN_ID}/`)), @@ -214,6 +242,8 @@ function PluginNavSidebarItemList({ orderedKeys, onMoveToTop: handleMoveToTop, onMoveToOverflow: handleMoveToOverflow, + disablePending: disable.isPending, + onDisable: (row: SidebarNavRow) => disable.mutate(row), }; return ( @@ -326,6 +356,8 @@ interface SidebarNavRowItemProps { orderedKeys: readonly string[]; onMoveToTop: (key: string) => void; onMoveToOverflow: (key: string) => void; + disablePending: boolean; + onDisable: (row: SidebarNavRow) => void; dragBindings?: SidebarSortableDragBindings; rowRef?: (element: HTMLElement | null) => void; rowStyle?: CSSProperties; @@ -343,30 +375,118 @@ function SidebarNavRowItem({ type PluginNavRowMenuSurface = "context" | "dropdown"; -function PluginNavRowPositionMenuItems({ +function PluginNavRowMenuItem({ + children, + disabled = false, + icon, + onSelect, + surface, +}: { + children: ReactNode; + disabled?: boolean; + icon: "ArrowDown" | "ArrowUp" | "Columns2" | "Info" | "Pause"; + onSelect: () => void; + surface: PluginNavRowMenuSurface; +}) { + const content = ( + <> +