6393d2df85
Co-authored-by: Kandev Agent <agent@kandev.dev>
326 lines
12 KiB
JavaScript
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",
|
|
});
|
|
});
|
|
},
|
|
});
|
|
})();
|