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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user