feat(plugins): allow plugin nav items in the sidebar Integrations section (#1767)

* feat(plugins): allow plugin nav items in the sidebar Integrations section

Adds "integrations" to the NavItem section contract: plugin-registered nav
items with that section render inside the sidebar's Integrations section
after the first-party links, gated on the plugins feature flag. The section
now also shows when only plugin items exist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): don't render an empty header-action slot with zero integration links

AppSidebarSection shows its header-action slot whenever headerAction is
non-null, regardless of content. IntegrationsSection always passed an
IntegrationHeaderShortcuts element, so the plugin-only case (zero configured
first-party integrations) rendered an empty slot with a stray 4px margin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(plugins): narrow the useFeature mock to the requested flag name

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
João Salavisa
2026-07-18 00:17:29 +01:00
committed by GitHub
parent 165bdc59b9
commit fa278e8dff
4 changed files with 139 additions and 21 deletions
@@ -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("");
});
});
@@ -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 (
<Link
href={href}
data-testid={testId}
className={cn(
"flex items-center gap-2.5 px-2.5 py-1.5 text-[13px] font-medium rounded-md cursor-pointer",
active ? SIDEBAR_ITEM_ACTIVE : SIDEBAR_ITEM_INACTIVE,
)}
>
<Icon className="h-4 w-4 shrink-0" />
<span className="flex-1 truncate">{label}</span>
</Link>
);
}
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 (
<AppSidebarSection
@@ -72,26 +108,28 @@ export function IntegrationsSection({ collapsed }: IntegrationsSectionProps) {
label="Integrations"
collapsed={collapsed}
icon={IconPlugConnected}
headerAction={<IntegrationHeaderShortcuts links={links} />}
headerAction={links.length > 0 ? <IntegrationHeaderShortcuts links={links} /> : undefined}
headerActionVisibility="always"
>
{links.map(({ id, label, href }) => {
const Icon = INTEGRATION_ICONS[id] ?? IconPlugConnected;
const isActive = pathname === href || pathname.startsWith(`${href}/`);
return (
<Link
key={id}
href={href}
className={cn(
"flex items-center gap-2.5 px-2.5 py-1.5 text-[13px] font-medium rounded-md cursor-pointer",
isActive ? SIDEBAR_ITEM_ACTIVE : SIDEBAR_ITEM_INACTIVE,
)}
>
<Icon className="h-4 w-4 shrink-0" />
<span className="flex-1 truncate">{label}</span>
</Link>
);
})}
{links.map(({ id, label, href }) => (
<IntegrationRow
key={id}
href={href}
label={label}
icon={INTEGRATION_ICONS[id] ?? IconPlugConnected}
active={pathname === href || pathname.startsWith(`${href}/`)}
/>
))}
{pluginItems.map((item) => (
<IntegrationRow
key={`plugin-${item.id}`}
href={item.path}
label={item.label}
icon={resolvePluginIcon(item.icon)}
active={pathname === item.path || pathname.startsWith(`${item.path}/`)}
testId={`plugin-nav-item-${item.id}`}
/>
))}
</AppSidebarSection>
);
}
+6 -1
View File
@@ -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";
}
/**
+5 -1
View File
@@ -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).