diff --git a/apps/web/components/app-sidebar/sections/integrations-section.test.tsx b/apps/web/components/app-sidebar/sections/integrations-section.test.tsx index 943b5fa575..2639530eb5 100644 --- a/apps/web/components/app-sidebar/sections/integrations-section.test.tsx +++ b/apps/web/components/app-sidebar/sections/integrations-section.test.tsx @@ -40,6 +40,27 @@ vi.mock("@/components/integrations/integrations-menu", () => ({ useConfiguredIntegrationLinks: linksMock, })); +const pluginsMock = vi.hoisted(() => ({ + enabled: true, + navItems: [] as Array<{ + id: string; + label: string; + path: string; + icon?: string; + section?: string; + }>, +})); + +vi.mock("@/hooks/domains/features/use-feature", () => ({ + useFeature: (flag: string) => flag === "plugins" && pluginsMock.enabled, +})); + +vi.mock("@/lib/plugins/registry", () => ({ + usePluginRegistry: () => ({ + getNavItems: () => pluginsMock.navItems, + }), +})); + vi.mock("@kandev/ui/collapsible", () => ({ Collapsible: ({ children, open }: { children: ReactNode; open?: boolean }) => { collapsibleMock.open = !!open; @@ -69,6 +90,8 @@ describe("IntegrationsSection", () => { { id: "github", label: "GitHub", href: "/github" }, { id: "jira", label: "Jira", href: "/jira" }, ]); + pluginsMock.enabled = true; + pluginsMock.navItems = []; }); afterEach(() => cleanup()); @@ -115,4 +138,52 @@ describe("IntegrationsSection", () => { expect(screen.getAllByTestId("integration-header-shortcut")).toHaveLength(4); expect(screen.getByRole("link", { name: "Sentry" })).toBeTruthy(); }); + + const costPerModelItem = { + id: "cost-per-model", + label: "Cost per Model", + path: "/cost-per-model", + icon: "chart", + section: "integrations", + }; + const costPerModelTestId = `plugin-nav-item-${costPerModelItem.id}`; + + it("renders plugin nav items registered with section integrations after the first-party links", () => { + storeState.appSidebar.sectionExpanded.integrations = true; + pluginsMock.navItems = [ + costPerModelItem, + { id: "hello", label: "Hello", path: "/hello", section: "main" }, + ]; + + renderSection(); + + const pluginRow = screen.getByTestId(costPerModelTestId); + expect(pluginRow.getAttribute("href")).toBe(costPerModelItem.path); + expect(screen.queryByRole("link", { name: "Hello" })).toBeNull(); + }); + + it("shows the section when only plugin integration items exist, with no empty header-action slot", () => { + storeState.appSidebar.sectionExpanded.integrations = true; + linksMock.mockReturnValue([]); + pluginsMock.navItems = [costPerModelItem]; + + const { container } = renderSection(); + + expect(screen.getByTestId(costPerModelTestId)).toBeTruthy(); + // Regression for the empty headerAction slot: AppSidebarSection renders + // a "shrink-0 mr-1 flex items-center" wrapper whenever headerAction is + // non-null, even with zero shortcuts inside it. + expect(container.querySelector(".shrink-0.mr-1")).toBeNull(); + }); + + it("hides plugin items (and an otherwise empty section) when the plugins feature is off", () => { + storeState.appSidebar.sectionExpanded.integrations = true; + linksMock.mockReturnValue([]); + pluginsMock.enabled = false; + pluginsMock.navItems = [costPerModelItem]; + + const { container } = renderSection(); + + expect(container.textContent).toBe(""); + }); }); diff --git a/apps/web/components/app-sidebar/sections/integrations-section.tsx b/apps/web/components/app-sidebar/sections/integrations-section.tsx index 45d129bc74..44ca131a9b 100644 --- a/apps/web/components/app-sidebar/sections/integrations-section.tsx +++ b/apps/web/components/app-sidebar/sections/integrations-section.tsx @@ -12,6 +12,9 @@ import { import type { Icon as TablerIcon } from "@tabler/icons-react"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { useConfiguredIntegrationLinks } from "@/components/integrations/integrations-menu"; +import { useFeature } from "@/hooks/domains/features/use-feature"; +import { resolvePluginIcon } from "@/lib/plugins/icons"; +import { usePluginRegistry } from "@/lib/plugins/registry"; import { cn } from "@/lib/utils"; import { APP_SIDEBAR_SECTION_IDS, @@ -60,11 +63,44 @@ function IntegrationHeaderShortcuts({ links }: { links: ConfiguredIntegrationLin ); } +type IntegrationRowProps = { + href: string; + label: string; + icon: TablerIcon; + active: boolean; + testId?: string; +}; + +function IntegrationRow({ href, label, icon: Icon, active, testId }: IntegrationRowProps) { + return ( + + + {label} + + ); +} + export function IntegrationsSection({ collapsed }: IntegrationsSectionProps) { const pathname = usePathname(); const links = useConfiguredIntegrationLinks(); + // Plugin-registered nav items that target this section + // (`registerNavItem({ section: "integrations" })`), rendered after the + // first-party links. Gated on the "plugins" feature flag like every other + // plugin surface. + const pluginsEnabled = useFeature("plugins"); + const registry = usePluginRegistry(); + const pluginItems = pluginsEnabled + ? registry.getNavItems().filter((item) => item.section === "integrations") + : []; - if (links.length === 0) return null; + if (links.length === 0 && pluginItems.length === 0) return null; return ( } + headerAction={links.length > 0 ? : undefined} headerActionVisibility="always" > - {links.map(({ id, label, href }) => { - const Icon = INTEGRATION_ICONS[id] ?? IconPlugConnected; - const isActive = pathname === href || pathname.startsWith(`${href}/`); - return ( - - - {label} - - ); - })} + {links.map(({ id, label, href }) => ( + + ))} + {pluginItems.map((item) => ( + + ))} ); } diff --git a/apps/web/lib/plugins/types.ts b/apps/web/lib/plugins/types.ts index 8ada82b218..5cb2a5c2ef 100644 --- a/apps/web/lib/plugins/types.ts +++ b/apps/web/lib/plugins/types.ts @@ -21,7 +21,12 @@ export interface NavItem { path: string; /** Curated icon name (see `lib/plugins/icons.ts`); unknown names render the puzzle glyph. */ icon?: string; - section?: "main" | "settings"; + /** + * Where the item renders: "main" (default) as a top-level sidebar entry, + * "integrations" inside the sidebar's Integrations section alongside the + * first-party integration links. + */ + section?: "main" | "settings" | "integrations"; } /** diff --git a/docs/plans/plugins/PLUGIN-API.md b/docs/plans/plugins/PLUGIN-API.md index 6790f85cfc..df372ceba8 100644 --- a/docs/plans/plugins/PLUGIN-API.md +++ b/docs/plans/plugins/PLUGIN-API.md @@ -82,7 +82,11 @@ fine. // icon: curated icon name (apps/web/lib/plugins/icons.ts — "ticket", "chart", // "robot", "database", ...); unknown/missing names render a puzzle glyph in // the sidebar. -interface NavItem { id: string; label: string; path: string; icon?: string; section?: "main" | "settings"; } +// section: "main" (default) renders as a top-level sidebar entry; +// "integrations" renders inside the sidebar's Integrations section alongside +// the first-party integration links (GitHub, Jira, ...). Hosts predating a +// section value simply don't render items targeting it (additive change). +interface NavItem { id: string; label: string; path: string; icon?: string; section?: "main" | "settings" | "integrations"; } // Configuration for the kandev-style title bar the host renders above a plugin // route. All fields optional; defaults are derived (see registerRoute below).