Files
kandev/apps/web/components/plugins/plugin-nav-items.test.tsx
T
João Salavisa adad46fab1 feat(plugins): richer frontend SDK — page chrome, nav icons, more host UI (#1759)
* feat(plugins): richer frontend SDK — page chrome, nav icons, more host UI

Give native UI plugins the primitives to render kandev-native pages while
keeping everything configurable:

- registerRoute gains an options arg; plugin routes get a first-party
  PageTopbar title bar by default (title/icon derived from the nav item for
  the same path, or set via { topbar: { title, subtitle, icon, backHref,
  backLabel, actions } }). Pass { topbar: false } to render full-bleed and
  own the chrome.
- NavItem.icon now resolves against a curated name map (lib/plugins/icons.ts);
  unknown/missing names fall back to the puzzle glyph.
- host.ui expands to the primitives a native-feeling page needs (Dialog,
  DropdownMenu, Pagination, Sheet, Select, Tabs, Table, Tooltip, ...) plus
  first-party PageTopbar, Combobox, and TaskCreateDialog. Plugins must use
  these host instances — a bundled second copy of any Radix/portal component
  splits React context.
- host.navigate(href, { replace? }) for soft SPA navigation into native
  routes, and host.api.baseUrl for reaching first-party REST without
  re-deriving the split-origin backend URL.

The registry records each plugin's display name for derived titles and
getRoutes() entries now carry pluginId + options. Contract doc
(PLUGIN-API.md) updated to match.

* fix(plugins): expose full Sheet subcomponent family on host.ui

host.ui advertised Sheet but only exposed Sheet/Content/Header/Title, so
plugins (which must use host instances for portal/context components)
couldn't build an uncontrolled sheet — no Trigger/Close/Footer/Description.
Add the missing four for parity with the Dialog family.

* fix(plugins): clear plugin display name on unregisterPlugin

unregisterPlugin bulk-revoked every other registration but left the entry
in pluginNames, so getPluginName returned a stale name after disable and
names leaked across enable/disable cycles. Delete the name too, with a
regression test.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-07-17 18:10:49 +01:00

109 lines
3.2 KiB
TypeScript

import { cleanup, render, screen } from "@testing-library/react";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { afterEach, describe, expect, it, vi } from "vitest";
import { pluginRegistry } from "@/lib/plugins/registry";
import { PluginNavItems } from "./plugin-nav-items";
let pathname = "/";
let pluginsEnabled = true;
const HELLO_PATH = "/plugins/hello";
function cleanupPlugins(...pluginIds: string[]) {
pluginIds.forEach((id) => pluginRegistry.unregisterPlugin(id));
}
vi.mock("@/lib/routing/client-router", () => ({
usePathname: () => pathname,
}));
vi.mock("@/hooks/domains/features/use-feature", () => ({
useFeature: () => pluginsEnabled,
}));
function renderNavItems(collapsed = false) {
return render(
<TooltipProvider>
<PluginNavItems collapsed={collapsed} />
</TooltipProvider>,
);
}
describe("PluginNavItems", () => {
afterEach(() => {
cleanup();
cleanupPlugins("plugin-a", "plugin-b");
pathname = "/";
pluginsEnabled = true;
window.history.pushState({}, "", "/");
});
it("renders nothing when no plugin has registered a nav item", () => {
const { container } = renderNavItems();
expect(container.innerHTML).toBe("");
});
it("renders a registered main-section nav item", () => {
pluginRegistry
.forPlugin("plugin-a")
.registerNavItem({ id: "hello", label: "Hello", path: HELLO_PATH });
renderNavItems();
expect(screen.getByTestId("plugin-nav-item-hello")).not.toBeNull();
expect(screen.getByText("Hello")).not.toBeNull();
});
it("omits a nav item registered for a non-main section", () => {
pluginRegistry.forPlugin("plugin-a").registerNavItem({
id: "settings-item",
label: "Settings Item",
path: "/settings/plugins/plugin-a",
section: "settings",
});
renderNavItems();
expect(screen.queryByTestId("plugin-nav-item-settings-item")).toBeNull();
});
it("renders nothing when the plugins feature flag is off, even with a registered nav item", () => {
pluginsEnabled = false;
pluginRegistry
.forPlugin("plugin-a")
.registerNavItem({ id: "hello", label: "Hello", path: HELLO_PATH });
const { container } = renderNavItems();
expect(container.innerHTML).toBe("");
});
it("renders the named curated icon, falling back to the puzzle glyph", () => {
pluginRegistry
.forPlugin("plugin-a")
.registerNavItem({ id: "hello", label: "Hello", path: HELLO_PATH, icon: "ticket" });
pluginRegistry
.forPlugin("plugin-b")
.registerNavItem({ id: "other", label: "Other", path: "/plugins/other", icon: "nope" });
renderNavItems();
expect(
screen.getByTestId("plugin-nav-item-hello").querySelector("svg.tabler-icon-ticket"),
).not.toBeNull();
expect(
screen.getByTestId("plugin-nav-item-other").querySelector("svg.tabler-icon-puzzle"),
).not.toBeNull();
});
it("navigates to item.path when clicked", () => {
pluginRegistry
.forPlugin("plugin-a")
.registerNavItem({ id: "hello", label: "Hello", path: HELLO_PATH });
renderNavItems();
screen.getByTestId("plugin-nav-item-hello").click();
expect(window.location.pathname).toBe(HELLO_PATH);
});
});