adad46fab1
* 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>
160 lines
5.0 KiB
TypeScript
160 lines
5.0 KiB
TypeScript
import { describe, it, expect, afterEach } from "vitest";
|
|
import { pluginRegistry } from "./registry";
|
|
|
|
const TASK_SIDEBAR_SLOT = "task-sidebar";
|
|
const TASK_CREATED_ACTION = "task.created";
|
|
|
|
function cleanup(...pluginIds: string[]) {
|
|
pluginIds.forEach((id) => pluginRegistry.unregisterPlugin(id));
|
|
}
|
|
|
|
describe("pluginRegistry", () => {
|
|
afterEach(() => {
|
|
cleanup("plugin-a", "plugin-b");
|
|
});
|
|
|
|
it("registers and returns a route via the scoped registry view", () => {
|
|
const scoped = pluginRegistry.forPlugin("plugin-a");
|
|
function Page() {
|
|
return null;
|
|
}
|
|
|
|
scoped.registerRoute("/plugin-a", Page);
|
|
|
|
const routes = pluginRegistry.getRoutes();
|
|
expect(routes).toContainEqual({
|
|
pluginId: "plugin-a",
|
|
path: "/plugin-a",
|
|
Component: Page,
|
|
options: undefined,
|
|
});
|
|
});
|
|
|
|
it("registers and returns a nav item", () => {
|
|
const scoped = pluginRegistry.forPlugin("plugin-a");
|
|
|
|
scoped.registerNavItem({ id: "nav-a", label: "A", path: "/plugin-a" });
|
|
|
|
expect(pluginRegistry.getNavItems()).toContainEqual({
|
|
id: "nav-a",
|
|
label: "A",
|
|
path: "/plugin-a",
|
|
});
|
|
});
|
|
|
|
it("registers and returns a settings route", () => {
|
|
const scoped = pluginRegistry.forPlugin("plugin-a");
|
|
function Settings() {
|
|
return null;
|
|
}
|
|
|
|
scoped.registerSettingsRoute("/settings/plugins/plugin-a", Settings);
|
|
|
|
expect(pluginRegistry.getSettingsRoutes()).toContainEqual({
|
|
path: "/settings/plugins/plugin-a",
|
|
Component: Settings,
|
|
});
|
|
});
|
|
|
|
it("registers a slot component and only returns it for the matching slot", () => {
|
|
const scoped = pluginRegistry.forPlugin("plugin-a");
|
|
function Sidebar() {
|
|
return null;
|
|
}
|
|
|
|
scoped.registerComponent(TASK_SIDEBAR_SLOT, Sidebar);
|
|
|
|
expect(pluginRegistry.getSlotComponents(TASK_SIDEBAR_SLOT)).toEqual([Sidebar]);
|
|
expect(pluginRegistry.getSlotComponents("settings-nav")).toEqual([]);
|
|
});
|
|
|
|
it("registers a WS handler and only returns it for the matching action", () => {
|
|
const scoped = pluginRegistry.forPlugin("plugin-a");
|
|
const handler = () => {};
|
|
|
|
scoped.registerWsHandler(TASK_CREATED_ACTION, handler);
|
|
|
|
expect(pluginRegistry.getWsHandlers(TASK_CREATED_ACTION)).toEqual([handler]);
|
|
expect(pluginRegistry.getWsHandlers("task.deleted")).toEqual([]);
|
|
});
|
|
|
|
it("bulk-revokes every registration owned by a plugin on unregisterPlugin", () => {
|
|
const scopedA = pluginRegistry.forPlugin("plugin-a");
|
|
const scopedB = pluginRegistry.forPlugin("plugin-b");
|
|
function PageA() {
|
|
return null;
|
|
}
|
|
function PageB() {
|
|
return null;
|
|
}
|
|
|
|
scopedA.registerRoute("/plugin-a", PageA);
|
|
scopedA.registerNavItem({ id: "nav-a", label: "A", path: "/plugin-a" });
|
|
scopedA.registerComponent(TASK_SIDEBAR_SLOT, PageA);
|
|
scopedA.registerWsHandler(TASK_CREATED_ACTION, () => {});
|
|
scopedB.registerRoute("/plugin-b", PageB);
|
|
|
|
pluginRegistry.unregisterPlugin("plugin-a");
|
|
|
|
expect(pluginRegistry.getRoutes()).toEqual([
|
|
{ pluginId: "plugin-b", path: "/plugin-b", Component: PageB, options: undefined },
|
|
]);
|
|
expect(pluginRegistry.getNavItems().find((item) => item.id === "nav-a")).toBeUndefined();
|
|
expect(pluginRegistry.getSlotComponents(TASK_SIDEBAR_SLOT)).toEqual([]);
|
|
expect(pluginRegistry.getWsHandlers(TASK_CREATED_ACTION)).toEqual([]);
|
|
});
|
|
|
|
it("notifies subscribers when a registration is added", () => {
|
|
const scoped = pluginRegistry.forPlugin("plugin-a");
|
|
let notified = 0;
|
|
const unsubscribe = pluginRegistry.subscribe(() => {
|
|
notified += 1;
|
|
});
|
|
|
|
scoped.registerNavItem({ id: "nav-a", label: "A", path: "/plugin-a" });
|
|
|
|
unsubscribe();
|
|
expect(notified).toBe(1);
|
|
});
|
|
|
|
it("does not notify subscribers when unregistering a plugin with no registrations", () => {
|
|
let notified = 0;
|
|
const unsubscribe = pluginRegistry.subscribe(() => {
|
|
notified += 1;
|
|
});
|
|
|
|
pluginRegistry.unregisterPlugin("plugin-with-nothing-registered");
|
|
|
|
unsubscribe();
|
|
expect(notified).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("pluginRegistry — route options and plugin names", () => {
|
|
afterEach(() => {
|
|
cleanup("plugin-a");
|
|
});
|
|
|
|
it("stores route options and the plugin display name for page chrome", () => {
|
|
const scoped = pluginRegistry.forPlugin("plugin-a", "Plugin A");
|
|
function Page() {
|
|
return null;
|
|
}
|
|
|
|
scoped.registerRoute("/plugin-a", Page, { topbar: { title: "Custom", icon: "ticket" } });
|
|
|
|
const route = pluginRegistry.getRoutes().find((entry) => entry.path === "/plugin-a");
|
|
expect(route?.options).toEqual({ topbar: { title: "Custom", icon: "ticket" } });
|
|
expect(pluginRegistry.getPluginName("plugin-a")).toBe("Plugin A");
|
|
});
|
|
|
|
it("clears the plugin display name on unregisterPlugin", () => {
|
|
pluginRegistry.forPlugin("plugin-a", "Plugin A");
|
|
expect(pluginRegistry.getPluginName("plugin-a")).toBe("Plugin A");
|
|
|
|
pluginRegistry.unregisterPlugin("plugin-a");
|
|
|
|
expect(pluginRegistry.getPluginName("plugin-a")).toBeUndefined();
|
|
});
|
|
});
|