feat(plugins): render a task menu action as a submenu (#3874)
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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", () => {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user