Files
kandev/apps/web/components/plugins/plugin-page.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

108 lines
3.5 KiB
TypeScript

import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { pluginRegistry } from "@/lib/plugins/registry";
import type { PluginRouteRegistration } from "@/lib/plugins/registry";
import { PluginPageFrame, resolvePluginPageChrome } from "./plugin-page";
const PLUGIN_ID = "plugin-a";
const PLUGIN_PATH = "/plugins/hello";
const PLUGIN_NAME = "Hello Plugin";
function Page() {
return null;
}
function registration(options?: PluginRouteRegistration["options"]): PluginRouteRegistration {
return { pluginId: PLUGIN_ID, path: PLUGIN_PATH, Component: Page, options };
}
describe("resolvePluginPageChrome", () => {
it("returns null when the registration opted out via topbar: false", () => {
expect(resolvePluginPageChrome(registration({ topbar: false }), [], "Hello")).toBeNull();
});
it("derives the title from the nav item registered for the same path", () => {
const chrome = resolvePluginPageChrome(
registration(),
[{ id: "nav", label: "Hello Nav", path: PLUGIN_PATH, icon: "ticket" }],
PLUGIN_NAME,
);
expect(chrome).toMatchObject({ title: "Hello Nav", icon: "ticket" });
});
it("falls back to the plugin display name, then the route path", () => {
expect(resolvePluginPageChrome(registration(), [], PLUGIN_NAME)?.title).toBe(PLUGIN_NAME);
expect(resolvePluginPageChrome(registration(), [], undefined)?.title).toBe(PLUGIN_PATH);
});
it("prefers explicit chrome config over every derived default", () => {
const chrome = resolvePluginPageChrome(
registration({
topbar: { title: "Custom", subtitle: "Sub", icon: "chart", backHref: "/x", backLabel: "X" },
}),
[{ id: "nav", label: "Nav Label", path: PLUGIN_PATH, icon: "ticket" }],
PLUGIN_NAME,
);
expect(chrome).toEqual({
title: "Custom",
subtitle: "Sub",
icon: "chart",
backHref: "/x",
backLabel: "X",
Actions: undefined,
});
});
});
describe("PluginPageFrame", () => {
afterEach(() => {
cleanup();
pluginRegistry.unregisterPlugin(PLUGIN_ID);
});
it("renders a kandev topbar with the derived title by default", () => {
pluginRegistry.forPlugin(PLUGIN_ID, PLUGIN_NAME);
render(
<PluginPageFrame registration={registration()}>
<div data-testid="plugin-content" />
</PluginPageFrame>,
);
expect(screen.getByRole("banner")).not.toBeNull();
expect(screen.getByText(PLUGIN_NAME)).not.toBeNull();
expect(screen.getByTestId("plugin-content")).not.toBeNull();
});
it("renders the children bare when the route opted out of the topbar", () => {
render(
<PluginPageFrame registration={registration({ topbar: false })}>
<div data-testid="plugin-content" />
</PluginPageFrame>,
);
expect(screen.queryByRole("banner")).toBeNull();
expect(screen.getByTestId("plugin-content")).not.toBeNull();
});
it("renders configured subtitle and a plugin-provided actions component", () => {
function Actions() {
return <button data-testid="plugin-topbar-action">Sync</button>;
}
render(
<PluginPageFrame
registration={registration({
topbar: { title: "Custom", subtitle: "Tickets", actions: Actions },
})}
>
<div />
</PluginPageFrame>,
);
expect(screen.getByText("Custom")).not.toBeNull();
expect(screen.getByText("Tickets")).not.toBeNull();
expect(screen.getByTestId("plugin-topbar-action")).not.toBeNull();
});
});