Files
kandev/apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js
T
2026-08-08 07:58:17 +02:00

326 lines
12 KiB
JavaScript

/**
* Native JS UI bundle for the kandev-plugin-e2e fixture plugin
* (docs/plans/plugins/PLUGIN-API.md). A self-contained ES module — no
* imports, no bundled React — that calls `window.registerKandevPlugin(id,
* plugin)` at evaluation time and, on `initialize(registry, host)`,
* registers a nav item, a top-level route, a `task-sidebar` slot component,
* a `main-top-bar` slot component, a `task.created` WS handler, and the
* `open-demo` keybinding (declared in manifest.yaml's `ui.keybindings`,
* default `mod+shift+j`) which opens a `host.openModal(...)` demo modal
* containing a `host.ui` Tooltip. Uses only host.React/host.jsx/host.ui.
*
* 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),
* 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
* round-trips a single per-user document through host.storage
* (get on mount, debounced set, subscribe to pick up a write from another
* tab/surface), a task-card-indicators slot component, a task-card-tags slot
* component, and a registerTaskMenuAction under the kanban card's "edit"
* group.
*
* The task-created counter lives in module scope (not component state) with
* a tiny listener set, so it survives across route navigations (the page
* component unmounts/remounts as the user navigates away and back).
*/
(function () {
var moduleCount = 0;
var listeners = new Set();
function emit() {
listeners.forEach(function (fn) {
fn(moduleCount);
});
}
function incrementCount() {
moduleCount += 1;
emit();
}
function useCounter(React) {
var state = React.useState(moduleCount);
var count = state[0];
var setCount = state[1];
React.useEffect(function () {
setCount(moduleCount);
listeners.add(setCount);
return function () {
listeners.delete(setCount);
};
}, []);
return count;
}
window.registerKandevPlugin("kandev-plugin-e2e", {
initialize: function (registry, host) {
var React = host.React;
var jsx = host.jsx;
var ui = host.ui;
// Reads host.theme once on mount, then tracks it purely through
// host.onThemeChange — so the readout only stays correct if the
// subscription actually fires. Also counts notifications, proving the
// host does not re-notify on unrelated <html> class churn.
function useHostTheme() {
var state = React.useState(function () {
return { theme: host.theme, changes: 0 };
});
var value = state[0];
var setValue = state[1];
React.useEffect(function () {
return host.onThemeChange(function (next) {
setValue(function (prev) {
return { theme: next, changes: prev.changes + 1 };
});
});
}, []);
return value;
}
function PluginPage() {
var count = useCounter(React);
var themeState = useHostTheme();
return jsx(
"div",
{ id: "hello-plugin-page-root" },
jsx("h1", { id: "hello-plugin-page" }, "Hello E2E"),
jsx("span", { id: "hello-task-counter" }, String(count)),
jsx(
"span",
{
"data-testid": "hello-theme-readout",
"data-theme-changes": String(themeState.changes),
},
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",
),
);
}
function SidebarSlot() {
return jsx("div", { id: "hello-sidebar" }, "Hello E2E sidebar");
}
function MainTopBarSlot(props) {
var slotProps = props.slotProps || {};
return jsx("span", { id: "hello-main-top-bar" }, "Hello " + slotProps.currentPage);
}
// Debounce delay for the Notes panel's autosave — short, so e2e specs
// don't need to wait long for a write to reach host.storage.
var NOTES_SAVE_DEBOUNCE_MS = 150;
function useNotesValue(taskId, panelId) {
var state = React.useState("");
var value = state[0];
var setValue = state[1];
var loadedState = React.useState(false);
var loaded = loadedState[0];
var setLoaded = loadedState[1];
React.useEffect(
function () {
var cancelled = false;
// Each notification (subscribe's refresh callback, plus the initial
// call) starts its own host.storage.get. Two in flight at once can
// resolve out of order, so `cancelled` alone (unmount-only) isn't
// enough — an older read landing after a newer one would overwrite
// it. Only the read matching the current generation may commit.
var refreshGeneration = 0;
function refresh() {
var generation = ++refreshGeneration;
host.storage.get("task", taskId, "note").then(
function (entry) {
if (cancelled || generation !== refreshGeneration) return;
setValue(entry ? entry.value : "");
setLoaded(true);
},
function () {
if (cancelled || generation !== refreshGeneration) return;
setLoaded(true);
},
);
}
refresh();
// Scope echo suppression to this panel instance (panelId) rather
// than the host's shared tab-wide default writerId. The kanban
// "Enhance notes" action below writes this same note using that
// shared default (it has no ongoing subscription of its own to
// protect) — if this panel used the same default, the action's
// write would look like this panel's own echo and get silently
// dropped instead of refreshing the panel.
var unsubscribe = host.storage.subscribe(
{ scope: "task", scopeId: taskId, key: "note", writerId: panelId },
refresh,
);
return function () {
cancelled = true;
unsubscribe();
};
},
[taskId, panelId],
);
return [value, setValue, loaded];
}
function NotesPanel(props) {
var taskId = props.taskId;
var panelId = props.panelId;
var notesValue = useNotesValue(taskId, panelId);
var value = notesValue[0];
var setValue = notesValue[1];
var loaded = notesValue[2];
var debounceRef = React.useRef(null);
function handleChange(e) {
var next = e.target.value;
setValue(next);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(function () {
host.storage
.set("task", taskId, "note", next, { writerId: panelId })
.catch(function () {
// surface the failed autosave instead of dropping it silently
});
}, NOTES_SAVE_DEBOUNCE_MS);
}
if (!loaded) {
return jsx("div", { "data-testid": "e2e-notes-panel-loading" }, "Loading notes…");
}
return jsx("textarea", {
"data-testid": "e2e-notes-panel",
"data-presentation": props.presentation,
value: value,
onChange: handleChange,
});
}
function CardIndicator(props) {
var slotProps = props.slotProps || {};
return jsx(
"span",
{ "data-testid": "e2e-card-indicator", "data-task-id": slotProps.taskId },
"N",
);
}
function CardTags(props) {
var slotProps = props.slotProps || {};
return jsx(
"span",
{ "data-testid": "e2e-card-tags", "data-task-id": slotProps.taskId },
"tags",
);
}
function StatusSlot(props) {
var slotProps = props.slotProps || {};
var id = slotProps.placement === "left" ? "hello-status-left" : "hello-status-right";
return jsx(
"span",
{ id: id },
"Hello status " +
String(slotProps.presentation || "unknown") +
" " +
String(slotProps.activeTaskId || "no-task"),
);
}
registry.registerNavItem({
id: "e2e-hello",
label: "Hello E2E",
path: "/plugins/e2e-hello",
section: "main",
});
registry.registerRoute("/plugins/e2e-hello", PluginPage);
registry.registerComponent("task-sidebar", SidebarSlot);
registry.registerComponent("main-top-bar", MainTopBarSlot);
registry.registerComponent("app-status-bar-left", StatusSlot);
registry.registerComponent("app-status-bar-right", StatusSlot);
registry.registerWsHandler("task.created", function () {
incrementCount();
});
registry.registerTaskPanel({
id: "notes",
title: "Notes",
icon: "book",
Component: NotesPanel,
mobileEnabled: true,
});
registry.registerComponent("task-card-indicators", CardIndicator);
registry.registerComponent("task-card-tags", CardTags);
registry.registerTaskMenuAction({
id: "enhance-notes",
label: "Enhance notes",
group: "edit",
run: function (context) {
return host.storage
.set("task", context.taskId, "note", "Enhanced via plugin action")
.then(function () {
// Keep the received responsive context observable to the E2E
// fixture without changing the plugin-facing action contract.
return host.storage.set(
"task",
context.taskId,
"menu-presentation",
context.presentation,
);
});
},
});
registry.registerKeybinding("open-demo", function () {
function DemoModalContent() {
// The Tooltip is the point of this modal in e2e: PluginModalHost
// mounts outside AppShell, so without its own TooltipProvider a
// Tooltip here throws on render and the error boundary swallows the
// whole modal. jsdom cannot open a Radix tooltip from synthetic
// hover, so real pointer hover is only assertable here.
return jsx(
"div",
{ id: "hello-demo-modal", "data-testid": "hello-demo-modal" },
"Hello from the plugin modal",
jsx(
ui.Tooltip,
null,
jsx(
ui.TooltipTrigger,
{ "data-testid": "hello-modal-tooltip-trigger" },
"hover me",
),
jsx(ui.TooltipContent, null, "Tooltip inside a plugin modal"),
),
);
}
host.openModal({
title: "Demo Modal",
content: DemoModalContent,
size: "md",
});
});
},
});
})();