feat(plugins): render a task menu action as a submenu (#3874)

Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Clément Fiere
2026-09-23 12:00:23 +02:00
committed by GitHub
parent 40b13df86d
commit f8708da1e0
34 changed files with 2491 additions and 95 deletions
@@ -1018,6 +1018,35 @@
);
},
});
registry.registerTaskMenuAction({
id: "task-shortcuts",
label: "Task shortcuts",
group: "primary",
items: function () {
return [
{
id: "record-presentation",
label: "Record menu presentation",
run: function (childContext) {
return host.storage.set(
"task",
childContext.taskId,
"primary-submenu-presentation",
childContext.presentation,
);
},
},
];
},
run: function (context) {
return host.storage.set(
"task",
context.taskId,
"primary-submenu-presentation",
context.presentation,
);
},
});
registry.registerKeybinding("open-demo", function () {
function DemoModalContent() {
+32
View File
@@ -422,12 +422,44 @@ export interface PluginTaskMenuContext {
presentation: "desktop" | "mobile";
}
/**
* One child of a task menu action that declares `items`. Unlike the parent
* action, an item is never registered on its own: it exists only inside its
* parent's submenu, so it has no `group`, no `visible`, and its own nesting is
* not supported (one level deep).
*/
export interface TaskMenuSubItemRegistration {
/** Unique within the parent action; contributes to the menu entry's React key. */
id: string;
label: string;
icon?: PluginIcon;
disabled?: boolean;
run(context: PluginTaskMenuContext): void | Promise<void>;
}
export interface TaskMenuActionRegistration {
id: string;
label: string;
icon?: PluginIcon;
group: "edit" | "primary";
visible?(context: PluginTaskMenuContext): boolean;
/**
* Declaring this turns the action into a submenu: the host renders `label`
* as an unselectable submenu trigger and calls `items(context)` to get its
* children, in the returned order. It must be synchronous and cheap: the
* host cannot await a menu item, and it builds a card's or row's entries on
* every render — a card's dropdown and context variants are built from one
* evaluation, whether or not a menu is open — so an implementation that
* scans or sorts should memoize on the state it reads.
*
* `run` then serves as the fallback for a host that predates submenus (it
* ignores this field and renders the flat item) and for a build where
* `items` yields nothing usable: an empty list, or a throw (caught,
* logged, and treated as empty). Either way the action stays reachable
* instead of becoming a trigger with no children.
*/
items?(context: PluginTaskMenuContext): readonly TaskMenuSubItemRegistration[];
/** A rejection is caught and logged; the menu closes either way. */
run(context: PluginTaskMenuContext): void | Promise<void>;
}
+3 -1
View File
@@ -280,7 +280,9 @@ and `lib/plugins/types.ts` are its detailed host implementation — all three mu
`PluginErrorBoundary`; `mobileEnabled: true` also renders it via the phone bottom nav
(`session-mobile-bottom-nav.tsx`) with `presentation: "mobile"`.
- **Task contributions:** `registerTaskMenuAction({ group: "edit", ... })` adds card-only actions to
the `Edit` submenu. Group `"primary"` adds flat actions to card and desktop/mobile task-row menus.
the `Edit` submenu. Group `"primary"` adds top-level actions to card and desktop/mobile task-row
menus; declaring `items(context)` renders an action of either group as a submenu of its children
instead, with `run` kept as the flat fallback.
Card indicator/tag slots stay card-specific; `task-row-metadata` is generic for sidebar and `/tasks` rows.
- **Sidebar workspace actions:** `registerComponent("sidebar-workspace-actions", ...)` renders after Quick Terminal/Quick Chat in the desktop sidebar's New Task row and in the shared phone navigation sheet, forwarding `SidebarWorkspaceActionsSlotProps` with `presentation: "desktop" | "mobile"`; mobile plugin controls own a 44px touch target and accessible name.
- **`host.storage`:** authenticated per-user key/value storage (`lib/plugins/host-api.ts`) backed by `/api/plugins/{id}/user-state/...` (`docs/decisions/2026-08-01-per-user-plugin-storage.md`); `subscribe` (`lib/plugins/user-state-sync.ts`) wraps `registerWsHandler` with own-plugin filtering and own-tab echo suppression via a per-tab `writerId`.
@@ -35,6 +35,11 @@ function registerEnhanceAction(
overrides: {
run?: () => Promise<void> | void;
visible?: (context: PluginTaskMenuContext) => boolean;
items?: (context: PluginTaskMenuContext) => readonly {
id: string;
label: string;
run: (context: PluginTaskMenuContext) => Promise<void> | void;
}[];
} = {},
) {
pluginRegistry.forPlugin(PLUGIN_ID).registerTaskMenuAction({
@@ -43,6 +48,7 @@ function registerEnhanceAction(
group: "edit",
run: overrides.run ?? vi.fn(),
...(overrides.visible ? { visible: overrides.visible } : {}),
...(overrides.items ? { items: overrides.items } : {}),
});
}
@@ -51,6 +57,29 @@ afterEach(() => {
pluginRegistry.unregisterPlugin(PLUGIN_ID);
});
describe("buildEditMenuEntry — a registration the host cannot render", () => {
// Regression: the submenu-vs-flat decision was made from the number of visible
// registrations, so an action with no usable label wrapped the native Edit item
// in a submenu even though its own entry was dropped -- unlike the primary
// group, which omits such an action entirely.
it("keeps the flat Edit item when the only edit-group action is unusable", () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
pluginRegistry.forPlugin(PLUGIN_ID).registerTaskMenuAction({
id: "broken",
label: "" as never,
group: "edit",
run: vi.fn(),
});
const entry = buildEditMenuEntry({ onEdit: vi.fn(), context: CONTEXT });
expect(entry.kind).toBe("item");
expect(entry.kind === "item" ? entry.key : "").toBe("edit");
expect(consoleErrorSpy).toHaveBeenCalled();
consoleErrorSpy.mockRestore();
});
});
describe("buildEditMenuEntry — AC10 (no plugin actions)", () => {
it("renders the flat Edit item exactly as before", () => {
const entry = renderEntry(vi.fn());
@@ -76,10 +105,31 @@ describe("buildEditMenuEntry — AC9 (plugin action registered)", () => {
if (entry.kind === "submenu") {
expect(entry.children.map((c) => c.key)).toEqual([
"edit-task",
`plugin-edit-${PLUGIN_ID}-enhance`,
`plugin-edit-${PLUGIN_ID}:enhance`,
]);
}
});
it("renders an edit action's children inside the Edit submenu", () => {
registerEnhanceAction({
items: () => [{ id: "quick", label: "Quick enhance", run: vi.fn() }],
});
const entry = buildEditMenuEntry({ onEdit: vi.fn(), context: CONTEXT });
expect(entry.kind).toBe("submenu");
if (entry.kind !== "submenu") return;
const pluginChild = entry.children.find(
(child) => child.key === `plugin-edit-${PLUGIN_ID}:enhance`,
);
expect(pluginChild?.kind).toBe("submenu");
if (pluginChild?.kind !== "submenu") return;
expect(pluginChild.children).toHaveLength(1);
const child = pluginChild.children[0];
expect(child?.kind).toBe("item");
if (child?.kind !== "item") return;
expect(child.label).toBe("Quick enhance");
});
});
describe("buildEditMenuEntry — AC11 (run invoked with context, rejection caught, menu still closes)", () => {
@@ -3,7 +3,7 @@
import { IconPencil } from "@tabler/icons-react";
import { t } from "@/lib/i18n";
import type { PluginTaskMenuContext } from "@/lib/plugins/types";
import { runnablePluginMenuEntry, visiblePluginMenuActions } from "./plugins/task-menu-actions";
import { pluginMenuEntry, visiblePluginMenuActions } from "./plugins/task-menu-actions";
import type { KanbanCardMenuEntry } from "./kanban-card-menu-items";
/**
@@ -29,10 +29,16 @@ export function buildEditMenuEntry({
forceFlat?: boolean;
}): KanbanCardMenuEntry {
const pluginActions = forceFlat ? [] : visiblePluginMenuActions("edit", context);
// Built and filtered before choosing between the submenu and the flat item: an
// action the host cannot render contributes no child, so a list yielding none
// must leave the native Edit item as it is rather than wrap it in a submenu.
const pluginEntries = pluginActions
.map((action) => pluginMenuEntry(action, context, disabled))
.filter((entry): entry is KanbanCardMenuEntry => entry !== null);
const icon = <IconPencil className="mr-2 h-4 w-4" />;
if (pluginActions.length === 0) {
if (pluginEntries.length === 0) {
return {
kind: "item",
key: "edit",
@@ -59,7 +65,7 @@ export function buildEditMenuEntry({
disabled: disabled || !onEdit,
onSelect: onEdit,
},
...pluginActions.map((action) => runnablePluginMenuEntry(action, context, disabled)),
...pluginEntries,
],
};
}
@@ -58,7 +58,7 @@ describe("buildKanbanCardMenuEntries — 'primary' group plugin actions", () =>
const keys = entryKeys(entries);
const sendToIndex = keys.indexOf("send-to-workflow");
const primaryIndex = keys.indexOf(`plugin-primary-${PLUGIN_ID}-${ACTION_ID}`);
const primaryIndex = keys.indexOf(`plugin-primary-${PLUGIN_ID}:${ACTION_ID}`);
const linkIndex = keys.indexOf("link");
expect(sendToIndex).toBeGreaterThanOrEqual(0);
@@ -107,7 +107,7 @@ describe("buildKanbanCardMenuEntries — 'primary' group plugin actions", () =>
"move-to",
"send-to-workflow",
"plugins-separator",
`plugin-primary-${PLUGIN_ID}-${ACTION_ID}`,
`plugin-primary-${PLUGIN_ID}:${ACTION_ID}`,
"remove-separator",
"archive",
"delete",
@@ -119,7 +119,7 @@ describe("buildKanbanCardMenuEntries — 'primary' group plugin actions", () =>
const entries = buildKanbanCardMenuEntries({ workflows: [], stepsByWorkflowId: {} });
expect(entryKeys(entries)).not.toContain(`plugin-primary-${PLUGIN_ID}-${ACTION_ID}`);
expect(entryKeys(entries)).not.toContain(`plugin-primary-${PLUGIN_ID}:${ACTION_ID}`);
});
it("leaves the 'edit' group submenu unaffected by 'primary' group registrations", () => {
@@ -5,9 +5,11 @@ import { IconFlag } from "@tabler/icons-react";
import type { StoreApi } from "zustand";
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@kandev/ui/dropdown-menu";
import { pluginRegistry } from "@/lib/plugins/registry";
import type { PluginTaskMenuContext, TaskMenuSubItemRegistration } from "@/lib/plugins/types";
import { StateProvider, useAppStoreApi } from "@/components/state-provider";
import type { AppState } from "@/lib/state/store";
import {
buildCardPluginEntries,
buildKanbanCardMenuEntries,
KanbanCardDropdownMenuItems,
useKanbanCardMoveTargets,
@@ -22,6 +24,81 @@ const PluginBitbucketIcon = () => null;
const WORKFLOW_ONE_NAME = "Workflow 1";
// Regression: React synthetic events bubble through the fiber tree from a Radix portal; without stopPropagation the parent Card's onClick fires instead of the confirm dialog.
/**
* A card builds its dropdown and context variants from one render with the
* same plugin inputs, so the plugin contributions must be built once: each
* plugin's `items()` is a callback into a foreign bundle, and evaluating it
* twice per render for identical children is the API's own documented
* anti-pattern ("memoize anything expensive").
*/
describe("buildCardPluginEntries", () => {
const PLUGIN_ID = "kandev-plugin-tags";
const CONTEXT: PluginTaskMenuContext = {
workspaceId: "ws-1",
taskId: "task-1",
taskTitle: "Fix the bug",
workflowStepId: "step-1",
presentation: "desktop",
};
afterEach(() => {
pluginRegistry.unregisterPlugin(PLUGIN_ID);
});
function registerItemsAction(items: () => readonly TaskMenuSubItemRegistration[]) {
pluginRegistry.forPlugin(PLUGIN_ID).registerTaskMenuAction({
id: "add-tag",
label: "Add tag",
group: "primary",
items,
run: vi.fn(),
});
}
it("evaluates a plugin action's items() once when both variants share the result", () => {
const items = vi.fn(() => [{ id: "more", label: "More tags", run: vi.fn() }]);
registerItemsAction(items);
const pluginEntries = buildCardPluginEntries({ pluginMenuContext: CONTEXT });
const dropdown = buildKanbanCardMenuEntries({
workflows: [],
stepsByWorkflowId: {},
pluginMenuContext: CONTEXT,
pluginEntries,
});
const contextMenu = buildKanbanCardMenuEntries({
workflows: [],
stepsByWorkflowId: {},
pluginMenuContext: CONTEXT,
pluginEntries,
});
expect(items).toHaveBeenCalledTimes(1);
const fromDropdown = dropdown.find(
(entry) => entry.key === `plugin-primary-${PLUGIN_ID}:add-tag`,
);
const fromContext = contextMenu.find(
(entry) => entry.key === `plugin-primary-${PLUGIN_ID}:add-tag`,
);
expect(fromDropdown).toBeDefined();
expect(fromContext).toBe(fromDropdown);
});
it("still builds its own contributions when a caller passes none", () => {
const items = vi.fn(() => [{ id: "more", label: "More tags", run: vi.fn() }]);
registerItemsAction(items);
const entries = buildKanbanCardMenuEntries({
workflows: [],
stepsByWorkflowId: {},
pluginMenuContext: CONTEXT,
});
expect(items).toHaveBeenCalledTimes(1);
expect(entries.some((entry) => entry.key === `plugin-primary-${PLUGIN_ID}:add-tag`)).toBe(true);
});
});
describe("KanbanCardDropdownMenuItems — click propagation", () => {
function renderWithParent(entries: KanbanCardMenuEntry[], parentOnClick: () => void) {
return render(
@@ -205,7 +282,7 @@ describe("buildKanbanCardMenuEntries — !onEdit does not disable plugin edit ac
(child) => child.kind === "item" && child.key === "edit-task",
);
const pluginAction = editMenu.children.find(
(child) => child.kind === "item" && child.key === `plugin-edit-${PLUGIN_ID}-enhance`,
(child) => child.kind === "item" && child.key === `plugin-edit-${PLUGIN_ID}:enhance`,
);
expect(editTask?.kind === "item" && editTask.disabled).toBe(true);
@@ -253,7 +330,7 @@ describe("buildKanbanCardMenuEntries — 'primary' group plugin actions", () =>
const keys = entryKeys(entries);
const sendToIndex = keys.indexOf("send-to-workflow");
const primaryIndex = keys.indexOf(`plugin-primary-${PLUGIN_ID}-quick-tag`);
const primaryIndex = keys.indexOf(`plugin-primary-${PLUGIN_ID}:quick-tag`);
const archiveIndex = keys.indexOf("archive");
expect(sendToIndex).toBeGreaterThanOrEqual(0);
@@ -281,7 +358,7 @@ describe("buildKanbanCardMenuEntries — 'primary' group plugin actions", () =>
const entries = buildKanbanCardMenuEntries({ workflows: [], stepsByWorkflowId: {} });
expect(entryKeys(entries)).not.toContain(`plugin-primary-${PLUGIN_ID}-quick-tag`);
expect(entryKeys(entries)).not.toContain(`plugin-primary-${PLUGIN_ID}:quick-tag`);
});
it("leaves the 'edit' group submenu unaffected by 'primary' group registrations", () => {
+58 -13
View File
@@ -138,6 +138,14 @@ export type BuildKanbanCardMenuEntriesArgs = {
onSendToWorkflow?: (workflowId: string, stepId: string) => void;
/** Defaults to an empty-id context (no visible plugin actions match it in practice). */
pluginMenuContext?: PluginTaskMenuContext;
/**
* Plugin contributions already built from this call's `PluginEntryInputs`.
* Callers must build them with the same inputs they pass here: the entries
* carry the disabled state, the edit handler and the menu context, so reusing
* a set across an input mismatch silently renders one call's plugin entries
* with another's behaviour. Building them in place is always correct.
*/
pluginEntries?: CardPluginEntries;
/**
* Forces the flat Edit item regardless of registered plugin `edit`-group
* actions. Group `edit` is a card-only plugin contract; surfaces outside
@@ -158,6 +166,43 @@ export function resolvePluginMenuContext(context?: PluginTaskMenuContext): Plugi
return context ?? EMPTY_PLUGIN_MENU_CONTEXT;
}
/** The two plugin-derived pieces of a card menu: group "primary", and `Edit`. */
export type CardPluginEntries = { primary: KanbanCardMenuEntry[]; edit: KanbanCardMenuEntry };
/** The card-menu inputs that decide those two entries. */
export type PluginEntryInputs = Pick<
BuildKanbanCardMenuEntriesArgs,
| "disabled"
| "isDeleting"
| "isArchiving"
| "isDetaching"
| "onEdit"
| "forceFlatEdit"
| "pluginMenuContext"
>;
/**
* Builds both plugin-derived card menu entries. `useKanbanCardMenus` builds one
* card's dropdown and context variants in a single render and they share every
* input here, so it calls this once and passes the result to both -- otherwise
* each plugin action's `items()` runs twice per render for the same children.
*/
export function buildCardPluginEntries(args: PluginEntryInputs): CardPluginEntries {
const isProcessing = Boolean(
args.disabled || args.isDeleting || args.isArchiving || args.isDetaching,
);
const context = resolvePluginMenuContext(args.pluginMenuContext);
return {
primary: buildPrimaryPluginEntries({ disabled: isProcessing, context }),
edit: buildEditMenuEntry({
onEdit: args.onEdit,
disabled: isProcessing,
context,
forceFlat: args.forceFlatEdit,
}),
};
}
function StepBadges({ step, isCurrent }: { step: TaskMoveStep; isCurrent: boolean }) {
const { t } = useTranslation();
const hasAutoStart = stepHasAutoStart(step);
@@ -371,6 +416,7 @@ export function buildKanbanCardMenuEntries({
onMoveToStep,
onSendToWorkflow,
pluginMenuContext,
pluginEntries,
forceFlatEdit,
}: BuildKanbanCardMenuEntriesArgs): KanbanCardMenuEntry[] {
const visibleWorkflows = workflows.filter((workflow) => !workflow.hidden);
@@ -396,10 +442,16 @@ export function buildKanbanCardMenuEntries({
onSendToWorkflow,
});
const pluginEntries = buildPrimaryPluginEntries({
disabled: isProcessing,
context: resolvePluginMenuContext(pluginMenuContext),
});
// `forceFlatEdit` outranks a prebuilt bundle: those surfaces must never show the submenu form.
const pluginContributions =
pluginEntries && !forceFlatEdit
? pluginEntries
: buildCardPluginEntries({
disabled: isProcessing,
onEdit,
forceFlatEdit,
pluginMenuContext,
});
const linkEntry = buildLinkSubmenu({
disabled: isProcessing,
@@ -418,14 +470,7 @@ export function buildKanbanCardMenuEntries({
{ key: "priority", entries: priorityEntry ? [priorityEntry] : [] },
{
key: "edit",
entries: [
buildEditMenuEntry({
onEdit,
disabled: isProcessing,
context: resolvePluginMenuContext(pluginMenuContext),
forceFlat: forceFlatEdit,
}),
],
entries: [pluginContributions.edit],
},
{
key: "relationships",
@@ -439,7 +484,7 @@ export function buildKanbanCardMenuEntries({
(entry): entry is KanbanCardMenuEntry => entry !== null,
),
},
{ key: "plugins", entries: pluginEntries },
{ key: "plugins", entries: pluginContributions.primary },
{
key: "remove",
entries: [
+15
View File
@@ -2,6 +2,7 @@
import { useRef } from "react";
import {
buildCardPluginEntries,
buildKanbanCardMenuEntries,
useKanbanCardMoveTargets,
} from "@/components/kanban-card-menu-items";
@@ -220,6 +221,18 @@ export function useKanbanCardMenus({
};
const pluginMenuContext = buildPluginMenuContext(task, workspaceId, presentation);
// Both variants below share every input the plugin entries depend on -- the
// processing flags, the edit handler and the context -- so build them once:
// passing one result to both keeps each plugin action's items() to a single
// evaluation per card per render (see buildCardPluginEntries).
const pluginEntries = buildCardPluginEntries({
disabled: menuBase.disabled,
isDeleting: menuBase.isDeleting,
isArchiving: menuBase.isArchiving,
isDetaching: menuBase.isDetaching,
onEdit: menuBase.onEdit,
pluginMenuContext,
});
return {
...dialogs,
@@ -228,12 +241,14 @@ export function useKanbanCardMenus({
onMoveToStep: moveMenu.moveToStepFromDropdown,
onSendToWorkflow: moveMenu.sendTaskToWorkflow,
pluginMenuContext,
pluginEntries,
}),
contextMenuEntries: buildKanbanCardMenuEntries({
...menuBase,
onMoveToStep: moveMenu.moveSelectedToStep,
onSendToWorkflow: moveMenu.sendSelectionToWorkflow,
pluginMenuContext,
pluginEntries,
}),
isDetaching,
detachAnchorRef,
@@ -27,7 +27,7 @@ async function invokePluginAction(presentation: "desktop" | "mobile") {
if (entry.kind !== "submenu") throw new Error("expected Edit submenu");
const action = entry.children.find(
(child) =>
child.kind === "item" && child.key === `plugin-edit-${PLUGIN_ID}-action-${presentation}`,
child.kind === "item" && child.key === `plugin-edit-${PLUGIN_ID}:action-${presentation}`,
);
if (action?.kind !== "item") throw new Error("expected plugin action");
@@ -0,0 +1,621 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { createElement } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@kandev/ui/dropdown-menu";
import { pluginRegistry } from "@/lib/plugins/registry";
import type {
PluginIcon,
PluginTaskMenuContext,
TaskMenuSubItemRegistration,
} from "@/lib/plugins/types";
import {
buildCardPluginEntries,
KanbanCardDropdownMenuItems,
type KanbanCardMenuEntry,
} from "../kanban-card-menu-items";
import { buildPrimaryPluginEntries } from "./task-menu-actions";
const PLUGIN_ID = "kandev-plugin-tags";
const ACTION_ID = "add-tag";
const ACTION_LABEL = "Add tag";
const ACTION_KEY = `plugin-primary-${PLUGIN_ID}:${ACTION_ID}`;
const CONTEXT: PluginTaskMenuContext = {
workspaceId: "ws-1",
taskId: "task-1",
taskTitle: "Fix the bug",
workflowStepId: "step-1",
presentation: "desktop",
};
function registerAction(
overrides: {
id?: string;
label?: string;
group?: "edit" | "primary";
icon?: PluginIcon;
run?: (context: PluginTaskMenuContext) => void | Promise<void>;
// Deliberately wider than the contract: this helper also registers results
// the types forbid (a promise, an unknown-returning wrapper), which is what
// a JavaScript bundle can actually send.
items?: (context: PluginTaskMenuContext) => unknown;
} = {},
) {
const run = overrides.run ?? vi.fn();
pluginRegistry.forPlugin(PLUGIN_ID).registerTaskMenuAction({
id: overrides.id ?? ACTION_ID,
label: overrides.label ?? ACTION_LABEL,
group: overrides.group ?? "primary",
run,
...(overrides.icon ? { icon: overrides.icon } : {}),
...(overrides.items
? {
items: overrides.items as (
context: PluginTaskMenuContext,
) => readonly TaskMenuSubItemRegistration[],
}
: {}),
});
return run;
}
function renderEntries(entries: KanbanCardMenuEntry[]) {
render(
<DropdownMenu defaultOpen>
<DropdownMenuTrigger>open</DropdownMenuTrigger>
<DropdownMenuContent>
<KanbanCardDropdownMenuItems entries={entries} />
</DropdownMenuContent>
</DropdownMenu>,
);
}
/** Radix opens a submenu on a mouse pointer-move over its trigger. */
async function openSubmenu(label: string, childLabel: string) {
fireEvent.pointerMove(screen.getByRole("menuitem", { name: label }), { pointerType: "mouse" });
return screen.findByRole("menuitem", { name: childLabel });
}
afterEach(() => {
cleanup();
pluginRegistry.unregisterPlugin(PLUGIN_ID);
});
describe("buildPrimaryPluginEntries — action without items", () => {
it("stays a flat item that runs run(context)", async () => {
const run = registerAction();
renderEntries(buildPrimaryPluginEntries({ context: CONTEXT }));
fireEvent.click(screen.getByRole("menuitem", { name: ACTION_LABEL }));
await Promise.resolve();
expect(run).toHaveBeenCalledWith(CONTEXT);
});
});
describe("buildPrimaryPluginEntries — action declaring items", () => {
it("renders a submenu whose children are the resolved items, in order", () => {
registerAction({
items: () => [
{ id: "more", label: "More tags", run: vi.fn() },
{ id: "blocked", label: "Blocked", run: vi.fn() },
],
});
const entries = buildPrimaryPluginEntries({ context: CONTEXT });
expect(entries.map((entry) => entry.kind)).toEqual(["submenu"]);
const entry = entries[0];
if (entry.kind !== "submenu") return;
expect(entry.key).toBe(ACTION_KEY);
expect(entry.label).toBe(ACTION_LABEL);
expect(entry.children.map((child) => child.key)).toEqual([
`${ACTION_KEY}#more`,
`${ACTION_KEY}#blocked`,
]);
// Every child is a selectable item, not a separator or a nested submenu.
expect(
entry.children.map((child) => (child.kind === "item" ? child.label : child.kind)),
).toEqual(["More tags", "Blocked"]);
});
it("runs the selected child with the menu context, not the action's fallback", async () => {
const more = vi.fn();
const blocked = vi.fn();
const run = registerAction({
items: () => [
{ id: "more", label: "More tags", run: more },
{ id: "blocked", label: "Blocked", run: blocked },
],
});
renderEntries(buildPrimaryPluginEntries({ context: CONTEXT }));
const blockedItem = await openSubmenu(ACTION_LABEL, "Blocked");
fireEvent.click(blockedItem);
await Promise.resolve();
expect(blocked).toHaveBeenCalledWith(CONTEXT);
expect(more).not.toHaveBeenCalled();
expect(run).not.toHaveBeenCalled();
});
it("keeps a disabled child unselectable", async () => {
const disabledRun = vi.fn();
registerAction({
items: () => [{ id: "blocked", label: "Blocked", disabled: true, run: disabledRun }],
});
renderEntries(buildPrimaryPluginEntries({ context: CONTEXT }));
const child = await openSubmenu(ACTION_LABEL, "Blocked");
fireEvent.click(child);
await Promise.resolve();
expect(disabledRun).not.toHaveBeenCalled();
});
it("disables every child while the entry itself is disabled", async () => {
const childRun = vi.fn();
registerAction({ items: () => [{ id: "blocked", label: "Blocked", run: childRun }] });
const entry = buildPrimaryPluginEntries({ context: CONTEXT, disabled: true })[0];
expect(entry.kind).toBe("submenu");
if (entry.kind !== "submenu") return;
// A disabled trigger cannot be opened at all; rendering its children
// directly is what proves the disabled flag reached them.
expect(entry.disabled).toBe(true);
renderEntries(entry.children);
fireEvent.click(screen.getByRole("menuitem", { name: "Blocked" }));
await Promise.resolve();
expect(childRun).not.toHaveBeenCalled();
});
// A plugin's items() runs inside the host's menu build, so an unusable list
// must never produce a trigger with nothing behind it: the action's run() is
// what a host predating `items` would have used anyway.
it("logs and renders the flat fallback when items() throws", () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
registerAction({
items: () => {
throw new Error("items() blew up");
},
});
expect(buildPrimaryPluginEntries({ context: CONTEXT }).map((entry) => entry.kind)).toEqual([
"item",
]);
expect(consoleErrorSpy).toHaveBeenCalled();
consoleErrorSpy.mockRestore();
});
it("renders the flat fallback when items() yields nothing", () => {
registerAction({ items: () => [] });
expect(buildPrimaryPluginEntries({ context: CONTEXT }).map((entry) => entry.kind)).toEqual([
"item",
]);
});
it("logs a rejecting child run without throwing", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
registerAction({
items: () => [
{ id: "blocked", label: "Blocked", run: () => Promise.reject(new Error("boom")) },
],
});
renderEntries(buildPrimaryPluginEntries({ context: CONTEXT }));
const child = await openSubmenu(ACTION_LABEL, "Blocked");
expect(() => fireEvent.click(child)).not.toThrow();
await vi.waitFor(() => expect(consoleErrorSpy).toHaveBeenCalled());
consoleErrorSpy.mockRestore();
});
});
describe("buildPrimaryPluginEntries — malformed plugin results", () => {
// Plugin bundles are plain JavaScript: the registration types are a promise,
// not a guarantee. Every shape a broken plugin can produce must degrade to
// the flat fallback (or drop just the unusable child) instead of crashing the
// card render or handing React children it cannot key. Each case registers a
// distinct action id, since one broken action is reported once -- the host
// rebuilds entries on every render, so a per-build log would spam.
const fallbackCases: [string, () => unknown][] = [
["a null child", () => [null]],
["a child without an id or label", () => [{ run: vi.fn() }]],
["a child with a blank id", () => [{ id: " ", label: "X", run: vi.fn() }]],
["a child with a blank label", () => [{ id: "x", label: " ", run: vi.fn() }]],
[
"a child with a non-boolean disabled",
() => [{ id: "x", label: "X", run: vi.fn(), disabled: 1 }],
],
["a child without a run", () => [{ id: "x", label: "X" }]],
["a child with an unusable icon", () => [{ id: "x", label: "X", icon: {}, run: vi.fn() }]],
["a non-array result", () => "nope"],
["a promise", () => Promise.resolve([{ id: "x", label: "X", run: vi.fn() }])],
[
"an array whose methods were replaced",
() =>
new Proxy([{ id: "x", label: "X", run: vi.fn() }], {
get() {
throw new Error("trap");
},
}),
],
[
"a thenable that throws on lookup",
() =>
Object.defineProperty({}, "then", {
get() {
throw new Error("then getter");
},
}),
],
];
for (const [index, [label, items]] of fallbackCases.entries()) {
it(`falls back to the flat item and reports ${label} once`, async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
registerAction({ id: `broken-${index}`, items });
expect(buildPrimaryPluginEntries({ context: CONTEXT }).map((entry) => entry.kind)).toEqual([
"item",
]);
// A second build must not log again: the defect is permanent for a
// loaded bundle, and this callback runs on every render.
expect(buildPrimaryPluginEntries({ context: CONTEXT }).map((entry) => entry.kind)).toEqual([
"item",
]);
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
consoleErrorSpy.mockRestore();
// A rejected promise must not escape as an unhandled rejection; one
// macrotask is enough for vitest to surface it if it were unobserved.
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
it("observes a rejected promise, so it cannot escape as an unhandled rejection", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
registerAction({ id: "rejecting", items: () => Promise.reject(new Error("boom")) });
expect(buildPrimaryPluginEntries({ context: CONTEXT }).map((entry) => entry.kind)).toEqual([
"item",
]);
// Vitest fails the file on an unhandled rejection, so reaching the end of
// this test at all proves the rejection was observed -- but the log has to
// say so too, which the deleted-observation mutation otherwise hides.
await vi.waitFor(() =>
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining("items() rejected"),
expect.any(Error),
),
);
consoleErrorSpy.mockRestore();
});
});
describe("buildPrimaryPluginEntries — unreadable and repeated children", () => {
it("reports a repeated child id and keeps the first entry", () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const first = { id: "dup", label: "First", run: vi.fn() };
registerAction({
id: "duplicated",
items: () => [first, { id: "dup", label: "Second", run: vi.fn() }],
});
const entry = buildPrimaryPluginEntries({ context: CONTEXT })[0];
expect(entry.kind).toBe("submenu");
if (entry.kind !== "submenu") return;
expect(entry.children.map((child) => child.key)).toEqual([
`plugin-primary-${PLUGIN_ID}:duplicated#dup`,
]);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining("repeated a child id"),
"dup",
);
consoleErrorSpy.mockRestore();
});
it("reports a later defect of a different kind on the same action", () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
let impl: () => unknown = () => [null];
registerAction({ id: "varies", items: () => impl() });
buildPrimaryPluginEntries({ context: CONTEXT });
impl = () => {
throw new Error("now it throws");
};
buildPrimaryPluginEntries({ context: CONTEXT });
expect(consoleErrorSpy.mock.calls.map((call) => String(call[0]))).toEqual([
expect.stringContaining("unusable child"),
expect.stringContaining("could not be read"),
]);
consoleErrorSpy.mockRestore();
});
it("falls back when a child cannot be read at all", () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const hostile = { id: "x", label: "X", run: vi.fn() };
Object.defineProperty(hostile, "icon", {
enumerable: true,
get() {
throw new Error("getter blew up");
},
});
registerAction({ id: "hostile-child", items: () => [hostile] });
let entries: KanbanCardMenuEntry[] = [];
expect(() => {
entries = buildPrimaryPluginEntries({ context: CONTEXT });
}).not.toThrow();
expect(entries.map((entry) => entry.kind)).toEqual(["item"]);
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
consoleErrorSpy.mockRestore();
});
it("keeps the usable children and drops only the broken ones", () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
registerAction({
id: "partly-broken",
items: () => [
{ id: "gone", label: "Gone", run: vi.fn() },
{ id: "", label: "No id", run: vi.fn() },
{ id: "ok", label: "Ok", run: vi.fn() },
{ id: "gone", label: "Duplicate id", run: vi.fn() },
],
});
const entries = buildPrimaryPluginEntries({ context: CONTEXT });
expect(entries.map((entry) => entry.kind)).toEqual(["submenu"]);
const entry = entries[0];
if (entry.kind !== "submenu") return;
expect(entry.children.map((child) => child.key)).toEqual([
`plugin-primary-${PLUGIN_ID}:partly-broken#gone`,
`plugin-primary-${PLUGIN_ID}:partly-broken#ok`,
]);
consoleErrorSpy.mockRestore();
});
});
describe("buildPrimaryPluginEntries — unusable registrations and array-likes", () => {
// A registration is plugin-authored data as much as its `items()` result: an
// action whose own fields cannot be read degrades to no entry instead of
// handing React an object child, and a result whose array methods are
// hostile must be copied rather than called.
it("omits an action whose label is not a usable string, and logs once", () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
// Registered around the helper, which defaults a missing label.
const broken = (id: string, label: unknown) => ({ id, label, group: "primary", run: vi.fn() });
pluginRegistry
.forPlugin(PLUGIN_ID)
.registerTaskMenuAction(broken("blank-label", " ") as never);
pluginRegistry
.forPlugin(PLUGIN_ID)
.registerTaskMenuAction(broken("object-label", { toString: () => "not a label" }) as never);
const entries = buildPrimaryPluginEntries({ context: CONTEXT });
expect(entries).toEqual([]);
// One report per action: the two registrations fail the same way but are
// different action ids.
expect(consoleErrorSpy).toHaveBeenCalledTimes(2);
consoleErrorSpy.mockRestore();
});
it("renders an action whose icon is an object that refuses to be coerced", () => {
// The icon resolver looks a name up in its curated map, so a non-string
// icon is no name at all: the entry keeps rendering with the fallback glyph
// instead of throwing out of the card's render while the map coerces the
// object to a key.
const icon = {
toString() {
throw new Error("key coercion");
},
};
registerAction({
id: "object-icon",
label: "Object icon",
icon: icon as unknown as PluginIcon,
});
const entries = buildPrimaryPluginEntries({ context: CONTEXT });
expect(entries.map((entry) => entry.kind)).toEqual(["item"]);
const entry = entries[0];
if (entry.kind !== "item") return;
expect(entry.label).toBe("Object icon");
expect(entry.icon).toBeDefined();
});
it("copies a hostile array-like instead of calling its methods", () => {
class HostileArray extends Array<unknown> {
forEach(): void {
throw new Error("forEach is not yours to call");
}
map(): never {
throw new Error("map is not yours to call");
}
}
const items = HostileArray.from([{ id: "child", label: "Child", run: vi.fn() }]);
registerAction({ id: "hostile-array", items: () => items });
const entry = buildPrimaryPluginEntries({ context: CONTEXT })[0];
expect(entry.kind).toBe("submenu");
if (entry.kind !== "submenu") return;
expect(entry.children.map((child) => child.key)).toEqual([
`plugin-primary-${PLUGIN_ID}:hostile-array#child`,
]);
});
it("treats every processing flag as disabling the plugin entries, on both paths", () => {
registerAction({ id: "flagged", items: () => [{ id: "child", label: "Child", run: vi.fn() }] });
for (const flag of ["disabled", "isDeleting", "isArchiving", "isDetaching"] as const) {
const inputs = { pluginMenuContext: CONTEXT, [flag]: true };
const prebuilt = buildCardPluginEntries(inputs).primary.find(
(entry) => entry.key === `plugin-primary-${PLUGIN_ID}:flagged`,
);
const internal = buildPrimaryPluginEntries({ context: CONTEXT, disabled: true })[0];
// Both paths must agree, and a submenu's children inherit the state.
const prebuiltSubmenu = prebuilt?.kind === "submenu" ? prebuilt : undefined;
const internalSubmenu = internal.kind === "submenu" ? internal : undefined;
expect(prebuiltSubmenu?.disabled, flag).toBe(true);
expect(internalSubmenu?.disabled, flag).toBe(true);
expect(
prebuiltSubmenu?.children.map((child) =>
child.kind === "item" ? child.disabled : undefined,
),
flag,
).toEqual([true]);
}
});
});
describe("buildPrimaryPluginEntries — shapes that must survive the boundary", () => {
it("keeps a child whose icon is an exotic component", () => {
// React's memo/forwardRef/lazy return objects, not functions, and an icon
// set -- which host-api tells plugins to bundle -- is built from
// forwardRef, so rejecting objects would drop a contract-valid child.
// memo() returns a non-callable object; a function carrying $$typeof would
// not reproduce the shape that `typeof icon === "function"` accepts.
const ExoticIcon = { $$typeof: Symbol.for("react.memo"), type: () => null };
registerAction({
id: "exotic-icon",
items: () => [{ id: "ok", label: "Ok", icon: ExoticIcon as never, run: vi.fn() }],
});
const entry = buildPrimaryPluginEntries({ context: CONTEXT })[0];
expect(entry?.kind).toBe("submenu");
if (entry?.kind !== "submenu") return;
expect(entry.children.map((child) => child.key)).toEqual([
`plugin-primary-${PLUGIN_ID}:exotic-icon#ok`,
]);
});
it("keeps a child with an absent or null icon and drops an unusable one", () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
registerAction({
id: "icon-shapes",
items: () => [
{ id: "null-icon", label: "Null icon", icon: null, run: vi.fn() },
{ id: "no-icon", label: "No icon", run: vi.fn() },
{ id: "array-icon", label: "Array icon", icon: [] as never, run: vi.fn() },
{ id: "object-icon", label: "Object icon", icon: {} as never, run: vi.fn() },
{ id: "null-disabled", label: "Null disabled", disabled: null as never, run: vi.fn() },
],
});
const entry = buildPrimaryPluginEntries({ context: CONTEXT })[0];
expect(entry?.kind).toBe("submenu");
if (entry?.kind !== "submenu") return;
expect(entry.children.map((child) => child.key)).toEqual([
`plugin-primary-${PLUGIN_ID}:icon-shapes#null-icon`,
`plugin-primary-${PLUGIN_ID}:icon-shapes#no-icon`,
`plugin-primary-${PLUGIN_ID}:icon-shapes#null-disabled`,
]);
// The two unusable shapes are one defect of the same kind: one report.
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
consoleErrorSpy.mockRestore();
});
});
describe("buildPrimaryPluginEntries — ids the encoder must survive", () => {
// Regression: `encodeURIComponent` raises `URIError` on a lone surrogate, which
// a truncated astral character (`slice`, `[0]`) produces. The throw left
// `pluginMenuEntry`, so it escaped the builder called during a card's render and
// unmounted the whole route -- and a lone surrogate is a non-blank string, so the
// id guard did not catch it either.
it("builds an entry whose id is a lone surrogate instead of throwing", () => {
const loneSurrogate = "\ud83c";
registerAction({
id: loneSurrogate,
items: () => [{ id: loneSurrogate, label: "Child", run: vi.fn() }],
});
const entries = buildPrimaryPluginEntries({ context: CONTEXT });
expect(entries).toHaveLength(1);
expect(entries[0]?.key).toContain(loneSurrogate);
expect(entries[0]?.kind === "submenu" ? entries[0].children[0]?.key : "").toContain(
loneSurrogate,
);
});
it("keeps an escaped unit from running into the literal after it", () => {
// Regression from review: an encoder that emits `%XX`-style tokens cannot use a
// bare `%` as the part separator, because the separator is then indistinguishable
// from the start of a token. Only `%`, `:` and `#` are escaped now.
registerAction({ id: "%0", label: "Percent" });
registerAction({ id: "\u0250", label: "Unit" });
// The same class one width up: a two-digit escape plus one literal hex digit.
registerAction({ id: "\u00e9a", label: "Accented" });
registerAction({ id: "\u0e9a", label: "Thai" });
registerAction({
id: "children",
label: "Children",
items: () => [
{ id: "\u00e9a", label: "Accented child", run: vi.fn() },
{ id: "\u0e9a", label: "Thai child", run: vi.fn() },
],
});
const entries = buildPrimaryPluginEntries({ context: CONTEXT });
const keys = entries.flatMap((entry) =>
entry
? [entry.key, ...(entry.kind === "submenu" ? entry.children.map((c) => c.key) : [])]
: [],
);
expect(keys).toHaveLength(7);
expect(new Set(keys).size).toBe(7);
});
it("keeps a lone-surrogate id distinct from a valid astral one", () => {
registerAction({ id: "\ud83c", label: "Half" });
registerAction({ id: "\ud83c\udf89", label: "Whole" });
const keys = buildPrimaryPluginEntries({ context: CONTEXT }).map((entry) => entry?.key);
expect(new Set(keys).size).toBe(2);
});
});
describe("buildPrimaryPluginEntries — mixed registrations", () => {
it("renders a submenu action as one entry and keeps the rest flat", () => {
registerAction({ id: "add-tag", items: () => [{ id: "more", label: "More", run: vi.fn() }] });
registerAction({ id: "other", label: "Other action" });
const entries = buildPrimaryPluginEntries({ context: CONTEXT });
expect(entries.map((entry) => entry.kind)).toEqual(["submenu", "item"]);
expect(entries.map((entry) => entry.key)).toEqual([
ACTION_KEY,
`plugin-primary-${PLUGIN_ID}:other`,
]);
});
});
describe("buildPrimaryPluginEntries — icon resolution", () => {
// Regression: icons registered before the name/component resolution landed
// are ready-made elements (kandev-plugin-tags ships one). Reading one as a
// name looked up `PLUGIN_ICONS["[object Object]"]` and rendered the puzzle
// fallback glyph instead of the plugin's own icon.
it("renders an element-form icon instead of the fallback glyph", () => {
const elementIcon = createElement(
"svg",
{ "data-testid": "plugin-menu-icon", viewBox: "0 0 24 24" },
createElement("path", { d: "M4 4h16" }),
);
registerAction({ icon: elementIcon as unknown as PluginIcon });
renderEntries(buildPrimaryPluginEntries({ context: CONTEXT }));
expect(screen.getByTestId("plugin-menu-icon")).toBeTruthy();
});
});
+331 -39
View File
@@ -1,9 +1,13 @@
import { createElement } from "react";
import { createElement, isValidElement, type ReactNode } from "react";
import { pluginRegistry } from "@/lib/plugins/registry";
import { resolvePluginIcon } from "@/lib/plugins/icons";
import type { PluginTaskMenuContext } from "@/lib/plugins/types";
import { isPluginIconComponent, resolvePluginIcon } from "@/lib/plugins/icons";
import type { PluginTaskMenuActionRegistration } from "@/lib/plugins/registry-registration-types";
import type {
PluginIcon,
PluginTaskMenuContext,
TaskMenuSubItemRegistration,
} from "@/lib/plugins/types";
import type { KanbanCardMenuEntry } from "../kanban-card-menu-items";
type PluginTaskMenuAction = ReturnType<typeof pluginRegistry.getTaskMenuActions>[number];
/**
* Registered actions for `group`, filtered by `visible(context)` (default
@@ -11,16 +15,16 @@ type PluginTaskMenuAction = ReturnType<typeof pluginRegistry.getTaskMenuActions>
* treated as hidden — the same defensive handling as `run`'s rejection.
*/
export function visiblePluginMenuActions(
group: PluginTaskMenuAction["group"],
group: PluginTaskMenuActionRegistration["group"],
context: PluginTaskMenuContext,
): PluginTaskMenuAction[] {
): PluginTaskMenuActionRegistration[] {
return pluginRegistry.getTaskMenuActions(group).filter((action) => {
if (!action.visible) return true;
try {
return action.visible(context);
} catch (error: unknown) {
console.error(
`[plugins] task menu action "${action.pluginId}:${action.id}" visible() threw`,
`[plugins] task menu action "${describeAction(action)}" visible() threw`,
error,
);
return false;
@@ -29,41 +33,328 @@ export function visiblePluginMenuActions(
}
/**
* Builds a runnable menu entry for a plugin task menu action. A `run` that
* throws or rejects is caught and logged; the menu still closes because
* `DropdownMenuItem`/`ContextMenuItem` already close on select regardless
* of the async result.
* Plugin icons get the same sizing every neighbouring native menu icon uses.
* A ready-made element — the shape registered before icon names/components
* were resolved, and still shipped by kandev-plugin-tags — renders as-is:
* passing it to the resolver would treat it as a name, miss the curated map,
* and silently replace the plugin's own glyph with the fallback puzzle.
*/
export function runnablePluginMenuEntry(
action: PluginTaskMenuAction,
function pluginMenuIcon(icon?: PluginIcon): ReactNode {
if (!icon) return undefined;
try {
if (isValidElement(icon)) return icon;
return createElement(resolvePluginIcon(icon), { className: "mr-2 h-4 w-4" });
} catch {
// `isValidElement` reads `$$typeof` again, so an icon whose accessors answer
// once and then throw must mean "no icon" rather than a failed render.
return undefined;
}
}
/**
* Plugin bundles are plain JavaScript, so the registration types are a
* promise, not a guarantee. One log per action *and kind* is enough: `items()`
* runs on every menu build, so a permanently malformed registration would
* otherwise log on every render, while a later defect of a different kind on
* the same action still reports. The set is never cleared, so the same kind
* after re-registration stays silent for the life of the page.
* The registry guards its own read of each registration before this module
* receives a copy. The guards below handle malformed values in that copy.
*/
const loggedMenuDefects = new Set<string>();
/**
* A plugin value's printable form. Template interpolation would call `ToString`,
* which throws for a value that has no string form -- a `Symbol` id or a
* null-prototype object -- and the omission paths are exactly where such a value
* arrives, so the log must not be able to throw.
*/
function printable(value: unknown): string {
if (typeof value === "string") return value;
try {
return String(value);
} catch {
// i18n-exempt: log placeholder for a value with no string form, never rendered.
return "<unprintable>";
}
}
/** The action's identity as a log label, read defensively for the same reason. */
function describeAction(action: PluginTaskMenuActionRegistration): string {
try {
return `${printable(action.pluginId)}:${printable(action.id)}`;
} catch {
// i18n-exempt: log placeholder for a registration that cannot be read, never rendered.
return "<unreadable registration>";
}
}
function logMenuDefect(
action: PluginTaskMenuActionRegistration,
kind: string,
detail?: unknown,
): void {
const actionLabel = describeAction(action);
const key = `${actionLabel}:${kind}`;
if (loggedMenuDefects.has(key)) return;
loggedMenuDefects.add(key);
console.error(`[plugins] task menu action "${actionLabel}" ${kind}`, detail);
}
/**
* The action's own label, read once and only when it is a non-blank string.
* A registration is plugin-authored data too, so a missing or hostile accessor
* omits the entry (and reports it) rather than handing React an object child.
*/
/**
* Escapes the three characters a menu key gives meaning to -- `%` first, so an
* escape already inside an id cannot alias one this adds, then `:` and `#` -- and
* leaves every other character verbatim.
*
* That is what makes the key injective for any plugin-authored string: every `%`
* in the result starts one of exactly three two-character escapes, so decoding is
* unambiguous, and neither the part separator (`:`) nor the child separator (`#`)
* survives raw inside a part. A `%XX`-style per-code-unit escape cannot do this,
* because a bare `%` separator is then indistinguishable from the start of an
* escape: `p` + `abcd\uABCD` and `p\uABCD` + `abcd` would share one key.
*
* It also cannot throw, which is why it replaced `encodeURIComponent` here: that
* raises `URIError` on a lone surrogate, and an exception on this path escapes a
* card's render, where no error boundary catches it.
*/
function encodeKeyPart(value: string): string {
return value.replace(/%/g, "%25").replace(/:/g, "%3A").replace(/#/g, "%23");
}
/** The action's own id, read once and only when it is a non-blank string. */
function readActionId(action: PluginTaskMenuActionRegistration): string | null {
try {
const { id } = action;
return typeof id === "string" && id.trim().length > 0 ? id : null;
} catch {
return null;
}
}
function readActionLabel(action: PluginTaskMenuActionRegistration): string | null {
try {
const { label } = action;
return typeof label === "string" && label.trim().length > 0 ? label : null;
} catch {
return null;
}
}
/** The action's own icon, read once; an unreadable one simply means no icon. */
function readActionIcon(action: PluginTaskMenuActionRegistration): ReactNode {
try {
return pluginMenuIcon(action.icon);
} catch {
logMenuDefect(action, "registration icon could not be read");
return undefined;
}
}
/**
* An icon the menu can render: a curated name, a plugin-owned component
* (including React's exotic components, which are objects at run time), or the
* menu-only ready-made element. Everything else is a defect, while an absent
* icon is null or undefined in a plain bundle.
*/
function isRenderableIcon(icon: unknown): boolean {
if (icon === undefined || icon === null) return true;
return typeof icon === "string" || isPluginIconComponent(icon) || isValidElement(icon);
}
/** One child, read once into the plain values the menu actually renders. */
type SubItemSnapshot = {
id: string;
label: string;
icon?: PluginIcon;
disabled?: boolean;
run: (context: PluginTaskMenuContext) => void | Promise<void>;
};
/**
* Reads one plugin-supplied child into a snapshot the render can trust: a
* non-blank id and label, a callable run, and optional fields of the shapes
* the entry builder understands (a curated name, a component, or a ready-made
* element -- never an arbitrary object, which the icon resolver would coerce
* into a key). Everything the render will read is read here, inside the
* catch, so a throwing getter or a Proxy fails this child instead of the
* card's render, and nothing is read twice.
*/
function readSubItem(item: unknown): SubItemSnapshot | null {
if (!item || typeof item !== "object") return null;
try {
const { id, label, icon, disabled, run } = item as Partial<TaskMenuSubItemRegistration>;
if (typeof id !== "string" || id.trim().length === 0) return null;
if (typeof label !== "string" || label.trim().length === 0) return null;
if (typeof run !== "function") return null;
if (disabled !== undefined && disabled !== null && typeof disabled !== "boolean") return null;
if (!isRenderableIcon(icon)) return null;
return { id, label, icon: icon ?? undefined, disabled: disabled ?? undefined, run };
} catch {
return null;
}
}
/**
* The children of an action that declares `items`, or null when the action
* has no usable submenu — leaving `run` as the entry's behavior.
*
* That fallback is the whole defensive boundary, because `items()` is a
* plugin callback running inside the host's menu build, and every way its
* result can be unreadable is the same answer: a throw, a thenable (the
* contract is synchronous), a non-array, a result whose entries cannot be
* rendered (blank/absent id or label, no callable run, an icon of the wrong
* shape), a child that cannot even be read (a throwing getter, a Proxy), and
* a repeated id all degrade to the flat item — or drop just that child —
* instead of crashing the card's render, producing a trigger nothing can
* open, or handing React children it cannot key. The body is one `try` on
* purpose: a plugin object is free to throw from any property access,
* including the array methods and the `then` lookup this function would
* otherwise perform on it.
*/
function pluginSubItems(
action: PluginTaskMenuActionRegistration,
context: PluginTaskMenuContext,
): readonly SubItemSnapshot[] | null {
try {
if (typeof action.items !== "function") return null;
const result: unknown = action.items(context);
if (result && typeof (result as { then?: unknown }).then === "function") {
logMenuDefect(action, "items() returned a promise; it must be synchronous");
// Observe the rejection so an out-of-contract async callback cannot
// surface as an unhandled rejection out of the host's menu build.
void Promise.resolve(result).catch((error: unknown) => {
logMenuDefect(action, "items() rejected", error);
});
return null;
}
if (!Array.isArray(result)) {
// null/undefined means "no children"; anything else is out of contract.
if (result !== null && result !== undefined) {
logMenuDefect(action, "items() must return an array");
}
return null;
}
// Array.from copies through the array's own length and indices, so no
// array method is looked up on the plugin's object.
const list = Array.from(result as readonly unknown[]);
const seen = new Set<string>();
const usable: SubItemSnapshot[] = [];
list.forEach((item) => {
const child = readSubItem(item);
if (!child) {
logMenuDefect(action, "items() returned an unusable child", item);
return;
}
if (seen.has(child.id)) {
logMenuDefect(action, "items() repeated a child id", child.id);
return;
}
seen.add(child.id);
usable.push(child);
});
return usable.length > 0 ? usable : null;
} catch (error: unknown) {
logMenuDefect(action, "items() could not be read", error);
return null;
}
}
/**
* `Promise.resolve().then(() => run(context))` — not
* `Promise.resolve(run(context))` — so a *synchronous* throw inside run()
* also lands in the `.catch()` below. Calling run directly as the
* Promise.resolve() argument still throws before that expression finishes
* evaluating, escaping past .catch() entirely and straight out of this
* onSelect handler. `subject` names the failing part of a submenu action.
*/
function runPluginCallback(
action: PluginTaskMenuActionRegistration,
run: (context: PluginTaskMenuContext) => void | Promise<void>,
context: PluginTaskMenuContext,
subject = "",
): void {
Promise.resolve()
.then(() => run(context))
.catch((error: unknown) => {
console.error(
`[plugins] task menu action "${action.pluginId}:${action.id}"${subject} failed`,
error,
);
});
}
/**
* Builds the menu entry for one plugin task menu action: a submenu when the
* action declares a non-empty `items(context)`, otherwise the flat item it
* has always been (and still is on a host that predates submenus, which
* ignores `items` and reads only `label`/`icon`/`run`).
*/
export function pluginMenuEntry(
action: PluginTaskMenuActionRegistration,
context: PluginTaskMenuContext,
disabled?: boolean,
keyPrefix = "plugin-edit",
): KanbanCardMenuEntry {
): KanbanCardMenuEntry | null {
const label = readActionLabel(action);
if (label === null) {
logMenuDefect(action, "registration has no usable label");
return null;
}
const actionId = readActionId(action);
if (actionId === null) {
logMenuDefect(action, "registration has no usable id");
return null;
}
const icon = readActionIcon(action);
// Every plugin-controlled part is percent-encoded, so a dash-join cannot spell
// another action's key (`p` + `q-x` vs `p-q` + `x`) and no part can contain the
// separators: the palette flattens every plugin entry into one list where the
// key is both a React key and cmdk's value.
const key = `${keyPrefix}-${encodeKeyPart(action.pluginId)}:${encodeKeyPart(actionId)}`;
const items = pluginSubItems(action, context);
if (items) {
return {
kind: "submenu",
key,
icon,
label,
disabled,
children: items.map((item) => ({
kind: "item",
// The id is plugin-controlled and the action key is itself a dash-join
// of plugin-controlled ids, so `-${id}` can spell another action's child
// key exactly; the palette flattens every plugin entry into one list and
// uses this key as both its React key and cmdk's value. The separator
// stays unambiguous because an id's own `#` is percent-encoded.
key: `${key}#${encodeKeyPart(item.id)}`,
icon: pluginMenuIcon(item.icon),
label: item.label,
disabled: disabled || item.disabled,
onSelect: () => runPluginCallback(action, item.run, context, ` item "${item.id}"`),
})),
};
}
if (typeof action.run !== "function") {
logMenuDefect(action, "registration has no run");
return null;
}
return {
kind: "item",
key: `${keyPrefix}-${action.pluginId}-${action.id}`,
icon: action.icon
? createElement(resolvePluginIcon(action.icon), { className: "mr-2 h-4 w-4" })
: undefined,
label: action.label,
key,
icon,
label,
disabled,
onSelect: () => {
// Promise.resolve().then(() => action.run(context)) — not
// Promise.resolve(action.run(context)) — so a *synchronous* throw
// inside run() also lands in the .catch() below. Calling action.run
// directly as the Promise.resolve() argument still throws before that
// expression finishes evaluating, escaping past .catch() entirely and
// straight out of this onSelect handler.
Promise.resolve()
.then(() => action.run(context))
.catch((error: unknown) => {
console.error(
`[plugins] task menu action "${action.pluginId}:${action.id}" failed`,
error,
);
});
},
onSelect: () => runPluginCallback(action, action.run, context),
};
}
@@ -71,7 +362,8 @@ export function runnablePluginMenuEntry(
* Builds flat, top-level menu entries for group "primary" task menu
* actions, in registration order. Unlike group "edit" (nested in the Edit
* submenu), these render directly in the card menu — after the movement
* group and before the Archive/Delete removal group.
* group and before the Archive/Delete removal group. An action that declares
* `items` is the one exception: it renders as its own submenu.
*/
export function buildPrimaryPluginEntries({
disabled,
@@ -80,7 +372,7 @@ export function buildPrimaryPluginEntries({
disabled?: boolean;
context: PluginTaskMenuContext;
}): KanbanCardMenuEntry[] {
return visiblePluginMenuActions("primary", context).map((action) =>
runnablePluginMenuEntry(action, context, disabled, "plugin-primary"),
);
return visiblePluginMenuActions("primary", context)
.map((action) => pluginMenuEntry(action, context, disabled, "plugin-primary"))
.filter((entry): entry is KanbanCardMenuEntry => entry !== null);
}
@@ -0,0 +1,165 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { pluginRegistry } from "@/lib/plugins/registry";
import type { PluginTaskMenuContext } from "@/lib/plugins/types";
import { buildPrimaryPluginEntries } from "./task-menu-actions";
// Key identity. A plugin entry key is a palette row's React key and cmdk value,
// so it has to be unique for any strings a bundle can author -- including the
// shapes that make a naive escape and a bare separator collide.
const PLUGIN_ID = "kandev-plugin-keys";
const CONTEXT: PluginTaskMenuContext = {
workspaceId: "ws-1",
taskId: "task-1",
taskTitle: "Fix the bug",
workflowStepId: "step-1",
presentation: "desktop",
};
afterEach(() => {
pluginRegistry.unregisterPlugin(PLUGIN_ID);
pluginRegistry.unregisterPlugin("p");
pluginRegistry.unregisterPlugin("p-a");
pluginRegistry.unregisterPlugin("p-q");
pluginRegistry.unregisterPlugin("p-pick");
pluginRegistry.unregisterPlugin("p\uABCD");
});
describe("plugin entry keys — escapes and separators", () => {
it("keeps keys distinct when an id ends where another's escape token begins", () => {
// The separator cannot be read as the start of an escape: with a `%XX`-style
// token and a bare `%` separator, plugin `p\uABCD` with action `abcd` and
// plugin `p` with action `abcd\uABCD` both produced `plugin-primary-p%abcd%abcd`.
pluginRegistry.forPlugin("p\uABCD").registerTaskMenuAction({
id: "abcd",
label: "Shifted",
group: "primary",
run: vi.fn(),
});
pluginRegistry.forPlugin("p").registerTaskMenuAction({
id: "abcd\uABCD",
label: "Trailing",
group: "primary",
run: vi.fn(),
});
pluginRegistry.forPlugin("p").registerTaskMenuAction({
id: "abcd",
label: "Parent",
group: "primary",
items: () => [
{ id: "\uABCD", label: "Child unit", run: vi.fn() },
{ id: "%3A", label: "Looks like an escape", run: vi.fn() },
],
run: vi.fn(),
});
const entries = buildPrimaryPluginEntries({ context: CONTEXT });
const keys = entries.flatMap((entry) =>
entry
? [entry.key, ...(entry.kind === "submenu" ? entry.children.map((c) => c.key) : [])]
: [],
);
expect(keys).toHaveLength(5);
expect(new Set(keys).size).toBe(5);
for (const pluginId of ["p\uABCD", "p"]) pluginRegistry.unregisterPlugin(pluginId);
});
it("keeps keys distinct when an id carries the child delimiter or a dash", () => {
// A `#` inside an action id must not forge a sibling child's key, and a `-`
// must not let one (pluginId, actionId) pair spell another's.
pluginRegistry.forPlugin("p").registerTaskMenuAction({
id: "quick",
label: "Quick",
group: "primary",
items: () => [{ id: "x", label: "Child", run: vi.fn() }],
run: vi.fn(),
});
pluginRegistry.forPlugin("p").registerTaskMenuAction({
id: "quick#x",
label: "Hashed",
group: "primary",
run: vi.fn(),
});
pluginRegistry.forPlugin("p").registerTaskMenuAction({
id: "a-b",
label: "Dashed",
group: "primary",
run: vi.fn(),
});
pluginRegistry.forPlugin("p-a").registerTaskMenuAction({
id: "b",
label: "Shifted",
group: "primary",
run: vi.fn(),
});
const entries = buildPrimaryPluginEntries({ context: CONTEXT });
const keys = entries.flatMap((entry) =>
entry
? [entry.key, ...(entry.kind === "submenu" ? entry.children.map((c) => c.key) : [])]
: [],
);
expect(keys).toHaveLength(5);
expect(new Set(keys).size).toBe(5);
// A flat key never contains the raw child delimiter, so it cannot equal a child key.
expect(keys.filter((key) => key.includes("#"))).toHaveLength(1);
for (const pluginId of ["p", "p-a"]) pluginRegistry.unregisterPlugin(pluginId);
});
});
describe("plugin entry keys — dash joins", () => {
it("keeps child keys distinct when two actions' ids dash-join identically", () => {
// Every key here is a dash-join (before this fix) of plugin-controlled ids,
// and the palette flattens all plugin entries into one list where a key is
// both a React key and cmdk's value. Both pairs below collided under a
// dash-join: a child against a flat action's key, and two actions whose id
// joins agree (`p` + `q-x` versus `p-q` + `x`).
pluginRegistry.forPlugin("p").registerTaskMenuAction({
id: "pick",
label: "Pick",
group: "primary",
items: () => [{ id: "x", label: "A", run: vi.fn() }],
run: vi.fn(),
});
pluginRegistry.forPlugin("p-pick").registerTaskMenuAction({
id: "pick",
label: "Pick x",
group: "primary",
items: () => [{ id: "pick-x", label: "B", run: vi.fn() }],
run: vi.fn(),
});
pluginRegistry.forPlugin("p").registerTaskMenuAction({
id: "q-x",
label: "Q x",
group: "primary",
items: () => [{ id: "c", label: "C", run: vi.fn() }],
run: vi.fn(),
});
pluginRegistry.forPlugin("p-q").registerTaskMenuAction({
id: "x",
label: "X",
group: "primary",
items: () => [{ id: "c", label: "D", run: vi.fn() }],
run: vi.fn(),
});
const entries = buildPrimaryPluginEntries({ context: CONTEXT });
const keys = entries.map((entry) => entry.key);
const childKeys = entries.flatMap((entry) =>
entry && entry.kind === "submenu" ? entry.children.map((child) => child.key) : [],
);
expect(keys).toHaveLength(4);
expect(new Set(keys).size).toBe(4);
expect(childKeys).toHaveLength(4);
expect(new Set(childKeys).size).toBe(4);
// A child key can never equal a flat action key either.
expect(childKeys.filter((key) => keys.includes(key))).toEqual([]);
for (const pluginId of ["p", "p-pick", "p-q"]) pluginRegistry.unregisterPlugin(pluginId);
});
});
@@ -0,0 +1,66 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { pluginRegistry } from "@/lib/plugins/registry";
import type { PluginIcon, PluginTaskMenuContext } from "@/lib/plugins/types";
import { buildPrimaryPluginEntries } from "./task-menu-actions";
// The registration boundary: what the menu builder does with a registration whose
// own `id` is missing or has no usable string form. The registry guards its own
// read of these objects (see registry.test.ts); these cases cover the builder's
// half, where a value of the wrong shape must drop the entry rather than key it.
const PLUGIN_ID = "kandev-plugin-boundary";
const CONTEXT: PluginTaskMenuContext = {
workspaceId: "ws-1",
taskId: "task-1",
taskTitle: "Fix the bug",
workflowStepId: "step-1",
presentation: "desktop",
};
function registerAction(
overrides: {
id?: unknown;
label?: string;
icon?: PluginIcon;
items?: unknown;
} = {},
) {
pluginRegistry.forPlugin(PLUGIN_ID).registerTaskMenuAction({
id: overrides.id ?? "action",
label: overrides.label ?? "Action",
group: "primary",
run: vi.fn(),
...(overrides.icon ? { icon: overrides.icon } : {}),
...(overrides.items ? { items: overrides.items } : {}),
} as never);
}
afterEach(() => pluginRegistry.unregisterPlugin(PLUGIN_ID));
describe("task menu registration boundary", () => {
it("omits an action whose id is not a usable string", () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
registerAction({ id: {} as never, items: () => [{ id: "ok", label: "Ok", run: vi.fn() }] });
expect(buildPrimaryPluginEntries({ context: CONTEXT })).toEqual([]);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining("no usable id"),
undefined,
);
consoleErrorSpy.mockRestore();
});
it("omits a registration whose id has no string form, without throwing", () => {
// A Symbol id or a null-prototype object has no `ToString`: the log line that
// reports the omission must not be able to throw, or the report itself takes
// the render down while dropping the registration.
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
registerAction({ id: Symbol("add-tag") as never, label: "Symbol id" });
registerAction({ id: Object.create(null) as never, label: "Null-prototype id" });
expect(() => buildPrimaryPluginEntries({ context: CONTEXT })).not.toThrow();
expect(buildPrimaryPluginEntries({ context: CONTEXT })).toEqual([]);
expect(consoleErrorSpy).toHaveBeenCalled();
consoleErrorSpy.mockRestore();
});
});
@@ -1,6 +1,6 @@
import { cleanup, renderHook } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { useTaskMoveChoices } from "./task-command-choices";
import { afterEach, describe, expect, it, vi } from "vitest";
import { pluginCommandChoices, useTaskMoveChoices } from "./task-command-choices";
afterEach(cleanup);
@@ -29,3 +29,79 @@ it("omits workflows without destinations while preserving both destination actio
expect(openMoveOptions).toHaveBeenCalledWith({ ...target, workflow_id: "target" });
expect(moveImmediately).toHaveBeenCalledWith({ ...target, workflow_id: "target" });
});
/**
* A plugin submenu carries no action of its own (its label is a trigger), so
* the command palette has to reach its children -- dropping the entry would
* silently remove a plugin's actions from the palette and the sidebar's task
* commands (see task-commands.test.tsx's plugin parity assertions).
*/
describe("pluginCommandChoices", () => {
const item = (label: string, key = label) => ({
kind: "item" as const,
key,
label,
onSelect: vi.fn(),
});
it("turns a flat plugin entry into one command", () => {
const entry = { ...item("Add tag"), disabled: true, icon: "icon" };
expect(pluginCommandChoices(entry, "Tasks")).toEqual([
{
id: "Add tag",
label: "Add tag",
group: "Tasks",
action: entry.onSelect,
disabled: true,
icon: "icon",
},
]);
});
it("flattens a plugin submenu into its item children, keeping their disabled flag", () => {
const first = item("Blocked", "plugin-primary-tags-add-tag-more");
const second = { ...item("Urgent"), disabled: true };
const entry = {
kind: "submenu" as const,
key: "plugin-primary-tags-add-tag",
label: "Add tag...",
children: [first, second, { kind: "separator" as const, key: "sep" }],
};
expect(pluginCommandChoices(entry, "Tasks").map((command) => command.id)).toEqual([
"plugin-primary-tags-add-tag-more",
"Urgent",
]);
const [more, urgent] = pluginCommandChoices(entry, "Tasks");
more.action?.();
expect(first.onSelect).toHaveBeenCalledTimes(1);
expect(urgent.disabled).toBe(true);
expect(more.context).toBe("Add tag...");
});
it("reuses the entry key as the command id, which the builder keeps unique", () => {
// Uniqueness is the builder's job: a child's key encodes its id, so the
// palette can use it directly as both the React key and cmdk's value.
const child = item("Pick", "plugin-primary-p-quick#pick-x");
const submenu = {
kind: "submenu" as const,
key: "plugin-primary-p-quick",
label: "Quick",
children: [child],
};
const flat = item("Quick pick", "plugin-primary-p-quick-pick");
const ids = [
...pluginCommandChoices(submenu, "Tasks"),
...pluginCommandChoices(flat, "Tasks"),
].map((command) => command.id);
expect(ids).toEqual(["plugin-primary-p-quick#pick-x", "plugin-primary-p-quick-pick"]);
expect(new Set(ids).size).toBe(ids.length);
});
it("ignores a label the palette cannot render", () => {
const entry = { ...item("x"), label: {} as never };
expect(pluginCommandChoices(entry, "Tasks")).toEqual([]);
});
});
+39 -11
View File
@@ -16,6 +16,7 @@ import {
} from "./task/task-move-context-menu";
import { useTaskPluginLinkActions } from "./task/task-session-sidebar-link-actions";
import { useTaskPluginPrimaryMenuEntries } from "./task/task-switcher-plugin-menu-items";
import type { KanbanCardMenuEntry } from "./kanban-card-menu-items";
import {
selectTaskLinkActions,
taskLinkMenuOptions,
@@ -34,6 +35,43 @@ type ChoiceOptions = {
moveImmediately: (step: TaskMoveStep) => void;
};
/**
* One command per *selectable* plugin menu entry. A plugin submenu has no
* activation of its own (its `label` is only a trigger and it carries no
* `onSelect`), so its item children become the commands -- flattening them
* keeps a plugin's actions reachable from the command palette instead of
* dropping the whole contribution, and mirrors what the search surface shows
* for every other card action.
*/
export function pluginCommandChoices(
entry: KanbanCardMenuEntry,
group: string,
parentLabel?: string,
): CommandItem[] {
if (entry.kind === "submenu") {
const label = typeof entry.label === "string" ? entry.label : parentLabel;
return entry.children.flatMap((child) => pluginCommandChoices(child, group, label));
}
if (entry.kind !== "item" || typeof entry.label !== "string") return [];
return [
{
// The entry key is unique across every plugin menu entry, including
// flattened children whose key encodes the child id, and it doubles as
// this row's React key and cmdk value.
id: entry.key,
label: entry.label,
group,
// A child's own label ("Blocked") is meaningless on its own in a palette
// listing every action, so the trigger it came from is the entry's
// context -- the same field the sidebar task commands use for the title.
context: parentLabel,
action: entry.onSelect,
disabled: entry.disabled,
icon: entry.icon,
},
];
}
export function useTaskCommandChoices(options: ChoiceOptions) {
const { task, linkHandlers } = options;
const { t } = useTranslation();
@@ -87,17 +125,7 @@ export function useTaskCommandChoices(options: ChoiceOptions) {
icon: createElement(resolvePluginIcon(link.icon), { className: "size-3.5" }),
})),
);
const plugins = pluginEntries.flatMap((entry): CommandItem[] =>
entry.kind === "item" && typeof entry.label === "string"
? [
{
...item(entry.key, entry.label, entry.onSelect),
disabled: entry.disabled,
icon: entry.icon,
},
]
: [],
);
const plugins = pluginEntries.flatMap((entry) => pluginCommandChoices(entry, group));
return {
colors,
priorities,
+4 -1
View File
@@ -88,6 +88,9 @@ export function buildSidebarTaskCommands(ctx: TaskCommandContext): CommandItem[]
...item,
priority: 0,
disabled: item.disabled || ctx.disabled,
context: task.title,
// An item that names its own context keeps it (a plugin submenu child
// carries the trigger it came from); the task title is only the default
// second line for everything else.
context: item.context ?? task.title,
}));
}
@@ -74,6 +74,21 @@ describe("sidebar task command parity", () => {
expect(ctx.onPin).toHaveBeenCalledTimes(1);
expect(ctx.onDelete).toHaveBeenCalledTimes(1);
});
// Regression: this map used to stamp every command with the task title, which
// silently overwrote the trigger label a plugin submenu child carries, so the
// value pluginCommandChoices computed never reached the palette row.
it("keeps a plugin command's own context and defaults the rest to the task title", () => {
const ctx = context({
plugins: [
{ ...child, id: PLUGIN_ACTION, context: "Add tag..." },
{ ...child, id: "plugin-flat" },
],
});
const commands = buildSidebarTaskCommands(ctx);
expect(commands.find((c) => c.id === PLUGIN_ACTION)?.context).toBe("Add tag...");
expect(commands.find((c) => c.id === "plugin-flat")?.context).toBe("Task A");
});
it("removes vanished plugin actions and unavailable choices on rebuild", () => {
const ctx = context({ plugins: [], links: [], steps: [], workflows: [] });
expect(buildSidebarTaskCommands(ctx).map((c) => c.id)).not.toContain(PLUGIN_ACTION);
@@ -1018,6 +1018,35 @@
);
},
});
registry.registerTaskMenuAction({
id: "task-shortcuts",
label: "Task shortcuts",
group: "primary",
items: function () {
return [
{
id: "record-presentation",
label: "Record menu presentation",
run: function (childContext) {
return host.storage.set(
"task",
childContext.taskId,
"primary-submenu-presentation",
childContext.presentation,
);
},
},
];
},
run: function (context) {
return host.storage.set(
"task",
context.taskId,
"primary-submenu-presentation",
context.presentation,
);
},
});
registry.registerKeybinding("open-demo", function () {
function DemoModalContent() {
@@ -150,4 +150,46 @@ test.describe("Mobile plugin task panel", () => {
)
.toBe("mobile");
});
test("opens and selects a primary plugin submenu by touch", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(90_000);
await installFixturePlugin(testPage);
const task = await apiClient.createTask(seedData.workspaceId, "Mobile plugin submenu task", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
const mobile = new MobileKanbanPage(testPage);
await mobile.goto();
await mobile.taskCard(task.id).getByRole("button", { name: "More options" }).tap();
const menu = testPage.locator('[data-slot="dropdown-menu-content"]:visible');
const submenu = menu.getByRole("menuitem", { name: "Task shortcuts", exact: true });
await expect(submenu).toBeVisible();
await submenu.tap();
const child = testPage.getByRole("menuitem", { name: "Record menu presentation", exact: true });
await expect(child).toBeVisible();
await child.tap();
await expect
.poll(
async () => {
const res = await apiClient.rawRequest(
"GET",
`/api/plugins/${PLUGIN_ID}/user-state/task/${task.id}/primary-submenu-presentation`,
);
if (res.status !== 200) return null;
const body = (await res.json()) as { value: string };
return body.value;
},
{ timeout: 10_000, intervals: [250, 500, 1000] },
)
.toBe("mobile");
});
});
@@ -1,7 +1,10 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { pluginRegistry } from "@/lib/plugins/registry";
import { buildTaskActionsMenuEntries } from "./task-actions-menu-entries";
import type { KanbanCardMenuEntry } from "@/components/kanban-card-menu-items";
import {
buildCardPluginEntries,
type KanbanCardMenuEntry,
} from "@/components/kanban-card-menu-items";
const PLUGIN_ID = "test-task-actions-menu-plugin";
const REMOVE_SEPARATOR = "remove-separator";
@@ -39,6 +42,38 @@ const baseArgs = {
onLinkPullRequest: vi.fn(),
};
describe("buildTaskActionsMenuEntries — prebuilt plugin entries", () => {
afterEach(() => pluginRegistry.unregisterPlugin(PLUGIN_ID));
// Regression: these tiers force `forceFlatEdit`, so a caller's prebuilt set --
// built for the card, where an edit-group action nests -- would render the very
// Edit submenu the tier exists to suppress.
it("ignores prebuilt entries, keeping the flat Edit item", () => {
pluginRegistry.forPlugin(PLUGIN_ID).registerTaskMenuAction({
id: "nested",
label: "Nested",
group: "edit",
items: () => [{ id: "child", label: "Child", run: vi.fn() }],
run: vi.fn(),
});
const context = {
workspaceId: "ws-1",
taskId: "task-1",
taskTitle: "Task",
workflowStepId: "step-1",
presentation: "desktop" as const,
};
const cardEntries = buildCardPluginEntries({ onEdit: vi.fn(), pluginMenuContext: context });
const entries = buildTaskActionsMenuEntries("normal", {
...baseArgs,
pluginEntries: cardEntries,
});
expect(entries.find((entry) => entry.key === "edit")?.kind).toBe("item");
});
});
describe("buildTaskActionsMenuEntries — normal tier", () => {
afterEach(() => pluginRegistry.unregisterPlugin(PLUGIN_ID));
@@ -131,7 +166,7 @@ describe("buildTaskActionsMenuEntries — archived tier", () => {
const entries = buildTaskActionsMenuEntries("archived", baseArgs);
expect(itemKeys(entries)).toEqual([
`plugin-primary-${PLUGIN_ID}-primary-action`,
`plugin-primary-${PLUGIN_ID}:primary-action`,
REMOVE_SEPARATOR,
"delete",
]);
@@ -164,7 +199,7 @@ describe("buildTaskActionsMenuEntries — unresolved board row tier", () => {
const entries = buildTaskActionsMenuEntries("unresolved-row", baseArgs);
expect(itemKeys(entries)).toEqual([
`plugin-primary-${PLUGIN_ID}-primary-action`,
`plugin-primary-${PLUGIN_ID}:primary-action`,
REMOVE_SEPARATOR,
"archive",
"delete",
@@ -24,7 +24,9 @@ export function buildTaskActionsMenuEntries(
): KanbanCardMenuEntry[] {
if (tier === "archived") return buildArchivedTaskActionsMenuEntries(args);
if (tier === "unresolved-row") return buildUnresolvedRowTaskActionsMenuEntries(args);
return buildKanbanCardMenuEntries({ ...args, forceFlatEdit: true });
// `forceFlatEdit` is forced here, so any entries built for the card must not be reused: their
// `Edit` entry may be the submenu this tier exists to suppress.
return buildKanbanCardMenuEntries({ ...args, forceFlatEdit: true, pluginEntries: undefined });
}
function buildArchivedTaskActionsMenuEntries({
+83 -1
View File
@@ -1,6 +1,7 @@
import { createElement, forwardRef, lazy } from "react";
import { describe, expect, it } from "vitest";
import { IconPuzzle, IconTicket } from "@tabler/icons-react";
import { lookupPluginIcon, resolvePluginIcon } from "./icons";
import { isPluginIconComponent, lookupPluginIcon, resolvePluginIcon } from "./icons";
describe("plugin icons", () => {
it("looks up a known icon name", () => {
@@ -25,6 +26,87 @@ describe("plugin icons", () => {
expect(lookupPluginIcon(undefined)).toBeUndefined();
});
// Regression: a JavaScript bundle can pass any value. Indexing PLUGIN_ICONS
// with a non-string coerced it to a key, so an object whose toString threw
// took the rendering surface down, and any other object silently became the
// puzzle glyph by way of a "[object Object]" lookup miss.
it("passes through an exotic component, which memo/forwardRef produce", () => {
// React's memo and forwardRef return objects that are callable per their
// types but not at run time, and @tabler/icons-react -- the set
// PLUGIN_ICONS maps names onto -- builds every icon with forwardRef.
// memo() returns a non-callable object; a function carrying $$typeof would
// not reproduce the shape that `typeof icon === "function"` accepts.
const ExoticIcon = { $$typeof: Symbol.for("react.memo"), type: () => null };
expect(lookupPluginIcon(ExoticIcon as never)).toBe(ExoticIcon);
expect(resolvePluginIcon(ExoticIcon as never)).toBe(ExoticIcon);
});
it("does not treat an element, a lazy component or a bare tag as a component", () => {
// An element is a rendered node, so handing it to createElement on the twelve
// surfaces that do `createElement(resolvePluginIcon(icon))` would throw; a
// lazy component cannot resolve synchronously in a menu; and an object that
// merely carries `$$typeof` is not a component at all. All three fall back.
const element = createElement("svg", { viewBox: "0 0 24 24" });
const lazyIcon = lazy(() => Promise.resolve({ default: () => null }));
const bogus = { $$typeof: 1 };
for (const icon of [element, lazyIcon, bogus]) {
expect(isPluginIconComponent(icon)).toBe(false);
expect(lookupPluginIcon(icon as never)).toBeUndefined();
expect(resolvePluginIcon(icon as never)).toBe(IconPuzzle);
}
});
it("passes through a forwardRef component, the shape an icon set builds", () => {
const Forwarded = forwardRef<SVGSVGElement, { className?: string }>(() => null);
expect(isPluginIconComponent(Forwarded)).toBe(true);
expect(lookupPluginIcon(Forwarded as never)).toBe(Forwarded);
});
it("treats an icon whose tag read throws as absent, not as a component", () => {
// Regression: the tag read (and the `in` check before it) runs a
// plugin-supplied trap, and this function backs every icon surface.
const hostile = new Proxy(
{},
{
get() {
throw new Error("hostile $$typeof getter");
},
has() {
throw new Error("hostile in trap");
},
},
);
expect(() => isPluginIconComponent(hostile)).not.toThrow();
expect(isPluginIconComponent(hostile)).toBe(false);
expect(lookupPluginIcon(hostile as never)).toBeUndefined();
expect(resolvePluginIcon(hostile as never)).toBe(IconPuzzle);
});
it("resolves only own keys, so a prototype member is not an icon", () => {
for (const name of ["__proto__", "constructor", "toString", "hasOwnProperty"]) {
expect(lookupPluginIcon(name)).toBeUndefined();
expect(resolvePluginIcon(name)).toBe(IconPuzzle);
}
expect(lookupPluginIcon("ticket")).toBe(IconTicket);
});
it("treats a non-string, non-function icon as absent instead of coercing it to a name", () => {
const hostile = {
toString() {
throw new Error("key coercion");
},
};
expect(() => lookupPluginIcon(hostile as unknown as string)).not.toThrow();
expect(lookupPluginIcon(hostile as unknown as string)).toBeUndefined();
expect(resolvePluginIcon(hostile as unknown as string)).toBe(IconPuzzle);
expect(lookupPluginIcon({} as unknown as string)).toBeUndefined();
});
it("falls back to the puzzle glyph from resolvePluginIcon", () => {
expect(resolvePluginIcon("not-an-icon")).toBe(IconPuzzle);
expect(resolvePluginIcon(undefined)).toBe(IconPuzzle);
+47 -3
View File
@@ -54,10 +54,54 @@ export const PLUGIN_ICONS: Record<string, TablerIcon> = {
users: IconUsers,
};
/** Strict lookup: the named icon, or undefined when the name is unknown/missing. */
/**
* The React tags a *component* carries at run time. `memo` and `forwardRef`
* return objects (callable per their types, not at run time), and an icon set --
* which `host-api` explicitly tells plugins to bundle -- is built from
* `forwardRef`, so a plain `typeof` check would reject the host's own icon
* shapes.
*
* Only those two count. A lazy component cannot resolve synchronously in a menu,
* and a *rendered* element is not a component at all: the menu passes an element
* through itself while every other surface maps it to the fallback glyph, so
* admitting it here would hand `createElement` something it refuses and take the
* render down.
*/
const PLUGIN_ICON_COMPONENT_TAGS = new Set<symbol>([
Symbol.for("react.memo"),
Symbol.for("react.forward_ref"),
]);
export function isPluginIconComponent(icon: unknown): icon is ResolvedPluginIcon {
if (typeof icon === "function") return true;
if (typeof icon !== "object" || icon === null) return false;
// The `in` check and the read both run a plugin-supplied trap -- a Proxy `has`,
// or an accessor whose getter throws -- and this function runs on every icon
// surface, so an unreadable tag means "not a component" rather than an escape.
let tag: unknown;
try {
if (!("$$typeof" in icon)) return false;
tag = icon.$$typeof;
} catch {
return false;
}
return typeof tag === "symbol" && PLUGIN_ICON_COMPONENT_TAGS.has(tag);
}
/**
* Strict lookup: the named icon, or undefined when the name is unknown/missing.
* Only a string names a curated icon: any other value (a stray object or a
* ready-made element from a JavaScript bundle) has no name to look up, and
* indexing the map with it would coerce it to a key -- which a hostile
* `toString` turns into a throw out of whatever is rendering. A component,
* exotic or not, needs no lookup at all.
*/
export function lookupPluginIcon(icon?: PluginIcon): ResolvedPluginIcon | undefined {
if (typeof icon === "function") return icon;
return icon ? PLUGIN_ICONS[icon] : undefined;
if (isPluginIconComponent(icon)) return icon;
if (typeof icon !== "string") return undefined;
// Own keys only: `PLUGIN_ICONS["__proto__"]` or `["constructor"]` would
// otherwise resolve through the prototype chain to a value that is not an icon.
return Object.hasOwn(PLUGIN_ICONS, icon) ? PLUGIN_ICONS[icon] : undefined;
}
/** Sidebar lookup: always renders something — unknown/missing names get the puzzle glyph. */
+33
View File
@@ -359,6 +359,39 @@ describe("pluginRegistry — task panels and task menu actions", () => {
expect(pluginRegistry.getTaskMenuActions()).toHaveLength(2);
});
it("skips unreadable task menu registrations without hiding healthy actions", () => {
const scoped = pluginRegistry.forPlugin("plugin-a");
const error = vi.spyOn(console, "error").mockImplementation(() => {});
const run = vi.fn();
scoped.registerTaskMenuAction({
id: "broken-group",
label: "Broken group",
get group(): never {
throw new Error("group failed");
},
run,
});
scoped.registerTaskMenuAction({
id: "broken-label",
get label(): never {
throw new Error("label failed");
},
group: "primary",
run,
});
scoped.registerTaskMenuAction({ id: "healthy", label: "Healthy", group: "primary", run });
try {
expect(pluginRegistry.getTaskMenuActions("primary")).toEqual([
{ pluginId: "plugin-a", id: "healthy", label: "Healthy", group: "primary", run },
]);
expect(pluginRegistry.getTaskMenuActions("primary")).toHaveLength(1);
expect(error).toHaveBeenCalledTimes(2);
} finally {
error.mockRestore();
}
});
it("bulk-revokes task panels and task menu actions on unregisterPlugin", () => {
const scopedA = pluginRegistry.forPlugin("plugin-a");
const scopedB = pluginRegistry.forPlugin("plugin-b");
+18 -3
View File
@@ -146,6 +146,7 @@ class PluginRegistryStore {
private workLifecycle = new PluginWorkLifecycle();
private taskPanels: Owned<TaskPanelRegistration>[] = [];
private taskMenuActions: Owned<TaskMenuActionRegistration>[] = [];
private reportedUnreadableTaskMenuActions = new WeakSet<Owned<TaskMenuActionRegistration>>();
private taskFilters: Owned<TaskFilterRegistration>[] = [];
private taskListFacets: Owned<TaskListFacetRegistration>[] = [];
private nextSlotRegistrationId = 0;
@@ -699,9 +700,23 @@ class PluginRegistryStore {
getTaskMenuActions(
group?: TaskMenuActionRegistration["group"],
): PluginTaskMenuActionRegistration[] {
return this.taskMenuActions
.filter((entry) => !group || entry.value.group === group)
.map((entry) => ({ ...entry.value, pluginId: entry.pluginId }));
const actions: PluginTaskMenuActionRegistration[] = [];
for (const entry of this.taskMenuActions) {
try {
if (!group || entry.value.group === group) {
actions.push({ ...entry.value, pluginId: entry.pluginId });
}
} catch (error) {
if (!this.reportedUnreadableTaskMenuActions.has(entry)) {
this.reportedUnreadableTaskMenuActions.add(entry);
console.error(
`[plugins] task menu action from "${entry.pluginId}" could not be read`,
error,
);
}
}
}
return actions;
}
/** Every registered task filter, in registration order. */
+47
View File
@@ -20,6 +20,8 @@ import type {
RepositoryProviderRegistration as PublicRepositoryProviderRegistration,
ReviewSummary as PublicReviewSummary,
ReviewTaskAssociation as PublicReviewTaskAssociation,
TaskMenuActionRegistration as PublicTaskMenuActionRegistration,
TaskMenuSubItemRegistration as PublicTaskMenuSubItemRegistration,
TaskPanelRegistration as PublicTaskPanelRegistration,
} from "@kandev/plugin-sdk";
import type {
@@ -41,6 +43,8 @@ import type {
RepositoryProviderRegistration,
ReviewItemSummary,
ReviewTaskAssociation,
TaskMenuActionRegistration,
TaskMenuSubItemRegistration,
TaskPanelRegistration,
} from "./types";
@@ -53,6 +57,24 @@ type SameType<Left, Right> = Left extends Right ? (Right extends Left ? true : f
type HasUseLayoutEffect = "useLayoutEffect" extends keyof PublicHostReact ? true : false;
type HasPromptMentionText = "PromptMentionText" extends keyof PublicPluginUIApi ? true : false;
type HasConversationApi = "conversation" extends keyof PublicPluginHostApi ? true : false;
type HasTaskMenuItems = "items" extends keyof PublicTaskMenuActionRegistration ? true : false;
// A registration that predates submenus must keep compiling unchanged, and the
// submenu shape must be expressible: that pair is the whole compatibility story
// of the optional `items` field.
const flatOnlyTaskMenuAction: PublicTaskMenuActionRegistration = {
id: "legacy",
label: "Legacy",
group: "primary",
run: () => {},
};
const submenuTaskMenuAction: PublicTaskMenuActionRegistration = {
id: "submenu",
label: "Submenu",
group: "primary",
items: () => [{ id: "child", label: "Child", run: () => {} }],
run: () => {},
};
const legacyTaskPanelRegistration: PublicTaskPanelRegistration = {
id: "legacy",
@@ -164,3 +186,28 @@ describe("public plugin SDK", () => {
expect(publicRegistryContract).toBeTypeOf("function");
});
});
// The registrations this feature adds to the public SDK. A plugin author
// imports these names verbatim, and the host's runtime-facing types must stay
// identical to them, including the optional `items` field.
describe("task menu submenu SDK contract", () => {
it("is canonical in both directions", () => {
const taskMenuActionRegistrationIsCanonical: SameType<
TaskMenuActionRegistration,
PublicTaskMenuActionRegistration
> = true;
const taskMenuSubItemRegistrationIsCanonical: SameType<
TaskMenuSubItemRegistration,
PublicTaskMenuSubItemRegistration
> = true;
const publicTaskMenuActionHasItems: HasTaskMenuItems = true;
expect(taskMenuActionRegistrationIsCanonical).toBe(true);
expect(taskMenuSubItemRegistrationIsCanonical).toBe(true);
expect(publicTaskMenuActionHasItems).toBe(true);
// A registration that predates submenus still compiles unchanged, and the
// submenu shape is expressible: that pair is the compatibility story of the
// optional `items` field.
expect(flatOnlyTaskMenuAction.run).toBeTypeOf("function");
expect(submenuTaskMenuAction.items).toBeTypeOf("function");
});
});
+8
View File
@@ -360,9 +360,17 @@ export type PluginTaskMenuContext = PluginSDK.PluginTaskMenuContext;
* "edit" nests the item inside the card's `Edit` submenu; group "primary"
* renders it as a flat, top-level menu item after the movement group and
* before the `Archive`/`Delete` removal group.
*
* An action that declares `items` becomes a submenu: `label` is its
* (unselectable) trigger, the returned children are its entries, and `run`
* remains the fallback for a host that predates the field and for a build
* whose `items` yields nothing usable.
*/
export type TaskMenuActionRegistration = PluginSDK.TaskMenuActionRegistration;
/** One child of a `TaskMenuActionRegistration` that declares `items`. */
export type TaskMenuSubItemRegistration = PluginSDK.TaskMenuSubItemRegistration;
/** Read-only context passed to `TaskFilterRegistration.matches`. */
export type PluginTaskFilterContext = Parameters<PluginSDK.TaskFilterRegistration["matches"]>[0];