Files
kandev/apps/web/lib/plugins/host-api.ts
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

202 lines
5.5 KiB
TypeScript

/**
* Builds the `PluginHostApi` (docs/plans/plugins/PLUGIN-API.md) passed
* into a plugin's `initialize(registry, host)`.
*/
import * as React from "react";
import type { StoreApi } from "zustand";
import { Alert, AlertDescription, AlertTitle } from "@kandev/ui/alert";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@kandev/ui/card";
import { Checkbox } from "@kandev/ui/checkbox";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@kandev/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
import { Input } from "@kandev/ui/input";
import { Label } from "@kandev/ui/label";
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@kandev/ui/pagination";
import { ScrollArea } from "@kandev/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { Separator } from "@kandev/ui/separator";
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@kandev/ui/sheet";
import { Skeleton } from "@kandev/ui/skeleton";
import { Spinner } from "@kandev/ui/spinner";
import { Switch } from "@kandev/ui/switch";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@kandev/ui/tabs";
import { Textarea } from "@kandev/ui/textarea";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { Combobox } from "@/components/combobox";
import { PageTopbar } from "@/components/page-topbar";
import { TaskCreateDialog } from "@/components/task-create-dialog";
import { getBackendConfig } from "@/lib/config";
import { softNavigate } from "@/lib/routing/client-router";
import type { AppState } from "@/lib/state/store";
import type { PluginHostApi } from "./types";
/**
* Curated `@kandev/ui` subset exposed on `host.ui`, plus a handful of
* first-party app components (bottom of the map). Plugins must use these
* host instances rather than bundling their own copies — bundling is not an
* option for anything that touches React context or portals (Radix), since
* the plugin shares the host React instance and a second copy would split
* context and break refs/asChild. Pure-React libs (e.g. icon sets) bundle
* fine.
*/
const PLUGIN_UI: Record<string, unknown> = {
Alert,
AlertDescription,
AlertTitle,
Badge,
Button,
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
Checkbox,
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Input,
Label,
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
ScrollArea,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Separator,
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
Skeleton,
Spinner,
Switch,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
Textarea,
Tooltip,
TooltipContent,
TooltipTrigger,
// App UI (not shadcn primitives), exposed so plugins compose kandev-native
// surfaces instead of re-implementing them:
// - Combobox: the app's Command+Popover picker (used by native toolbars).
Combobox,
// - PageTopbar: the first-party title bar. Plugin routes get one by default
// (registerRoute options.topbar); this export is for routes that opt out
// (`topbar: false`) and render their own chrome.
PageTopbar,
// - TaskCreateDialog: kandev's real create-task modal, prefilled via
// initialValues, so plugins hand off task creation to the native flow
// instead of POSTing directly.
TaskCreateDialog,
};
export function buildHostApi(
pluginId: string,
storeApi: StoreApi<AppState>,
theme: "light" | "dark",
): PluginHostApi {
return {
pluginId,
React,
jsx: React.createElement,
store: {
getState: storeApi.getState,
setState: storeApi.setState,
subscribe: storeApi.subscribe,
},
api: {
fetch: (path, init) => fetchPluginApi(pluginId, path, init),
// Getter so split-origin dev/desktop always sees the current backend
// origin, matching what fetchPluginApi resolves per call.
get baseUrl() {
return getBackendConfig().apiBaseUrl;
},
},
ui: PLUGIN_UI,
theme,
navigate: (href, options) => softNavigate(href, options?.replace ? "replace" : "push"),
};
}
/** fetch scoped to `/api/plugins/{pluginId}/...` via the kandev reverse proxy. */
function fetchPluginApi(pluginId: string, path: string, init?: RequestInit): Promise<Response> {
const { apiBaseUrl } = getBackendConfig();
const suffix = path.startsWith("/") ? path : `/${path}`;
const url = `${apiBaseUrl}/api/plugins/${encodeURIComponent(pluginId)}${suffix}`;
return fetch(url, init);
}