feat(plugins): expand host.ui and add host.toast / host.utils (#2410)
Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
@@ -12,7 +12,9 @@
|
||||
* The plugin page also renders a `host.theme` readout kept current purely
|
||||
* through `host.onThemeChange`, so an e2e can flip the app theme for real and
|
||||
* prove the subscription fires in a browser (jsdom's MutationObserver is a
|
||||
* shim, and jsdom cannot open a Radix tooltip from synthetic hover at all).
|
||||
* shim, and jsdom cannot open a Radix tooltip from synthetic hover at all),
|
||||
* plus a button that fires `host.toast.error` against the real sonner
|
||||
* instance the app mounts.
|
||||
*
|
||||
* It also registers the plugin-hooks surface this fixture exists to drive
|
||||
* end to end: a "Notes" task panel (registerTaskPanel, mobile-enabled) that
|
||||
@@ -97,6 +99,20 @@
|
||||
},
|
||||
themeState.theme,
|
||||
),
|
||||
// host.toast is a per-plugin Proxy over sonner; unit tests only ever
|
||||
// see it against a mocked sonner, so this button is how an e2e
|
||||
// proves a real toast renders and that .error does NOT file a
|
||||
// frontend-error report.
|
||||
jsx(
|
||||
"button",
|
||||
{
|
||||
"data-testid": "hello-toast-error",
|
||||
onClick: function () {
|
||||
host.toast.error("Plugin toast error");
|
||||
},
|
||||
},
|
||||
"toast error",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -494,6 +494,57 @@ test.describe("Plugins — gRPC plugin install/load/live-update/uninstall", () =
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// host.toast is a per-plugin Proxy over sonner. The unit tests only ever see
|
||||
// it against a mocked sonner, so nothing has observed its runtime behavior:
|
||||
// that a real toast renders, and that `.error` does NOT file a
|
||||
// frontend-error report the way the app's own reporting seam does.
|
||||
test("host.toast.error renders a real toast and files no frontend-error report", async ({
|
||||
testPage,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
await openInstallDialog(testPage);
|
||||
await uploadPackage(testPage, PACKAGE_PATH);
|
||||
await expect(testPage.getByTestId(`plugin-row-${PLUGIN_ID}`)).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await testPage.goto("/");
|
||||
await testPage.reload();
|
||||
await testPage.getByTestId(`plugin-nav-item-${NAV_ITEM_ID}`).click();
|
||||
|
||||
const reportRequests: string[] = [];
|
||||
testPage.on("request", (request) => {
|
||||
if (request.url().includes("/api/v1/system/logs/frontend-errors")) {
|
||||
reportRequests.push(request.url());
|
||||
}
|
||||
});
|
||||
const consoleErrors: string[] = [];
|
||||
testPage.on("console", (message) => {
|
||||
if (message.type() === "error") consoleErrors.push(message.text());
|
||||
});
|
||||
|
||||
const trigger = testPage.getByTestId("hello-toast-error");
|
||||
await expect(trigger).toBeVisible({ timeout: 15_000 });
|
||||
await trigger.click();
|
||||
|
||||
// The real sonner <Toaster/> the app mounts in app/layout.tsx.
|
||||
await expect(
|
||||
testPage.locator("[data-sonner-toast]").filter({ hasText: "Plugin toast error" }),
|
||||
).toBeVisible();
|
||||
|
||||
// The load-bearing assertion: the app's reporting seam would POST here on
|
||||
// every .error, so a polling plugin would file an Error-level backend log
|
||||
// entry per cycle. The report is scheduled in a microtask and sent async,
|
||||
// so give it a real chance to fire before asserting it never did.
|
||||
await testPage.waitForTimeout(500);
|
||||
expect(reportRequests).toEqual([]);
|
||||
|
||||
// Attribution goes to the console instead, matching every other plugin
|
||||
// failure path.
|
||||
expect(consoleErrors.some((line) => line.includes(`toast.error from "${PLUGIN_ID}"`))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
// host.theme is a live getter and host.onThemeChange is backed by a
|
||||
// MutationObserver on <html>'s class. jsdom's MutationObserver is a shim, so
|
||||
// only a browser proves a real theme flip reaches a plugin subscriber.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { formatDate, formatNumber, formatRelative } from "./formats";
|
||||
import { formatDate, formatNumber, formatRelative, formatRelativeTime } from "./formats";
|
||||
import { activateLocale } from "./index";
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -84,3 +84,122 @@ describe("locale-aware Intl wrappers", () => {
|
||||
await activateLocale("en");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `host.utils.formatRelativeTime` (docs/plans/plugins/PLUGIN-API.md). Unlike
|
||||
* `formatRelative` above, this one goes through `Intl.RelativeTimeFormat`
|
||||
* rather than the message catalog, so it covers the full range up to years
|
||||
* and localizes without any keys — which is the point: three published
|
||||
* plugins ship hand-rolled English-only versions of this.
|
||||
*/
|
||||
const THREE_HOURS_AGO = "3 hours ago";
|
||||
|
||||
describe("formatRelativeTime (en)", () => {
|
||||
const now = new Date("2026-07-27T12:00:00Z").getTime();
|
||||
const secondsAgo = (n: number) => new Date(now - n * 1000);
|
||||
const secondsAhead = (n: number) => new Date(now + n * 1000);
|
||||
|
||||
it("returns an empty string for unparseable input", () => {
|
||||
expect(formatRelativeTime("not-a-date", now)).toBe("");
|
||||
expect(formatRelativeTime("", now)).toBe("");
|
||||
});
|
||||
|
||||
it("collapses anything within ten seconds to 'now'", () => {
|
||||
expect(formatRelativeTime(secondsAgo(0), now)).toBe("now");
|
||||
expect(formatRelativeTime(secondsAgo(9), now)).toBe("now");
|
||||
expect(formatRelativeTime(secondsAhead(9), now)).toBe("now");
|
||||
});
|
||||
|
||||
// The threshold is tested against raw milliseconds, not the rounded second
|
||||
// count. Rounding first moved the real cutoff to 9.5s and made it
|
||||
// asymmetric — Math.round(-9.5) is -9 but Math.round(9.5) is 10 — so 9.5s
|
||||
// ago read "now" while 9.5s ahead read "in 10 seconds". Reported by
|
||||
// @yattdev on #2410.
|
||||
it("uses a symmetric 10s 'now' cutoff that sub-second input cannot shift", () => {
|
||||
const ms = (n: number) => new Date(now - n);
|
||||
|
||||
expect(formatRelativeTime(ms(9_400), now)).toBe("now");
|
||||
expect(formatRelativeTime(ms(9_600), now)).toBe("now");
|
||||
expect(formatRelativeTime(ms(9_900), now)).toBe("now");
|
||||
expect(formatRelativeTime(ms(9_999), now)).toBe("now");
|
||||
|
||||
expect(formatRelativeTime(ms(10_000), now)).toBe("10 seconds ago");
|
||||
expect(formatRelativeTime(ms(10_100), now)).toBe("10 seconds ago");
|
||||
});
|
||||
|
||||
it("applies the same cutoff to future timestamps as to past ones", () => {
|
||||
const ahead = (n: number) => new Date(now + n);
|
||||
|
||||
expect(formatRelativeTime(ahead(9_500), now)).toBe("now");
|
||||
expect(formatRelativeTime(ahead(9_900), now)).toBe("now");
|
||||
expect(formatRelativeTime(ahead(10_000), now)).toBe("in 10 seconds");
|
||||
});
|
||||
|
||||
// The other half of the report — 59.6s rendering as "1 minute ago" — is
|
||||
// intended. Unit selection rounds to the nearest unit, which describes
|
||||
// 59.6s better than "59 seconds ago" would.
|
||||
it("selects the nearest unit rather than truncating toward zero", () => {
|
||||
expect(formatRelativeTime(new Date(now - 59_600), now)).toBe("1 minute ago");
|
||||
expect(formatRelativeTime(new Date(now - 59_400), now)).toBe("59 seconds ago");
|
||||
});
|
||||
|
||||
it("reports whole seconds below a minute", () => {
|
||||
expect(formatRelativeTime(secondsAgo(10), now)).toBe("10 seconds ago");
|
||||
expect(formatRelativeTime(secondsAgo(59), now)).toBe("59 seconds ago");
|
||||
});
|
||||
|
||||
it("switches to minutes, hours, days and weeks at each boundary", () => {
|
||||
expect(formatRelativeTime(secondsAgo(60), now)).toBe("1 minute ago");
|
||||
expect(formatRelativeTime(secondsAgo(90), now)).toBe("1 minute ago");
|
||||
expect(formatRelativeTime(secondsAgo(60 * 60), now)).toBe("1 hour ago");
|
||||
expect(formatRelativeTime(secondsAgo(3 * 60 * 60), now)).toBe(THREE_HOURS_AGO);
|
||||
expect(formatRelativeTime(secondsAgo(2 * 24 * 60 * 60), now)).toBe("2 days ago");
|
||||
expect(formatRelativeTime(secondsAgo(3 * 7 * 24 * 60 * 60), now)).toBe("3 weeks ago");
|
||||
expect(formatRelativeTime(secondsAgo(800 * 24 * 60 * 60), now)).toBe("2 years ago");
|
||||
});
|
||||
|
||||
// numeric: "auto" is deliberate — CLDR phrases the small magnitudes
|
||||
// idiomatically, which no interpolated number can do across locales.
|
||||
it("uses idiomatic phrasing for a single unit either side", () => {
|
||||
expect(formatRelativeTime(secondsAgo(24 * 60 * 60), now)).toBe("yesterday");
|
||||
expect(formatRelativeTime(secondsAhead(24 * 60 * 60), now)).toBe("tomorrow");
|
||||
expect(formatRelativeTime(secondsAgo(400 * 24 * 60 * 60), now)).toBe("last year");
|
||||
});
|
||||
|
||||
it("phrases future timestamps as future", () => {
|
||||
expect(formatRelativeTime(secondsAhead(3 * 60 * 60), now)).toBe("in 3 hours");
|
||||
expect(formatRelativeTime(secondsAhead(5 * 24 * 60 * 60), now)).toBe("in 5 days");
|
||||
});
|
||||
|
||||
it("accepts a Date, an epoch number, and an ISO string alike", () => {
|
||||
const date = secondsAgo(3 * 60 * 60);
|
||||
expect(formatRelativeTime(date, now)).toBe(THREE_HOURS_AGO);
|
||||
expect(formatRelativeTime(date.getTime(), now)).toBe(THREE_HOURS_AGO);
|
||||
expect(formatRelativeTime(date.toISOString(), now)).toBe(THREE_HOURS_AGO);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The reason this lives in the host rather than in each plugin. A hand-rolled
|
||||
* "3 hours ago" ladder is English-only by construction; Intl reads the active
|
||||
* locale, so the same call renders Portuguese for a pt-PT user.
|
||||
*/
|
||||
describe("formatRelativeTime (locale awareness)", () => {
|
||||
const now = new Date("2026-07-27T12:00:00Z").getTime();
|
||||
const threeHoursAgo = new Date(now - 3 * 60 * 60 * 1000);
|
||||
|
||||
afterAll(async () => {
|
||||
await activateLocale("en");
|
||||
});
|
||||
|
||||
it("renders in the active locale, not English", async () => {
|
||||
const english = formatRelativeTime(threeHoursAgo, now);
|
||||
|
||||
await activateLocale("pt-pt");
|
||||
const portuguese = formatRelativeTime(threeHoursAgo, now);
|
||||
|
||||
expect(portuguese).not.toBe(english);
|
||||
expect(portuguese).toContain("3");
|
||||
expect(portuguese).toMatch(/horas/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,79 @@ export function formatRelative(dateStr: string, now: number = Date.now()): strin
|
||||
return t("common:dAgo", { diffDay });
|
||||
}
|
||||
|
||||
/**
|
||||
* Units `formatRelativeTime` picks from, largest first, with the number of
|
||||
* seconds in each. Months and years are the conventional 30/365-day
|
||||
* approximations — `Intl.RelativeTimeFormat` only phrases a magnitude, so
|
||||
* calendar-exact boundaries are neither available nor expected here.
|
||||
*/
|
||||
const RELATIVE_UNITS: readonly (readonly [Intl.RelativeTimeFormatUnit, number])[] = [
|
||||
["year", 365 * 24 * 60 * 60],
|
||||
["month", 30 * 24 * 60 * 60],
|
||||
["week", 7 * 24 * 60 * 60],
|
||||
["day", 24 * 60 * 60],
|
||||
["hour", 60 * 60],
|
||||
["minute", 60],
|
||||
["second", 1],
|
||||
];
|
||||
|
||||
/**
|
||||
* Anything closer than this to `now` reads as "now" rather than "3 seconds
|
||||
* ago". Compared against raw milliseconds, deliberately: testing it against
|
||||
* the already-rounded second count moved the real cutoff to 9.5s, and made it
|
||||
* asymmetric, because `Math.round(-9.5)` is `-9` while `Math.round(9.5)` is
|
||||
* `10` — so 9.5s in the past read "now" while 9.5s in the future read "in 10
|
||||
* seconds".
|
||||
*/
|
||||
const JUST_NOW_SECONDS = 10;
|
||||
|
||||
const relativeFormatters = new Map<string, Intl.RelativeTimeFormat>();
|
||||
|
||||
function relativeFormatter(): Intl.RelativeTimeFormat {
|
||||
const locale = intlLocale();
|
||||
const cached = relativeFormatters.get(locale);
|
||||
if (cached) return cached;
|
||||
// `numeric: "auto"` lets the CLDR data phrase the small magnitudes
|
||||
// idiomatically ("yesterday" rather than "1 day ago"), which is the whole
|
||||
// reason to go through Intl instead of interpolating a number.
|
||||
const formatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
|
||||
relativeFormatters.set(locale, formatter);
|
||||
return formatter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locale-aware relative time ("3 hours ago", "in 2 days", "yesterday") via
|
||||
* `Intl.RelativeTimeFormat`, in the active i18next locale. Future timestamps
|
||||
* come back phrased as future. Returns "" for unparseable input.
|
||||
*
|
||||
* Distinct from `formatRelative` above, which routes a fixed four-bucket
|
||||
* ladder through the message catalog for compact UI chips. This one covers
|
||||
* the full range up to years and needs no catalog keys, so it is what
|
||||
* `host.utils.formatRelativeTime` exposes to plugins.
|
||||
*/
|
||||
export function formatRelativeTime(
|
||||
value: string | number | Date,
|
||||
now: number = Date.now(),
|
||||
): string {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
|
||||
const formatter = relativeFormatter();
|
||||
const diffMs = date.getTime() - now;
|
||||
if (Math.abs(diffMs) < JUST_NOW_SECONDS * 1000) return formatter.format(0, "second");
|
||||
|
||||
// Rounded, not truncated, so unit selection lands on the nearest unit —
|
||||
// 59.6s reads "1 minute ago", which describes it better than "59 seconds
|
||||
// ago". That rounding is intended for the unit choice; it just must not
|
||||
// decide the "now" threshold above.
|
||||
const diffSec = Math.round(diffMs / 1000);
|
||||
const magnitude = Math.abs(diffSec);
|
||||
for (const [unit, seconds] of RELATIVE_UNITS) {
|
||||
if (magnitude >= seconds) return formatter.format(Math.trunc(diffSec / seconds), unit);
|
||||
}
|
||||
return formatter.format(0, "second");
|
||||
}
|
||||
|
||||
export function formatNumber(value: number, options?: Intl.NumberFormatOptions): string {
|
||||
return new Intl.NumberFormat(intlLocale(), options).format(value);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@ import { pluginRegistry } from "./registry";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import type { ActivePlugin, PluginHostApi, PluginRegistry } from "./types";
|
||||
|
||||
/** No-op `host.toast`; these specs exercise lifecycle, never notifications. */
|
||||
const NOOP_TOAST = new Proxy(() => 0, {
|
||||
get: () => () => 0,
|
||||
}) as unknown as PluginHostApi["toast"];
|
||||
|
||||
vi.mock("@/lib/config", () => ({ getBackendConfig: () => ({ apiBaseUrl: "" }) }));
|
||||
|
||||
const PLUGIN_ID = "kandev-plugin-render-dup";
|
||||
@@ -31,6 +36,8 @@ function makeHostFactory(pluginId: string): PluginHostApi {
|
||||
onThemeChange: () => () => {},
|
||||
navigate: () => {},
|
||||
openModal: () => ({ close: () => {} }),
|
||||
toast: NOOP_TOAST,
|
||||
utils: { cn: () => "", formatRelativeTime: () => "" },
|
||||
storage: {
|
||||
get: async () => undefined,
|
||||
set: async () => ({ updatedAt: "" }),
|
||||
|
||||
@@ -5,16 +5,45 @@ import { buildHostApi } from "./host-api";
|
||||
|
||||
/** Curated primitives a plugin needs to build a full native-feeling page. */
|
||||
const EXPECTED_UI_PRIMITIVES = [
|
||||
"Accordion",
|
||||
"AccordionContent",
|
||||
"AccordionItem",
|
||||
"AccordionTrigger",
|
||||
"Alert",
|
||||
"Badge",
|
||||
"Button",
|
||||
"Card",
|
||||
"ChartContainer",
|
||||
"ChartLegend",
|
||||
"ChartLegendContent",
|
||||
"ChartStyle",
|
||||
"ChartTooltip",
|
||||
"ChartTooltipContent",
|
||||
"Checkbox",
|
||||
"Collapsible",
|
||||
"CollapsibleContent",
|
||||
"CollapsibleTrigger",
|
||||
"Dialog",
|
||||
"DropdownMenu",
|
||||
"Empty",
|
||||
"EmptyContent",
|
||||
"EmptyDescription",
|
||||
"EmptyHeader",
|
||||
"EmptyMedia",
|
||||
"EmptyTitle",
|
||||
"Input",
|
||||
"Kbd",
|
||||
"KbdGroup",
|
||||
"Label",
|
||||
"Pagination",
|
||||
"Popover",
|
||||
"PopoverAnchor",
|
||||
"PopoverContent",
|
||||
"PopoverDescription",
|
||||
"PopoverHeader",
|
||||
"PopoverTitle",
|
||||
"PopoverTrigger",
|
||||
"Progress",
|
||||
"ScrollArea",
|
||||
"Select",
|
||||
"Sheet",
|
||||
@@ -31,6 +60,7 @@ const EXPECTED_UI_PRIMITIVES = [
|
||||
"Tabs",
|
||||
"Textarea",
|
||||
"Tooltip",
|
||||
"TooltipProvider",
|
||||
];
|
||||
|
||||
describe("buildHostApi — api.fetch", () => {
|
||||
@@ -268,6 +298,54 @@ describe("buildHostApi — host.theme / host.onThemeChange", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `toast` and `utils` are functions, so they sit beside `navigate`/`openModal`
|
||||
* rather than in `ui`, which is a component map.
|
||||
*/
|
||||
describe("buildHostApi — host.toast / host.utils", () => {
|
||||
it("exposes sonner's imperative toast, which needs no host rendering", () => {
|
||||
const host = buildHostApi("jira", createAppStore());
|
||||
|
||||
expect(typeof host.toast).toBe("function");
|
||||
for (const method of ["success", "error", "warning", "info", "dismiss"] as const) {
|
||||
expect(typeof host.toast[method], `host.toast.${method}`).toBe("function");
|
||||
}
|
||||
});
|
||||
|
||||
// Scoped per plugin: an unattributed plugin error toast would land in
|
||||
// kandev's own frontend error log as an application error.
|
||||
it("scopes toast.error to the calling plugin rather than the app reporting seam", () => {
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
const host = buildHostApi("kandev-plugin-github-status", createAppStore());
|
||||
|
||||
host.toast.error("Poll failed");
|
||||
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'[plugins] toast.error from "kandev-plugin-github-status":',
|
||||
"Poll failed",
|
||||
);
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it("exposes cn, merging conflicting tailwind classes the way host components do", () => {
|
||||
const host = buildHostApi("jira", createAppStore());
|
||||
|
||||
expect(typeof host.utils.cn).toBe("function");
|
||||
// tailwind-merge semantics, not plain concatenation — the later padding wins.
|
||||
expect(host.utils.cn("p-2", "p-4")).toBe("p-4");
|
||||
const hidden = false;
|
||||
expect(host.utils.cn("text-sm", hidden && "hidden", "font-bold")).toBe("text-sm font-bold");
|
||||
});
|
||||
|
||||
it("exposes formatRelativeTime rather than leaving plugins to hand-roll one", () => {
|
||||
const host = buildHostApi("jira", createAppStore());
|
||||
|
||||
expect(typeof host.utils.formatRelativeTime).toBe("function");
|
||||
const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60 * 1000);
|
||||
expect(host.utils.formatRelativeTime(threeHoursAgo)).toContain("3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildHostApi — ui", () => {
|
||||
it("exposes RichTextEditor/RichTextReadOnly for plugin notes-style UIs", () => {
|
||||
const host = buildHostApi("jira", createAppStore());
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
import * as React from "react";
|
||||
import type { StoreApi } from "zustand";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@kandev/ui/accordion";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@kandev/ui/alert";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
@@ -16,7 +17,16 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@kandev/ui/card";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@kandev/ui/chart";
|
||||
import { Checkbox } from "@kandev/ui/checkbox";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@kandev/ui/collapsible";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
@@ -34,7 +44,16 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@kandev/ui/dropdown-menu";
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@kandev/ui/empty";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { Kbd, KbdGroup } from "@kandev/ui/kbd";
|
||||
import { Label } from "@kandev/ui/label";
|
||||
import {
|
||||
Pagination,
|
||||
@@ -45,6 +64,16 @@ import {
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@kandev/ui/pagination";
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverDescription,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverTrigger,
|
||||
} from "@kandev/ui/popover";
|
||||
import { Progress } from "@kandev/ui/progress";
|
||||
import { ScrollArea } from "@kandev/ui/scroll-area";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
|
||||
import { Separator } from "@kandev/ui/separator";
|
||||
@@ -65,11 +94,14 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@kandev/ui/tabs";
|
||||
import { Textarea } from "@kandev/ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { cn } from "@kandev/ui/utils";
|
||||
import { Combobox } from "@/components/combobox";
|
||||
import { RichTextEditor, RichTextReadOnly } from "@/components/editors/tiptap/rich-text-editor";
|
||||
import { PageTopbar } from "@/components/page-topbar";
|
||||
import { TaskCreateDialog } from "@/components/task-create-dialog";
|
||||
import { getBackendConfig } from "@/lib/config";
|
||||
import { formatRelativeTime } from "@/lib/i18n/formats";
|
||||
import { createPluginToast } from "@/lib/toast/sonner";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
import { softNavigate } from "@/lib/routing/client-router";
|
||||
import type { AppState } from "@/lib/state/store";
|
||||
@@ -90,10 +122,16 @@ import {
|
||||
* 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.
|
||||
* context and break refs/asChild. The same applies to recharts behind the
|
||||
* Chart* exports: it drives layout through its own context and portals
|
||||
* tooltips, so a bundled second copy splits that context exactly the way a
|
||||
* second Radix copy does. Pure-React libs (e.g. icon sets) bundle fine.
|
||||
*/
|
||||
const PLUGIN_UI: Record<string, unknown> = {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
@@ -106,7 +144,20 @@ const PLUGIN_UI: Record<string, unknown> = {
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
// Chart: recharts wrappers. Plugins must use these rather than importing
|
||||
// recharts themselves — same context/portal hazard as Radix, and recharts
|
||||
// 2.15.4 is already a dependency of both apps/web and @kandev/ui, so using
|
||||
// the host's copy costs no bundle bytes.
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
Checkbox,
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
@@ -120,7 +171,15 @@ const PLUGIN_UI: Record<string, unknown> = {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
Input,
|
||||
Kbd,
|
||||
KbdGroup,
|
||||
Label,
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
@@ -129,6 +188,14 @@ const PLUGIN_UI: Record<string, unknown> = {
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverDescription,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverTrigger,
|
||||
Progress,
|
||||
ScrollArea,
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -188,6 +255,19 @@ const PLUGIN_UI: Record<string, unknown> = {
|
||||
RichTextReadOnly,
|
||||
};
|
||||
|
||||
/**
|
||||
* `host.utils` — plain functions, deliberately not on `host.ui` (a component
|
||||
* map). Shared rather than reimplemented per plugin: `cn` must be the host's
|
||||
* so Tailwind class merging matches the components it styles, and
|
||||
* `formatRelativeTime` must be the host's so a plugin's timestamps follow the
|
||||
* user's locale instead of the hand-rolled English-only ladders several
|
||||
* published plugins ship today.
|
||||
*/
|
||||
const PLUGIN_UTILS = {
|
||||
cn,
|
||||
formatRelativeTime,
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-browser-tab id stamped on every `host.storage.set`/`delete` call and
|
||||
* echoed back in the `plugin.user-state.updated` WS payload, so
|
||||
@@ -250,6 +330,14 @@ export function buildHostApi(pluginId: string, storeApi: StoreApi<AppState>): Pl
|
||||
onThemeChange: (listener) => subscribeToThemeChanges(listener),
|
||||
navigate: (href, options) => softNavigate(href, options?.replace ? "replace" : "push"),
|
||||
openModal: (options) => pluginModalManager.openModal(pluginId, options),
|
||||
// Sonner's imperative global. The app mounts <Toaster/> once in
|
||||
// app/layout.tsx, so this needs no host wiring and — unlike a rendered
|
||||
// component — works from a plugin modal regardless of which providers
|
||||
// that modal sits under. Scoped per plugin so `.error` logs with
|
||||
// attribution rather than filing a kandev frontend error report; see
|
||||
// createPluginToast.
|
||||
toast: createPluginToast(pluginId),
|
||||
utils: PLUGIN_UTILS,
|
||||
storage: buildStorageApi(pluginId),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,11 @@ import { loadPlugins } from "./host";
|
||||
import { pluginRegistry } from "./registry";
|
||||
import type { ActivePlugin, PluginHostApi, PluginRegistry } from "./types";
|
||||
|
||||
/** No-op `host.toast`; these specs exercise lifecycle, never notifications. */
|
||||
const NOOP_TOAST = new Proxy(() => 0, {
|
||||
get: () => () => 0,
|
||||
}) as unknown as PluginHostApi["toast"];
|
||||
|
||||
const PLUGIN_LIFECYCLE_A_ID = "plugin-lifecycle-a";
|
||||
const PLUGIN_TIMEOUT_ID = "plugin-timeout-a";
|
||||
|
||||
@@ -30,6 +35,8 @@ function makeHostFactory(pluginId: string): PluginHostApi {
|
||||
onThemeChange: () => () => {},
|
||||
navigate: () => {},
|
||||
openModal: () => ({ close: () => {} }),
|
||||
toast: NOOP_TOAST,
|
||||
utils: { cn: () => "", formatRelativeTime: () => "" },
|
||||
storage: {
|
||||
get: async () => undefined,
|
||||
set: async () => ({ updatedAt: "" }),
|
||||
|
||||
@@ -5,6 +5,11 @@ import { pluginModalManager } from "./modal-manager";
|
||||
import { pluginRegistry } from "./registry";
|
||||
import type { ActivePlugin, PluginHostApi, PluginRegistry } from "./types";
|
||||
|
||||
/** No-op `host.toast`; these specs exercise lifecycle, never notifications. */
|
||||
const NOOP_TOAST = new Proxy(() => 0, {
|
||||
get: () => () => 0,
|
||||
}) as unknown as PluginHostApi["toast"];
|
||||
|
||||
let mockApiBaseUrl = "";
|
||||
vi.mock("@/lib/config", () => ({
|
||||
getBackendConfig: () => ({ apiBaseUrl: mockApiBaseUrl }),
|
||||
@@ -37,6 +42,8 @@ function makeHostFactory(pluginId: string): PluginHostApi {
|
||||
onThemeChange: () => () => {},
|
||||
navigate: () => {},
|
||||
openModal: () => ({ close: () => {} }),
|
||||
toast: NOOP_TOAST,
|
||||
utils: { cn: () => "", formatRelativeTime: () => "" },
|
||||
storage: {
|
||||
get: async () => undefined,
|
||||
set: async () => ({ updatedAt: "" }),
|
||||
|
||||
@@ -371,9 +371,9 @@ export interface PluginHostApi {
|
||||
};
|
||||
/**
|
||||
* Curated subset of `@kandev/ui` components (Button, Card, Badge, Input,
|
||||
* Tabs, Dialog, Table, ...) plus first-party app UI (PageTopbar,
|
||||
* TaskCreateDialog, RichTextEditor, RichTextReadOnly). See
|
||||
* `lib/plugins/host-api.ts` for the full list.
|
||||
* Tabs, Dialog, Table, Popover, Progress, Accordion, Chart, ...) plus
|
||||
* first-party app UI (PageTopbar, TaskCreateDialog, RichTextEditor,
|
||||
* RichTextReadOnly). See `lib/plugins/host-api.ts` for the full list.
|
||||
*/
|
||||
ui: Record<string, unknown>;
|
||||
/**
|
||||
@@ -400,10 +400,52 @@ export interface PluginHostApi {
|
||||
* handler may call it, but it works from any plugin code path.
|
||||
*/
|
||||
openModal(options: PluginModalOptions): PluginModalHandle;
|
||||
/**
|
||||
* Sonner's imperative `toast`, scoped to this plugin. The host mounts the
|
||||
* single `<Toaster/>`, so there is nothing to render:
|
||||
* `host.toast.success(...)` / `.error(...)` / `.warning(...)` work from any
|
||||
* plugin code path, including inside a modal.
|
||||
*
|
||||
* `.error` additionally logs to the browser console as
|
||||
* `[plugins] toast.error from "<pluginId>":`. It does **not** file a report
|
||||
* into kandev's frontend error log — that log is for kandev's own
|
||||
* application errors, and a plugin toasting an expected condition (a failed
|
||||
* poll, say) would otherwise record one every cycle.
|
||||
*/
|
||||
toast: PluginToastApi;
|
||||
/** Shared helpers — plain functions, not components. See `PluginUtilsApi`. */
|
||||
utils: PluginUtilsApi;
|
||||
/** Authenticated, per-user key/value storage. See `PluginStorageApi`. */
|
||||
storage: PluginStorageApi;
|
||||
}
|
||||
|
||||
/**
|
||||
* `host.toast` — sonner's `toast`. Typed structurally rather than as
|
||||
* `typeof import("sonner").toast` so a plugin's own type-checking does not
|
||||
* need sonner installed.
|
||||
*/
|
||||
export type PluginToastApi = {
|
||||
(message: string, options?: Record<string, unknown>): string | number;
|
||||
success(message: string, options?: Record<string, unknown>): string | number;
|
||||
error(message: string, options?: Record<string, unknown>): string | number;
|
||||
warning(message: string, options?: Record<string, unknown>): string | number;
|
||||
info(message: string, options?: Record<string, unknown>): string | number;
|
||||
dismiss(id?: string | number): unknown;
|
||||
};
|
||||
|
||||
/** `host.utils` — shared helpers a plugin would otherwise reimplement. */
|
||||
export interface PluginUtilsApi {
|
||||
/** The host's `clsx` + `tailwind-merge` class combiner. */
|
||||
cn(...inputs: unknown[]): string;
|
||||
/**
|
||||
* Locale-aware relative time ("3 hours ago", "in 2 days", "yesterday") via
|
||||
* `Intl.RelativeTimeFormat` in the user's active locale. Returns "" for
|
||||
* unparseable input. Prefer this over a hand-rolled ladder — those are
|
||||
* English-only and go untranslated for every non-English user.
|
||||
*/
|
||||
formatRelativeTime(value: string | number | Date): string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry surface passed as the first argument to `KandevPlugin.initialize`.
|
||||
* Each plugin receives an instance scoped to its own pluginId — the
|
||||
|
||||
@@ -28,7 +28,7 @@ vi.mock("@/lib/api/domains/frontend-error-log-api", () => ({
|
||||
scheduleFrontendErrorReport: mocks.report,
|
||||
}));
|
||||
|
||||
import { toast } from "./sonner";
|
||||
import { createPluginToast, toast } from "./sonner";
|
||||
|
||||
describe("reporting Sonner seam", () => {
|
||||
beforeEach(() => {
|
||||
@@ -52,6 +52,58 @@ describe("reporting Sonner seam", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// A plugin error toast is the plugin's problem, not a kandev application
|
||||
// error. Reporting it would file an Error-level backend log entry per
|
||||
// occurrence — every 60s for a polling plugin — with no way to tell it
|
||||
// apart from a real one. (The backend also rejects any source outside
|
||||
// "sonner" | "toast-provider" with 400, so attributing it there is not
|
||||
// available without a cross-stack change.)
|
||||
it("does not file a frontend error report for a plugin toast", () => {
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
const pluginToast = createPluginToast("kandev-plugin-github-status");
|
||||
|
||||
const result = pluginToast.error("Poll failed");
|
||||
|
||||
expect(result).toBe("toast-id");
|
||||
expect(mocks.upstreamError).toHaveBeenCalledOnce();
|
||||
expect(mocks.report).not.toHaveBeenCalled();
|
||||
expect(mocks.order).toEqual(["delegate"]);
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it("logs a plugin toast error to the console with plugin attribution", () => {
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
const pluginToast = createPluginToast("kandev-plugin-github-status");
|
||||
|
||||
pluginToast.error("Poll failed");
|
||||
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'[plugins] toast.error from "kandev-plugin-github-status":',
|
||||
"Poll failed",
|
||||
);
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it("leaves non-error plugin toast methods delegating straight to sonner", () => {
|
||||
const pluginToast = createPluginToast("kandev-plugin-github-status");
|
||||
|
||||
pluginToast.success("Synced");
|
||||
|
||||
expect(mocks.report).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps reporting for first-party call sites", () => {
|
||||
toast.error("Real app failure");
|
||||
|
||||
expect(mocks.report).toHaveBeenCalledOnce();
|
||||
expect(mocks.report).toHaveBeenCalledWith({
|
||||
source: "sonner",
|
||||
title: "Real app failure",
|
||||
description: undefined,
|
||||
error: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("prevents application call sites from bypassing the reporting seam", () => {
|
||||
const webRoot = path.resolve(process.cwd());
|
||||
const wrapper = path.join(webRoot, "lib", "toast", "sonner.ts");
|
||||
|
||||
@@ -23,6 +23,38 @@ export const toast = new Proxy(upstreamToast, {
|
||||
},
|
||||
}) as typeof upstreamToast;
|
||||
|
||||
/**
|
||||
* `host.toast` for one plugin (see `lib/plugins/host-api.ts`). Identical to
|
||||
* `toast` except `.error` logs to the console instead of filing a frontend
|
||||
* error report.
|
||||
*
|
||||
* That endpoint is kandev's *own* diagnostics: the backend logs each report at
|
||||
* Error level as "frontend error toast". A third-party plugin's error toast is
|
||||
* not a kandev application error — a plugin that toasts a failed poll every 60s
|
||||
* would file an application error every 60s indefinitely, indistinguishable
|
||||
* from a real one. (The backend also accepts only a closed set of sources, so
|
||||
* a per-plugin `source` is rejected with 400 rather than attributed.)
|
||||
*
|
||||
* Plugin failures go to the console under the `[plugins]` prefix instead,
|
||||
* matching every other plugin extension point — `PluginErrorBoundary`,
|
||||
* `dispatchToPluginWsHandlers`, and the `onThemeChange` listener guard.
|
||||
*/
|
||||
export function createPluginToast(pluginId: string): typeof upstreamToast {
|
||||
const pluginError = (
|
||||
...args: Parameters<typeof upstreamToast.error>
|
||||
): ReturnType<typeof upstreamToast.error> => {
|
||||
const result = upstreamToast.error(...args);
|
||||
console.error(`[plugins] toast.error from "${pluginId}":`, args[0]);
|
||||
return result;
|
||||
};
|
||||
return new Proxy(upstreamToast, {
|
||||
get(target, property, receiver) {
|
||||
if (property === "error") return pluginError;
|
||||
return Reflect.get(target, property, receiver);
|
||||
},
|
||||
}) as typeof upstreamToast;
|
||||
}
|
||||
|
||||
function ownDataValue(value: unknown, property: string): unknown {
|
||||
if (!value || typeof value !== "object") return undefined;
|
||||
try {
|
||||
|
||||
@@ -75,6 +75,14 @@ interface PluginHostApi {
|
||||
// (mounted once at the app root, isolated behind its own error boundary).
|
||||
// Independent of keybindings — any plugin code path may call it.
|
||||
openModal(options: PluginModalOptions): PluginModalHandle;
|
||||
// Sonner's imperative toast. The host mounts the single <Toaster/>, so
|
||||
// there is nothing to render and nothing to wire — and because it is
|
||||
// imperative rather than a component, it works from inside a plugin modal
|
||||
// regardless of which providers that modal sits under.
|
||||
toast: PluginToastApi;
|
||||
// Shared helpers — plain functions, so they live here rather than in `ui`
|
||||
// (a component map). See the "host.utils" section below.
|
||||
utils: PluginUtilsApi;
|
||||
// Authenticated, per-user key/value storage backed by
|
||||
// /api/plugins/{id}/user-state/... — see the "host.storage" section below.
|
||||
// Requires the plugin manifest to declare capabilities.user_state: true.
|
||||
@@ -162,10 +170,13 @@ interface PluginModalHandle {
|
||||
}
|
||||
```
|
||||
|
||||
`host.ui` contents: shadcn primitives (Alert*, Badge, Button, Card*, Checkbox,
|
||||
Dialog*, DropdownMenu*, Input, Label, Pagination*, ScrollArea, Select*,
|
||||
`host.ui` contents: shadcn primitives (Accordion*, Alert*, Badge, Button,
|
||||
Card*, Checkbox, Collapsible*, Dialog*, DropdownMenu*, Empty*, Input, Kbd,
|
||||
KbdGroup, Label, Pagination*, Popover*, Progress, ScrollArea, Select*,
|
||||
Separator, Sheet*, Skeleton, Spinner, Switch, Table*, Tabs*, Textarea,
|
||||
Tooltip*, including `TooltipProvider`) plus first-party app UI: `PageTopbar` (the kandev title bar, for
|
||||
Tooltip*, including `TooltipProvider`), the recharts wrappers (`ChartContainer`,
|
||||
`ChartTooltip`, `ChartTooltipContent`, `ChartLegend`, `ChartLegendContent`,
|
||||
`ChartStyle`), plus first-party app UI: `PageTopbar` (the kandev title bar, for
|
||||
routes that opt out of the default chrome and own their layout),
|
||||
`TaskCreateDialog` (kandev's real create-task modal, prefilled via
|
||||
`initialValues`), `Combobox` (the app's Command+Popover picker), and
|
||||
@@ -184,6 +195,56 @@ The host wraps plugin routes, slots, and `openModal` content in a
|
||||
`delayDuration`/`skipDelayDuration` over a dense cluster of tooltips; Radix
|
||||
supports nesting it.
|
||||
|
||||
**Charts use the host's recharts.** `recharts` is already a dependency of both
|
||||
`apps/web` and `@kandev/ui`, so the `Chart*` exports add no bundle weight — and
|
||||
a plugin must never bundle its own copy. recharts drives layout through its own
|
||||
React context and portals its tooltips, so a second copy splits that context
|
||||
exactly the way a second Radix copy does: charts render at zero size, tooltips
|
||||
attach to the wrong tree, and responsive containers stop resizing. Compose the
|
||||
`Chart*` wrappers with the chart primitives they hand you rather than importing
|
||||
`recharts` in plugin code.
|
||||
|
||||
### `host.toast` and `host.utils`
|
||||
|
||||
```ts
|
||||
// Sonner's imperative toast — the host owns the single <Toaster/>, so there
|
||||
// is nothing to render and it works from any plugin code path, modals
|
||||
// included.
|
||||
host.toast.success("Synced 12 issues");
|
||||
host.toast.error("Sync failed");
|
||||
|
||||
interface PluginToastApi {
|
||||
(message: string, options?: Record<string, unknown>): string | number;
|
||||
success(message: string, options?: Record<string, unknown>): string | number;
|
||||
// Renders the same toast as any other variant, and additionally logs
|
||||
// `[plugins] toast.error from "<pluginId>":` to the browser console.
|
||||
// It does NOT file a report into kandev's frontend error log — that log is
|
||||
// for kandev's own application errors, and a plugin toasting an expected
|
||||
// condition (a failed poll, say) would otherwise record an Error-level
|
||||
// entry every cycle. Console is where every plugin failure surfaces.
|
||||
error(message: string, options?: Record<string, unknown>): string | number;
|
||||
warning(message: string, options?: Record<string, unknown>): string | number;
|
||||
info(message: string, options?: Record<string, unknown>): string | number;
|
||||
// Dismisses one toast by the id a variant returned, or all of them when
|
||||
// called with no argument.
|
||||
dismiss(id?: string | number): unknown;
|
||||
}
|
||||
|
||||
interface PluginUtilsApi {
|
||||
// The host's clsx + tailwind-merge combiner, so class merging matches the
|
||||
// components it styles.
|
||||
cn(...inputs: unknown[]): string;
|
||||
// Locale-aware relative time ("3 hours ago", "in 2 days", "yesterday") via
|
||||
// Intl.RelativeTimeFormat in the user's active locale; "" for unparseable
|
||||
// input. Prefer it over a hand-rolled ladder, which is English-only by
|
||||
// construction and goes untranslated for every non-English user.
|
||||
formatRelativeTime(value: string | number | Date): string;
|
||||
}
|
||||
```
|
||||
|
||||
Both are functions, so they sit beside `navigate`/`openModal` rather than in
|
||||
`ui`, which is a component map.
|
||||
|
||||
### `host.ui.RichTextEditor` / `host.ui.RichTextReadOnly`
|
||||
|
||||
Pixel-identical to the Plan panel's markdown editor (paste handling, slash
|
||||
|
||||
Reference in New Issue
Block a user