feat(ui): group task and card menu actions (#3599)
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildKanbanCardMenuEntries, type KanbanCardMenuEntry } from "./kanban-card-menu-items";
|
||||
import { pluginRegistry } from "@/lib/plugins/registry";
|
||||
|
||||
const PLUGIN_ID = "kandev-plugin-tags";
|
||||
const ACTION_ID = "quick-tag";
|
||||
const ACTION_LABEL = "Quick tag";
|
||||
const WORKFLOW_ONE = "wf-1";
|
||||
const WORKFLOW_TWO = "wf-2";
|
||||
|
||||
const PluginBitbucketIcon = () => null;
|
||||
|
||||
afterEach(() => {
|
||||
pluginRegistry.unregisterPlugin(PLUGIN_ID);
|
||||
});
|
||||
|
||||
function entryKeys(entries: KanbanCardMenuEntry[]) {
|
||||
return entries.map((entry) => entry.key);
|
||||
}
|
||||
|
||||
function registerPrimaryAction(visible?: () => boolean) {
|
||||
pluginRegistry.forPlugin(PLUGIN_ID).registerTaskMenuAction({
|
||||
id: ACTION_ID,
|
||||
label: ACTION_LABEL,
|
||||
icon: PluginBitbucketIcon,
|
||||
group: "primary",
|
||||
visible,
|
||||
run: vi.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
function movementArgs() {
|
||||
return {
|
||||
currentWorkflowId: WORKFLOW_ONE,
|
||||
workflows: [
|
||||
{ id: WORKFLOW_ONE, name: "Workflow 1" },
|
||||
{ id: WORKFLOW_TWO, name: "Workflow 2" },
|
||||
],
|
||||
stepsByWorkflowId: {
|
||||
[WORKFLOW_ONE]: [
|
||||
{ id: "s1", title: "Step 1" },
|
||||
{ id: "s2", title: "Step 2" },
|
||||
],
|
||||
[WORKFLOW_TWO]: [{ id: "s3", title: "Step 3" }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildKanbanCardMenuEntries — 'primary' group plugin actions", () => {
|
||||
it("renders a 'primary' group action after movement and before removal", () => {
|
||||
registerPrimaryAction();
|
||||
|
||||
const entries = buildKanbanCardMenuEntries({
|
||||
...movementArgs(),
|
||||
onSendToWorkflow: vi.fn(),
|
||||
onLinkPullRequest: vi.fn(),
|
||||
});
|
||||
|
||||
const keys = entryKeys(entries);
|
||||
const sendToIndex = keys.indexOf("send-to-workflow");
|
||||
const primaryIndex = keys.indexOf(`plugin-primary-${PLUGIN_ID}-${ACTION_ID}`);
|
||||
const linkIndex = keys.indexOf("link");
|
||||
|
||||
expect(sendToIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(primaryIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(linkIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(linkIndex).toBeLessThan(sendToIndex);
|
||||
expect(sendToIndex).toBeLessThan(primaryIndex);
|
||||
|
||||
const archiveIndex = keys.indexOf("archive");
|
||||
expect(primaryIndex).toBeLessThan(archiveIndex);
|
||||
|
||||
const primaryEntry = entries[primaryIndex];
|
||||
expect(primaryEntry.kind).toBe("item");
|
||||
if (primaryEntry.kind === "item") {
|
||||
expect(primaryEntry.label).toBe(ACTION_LABEL);
|
||||
expect((primaryEntry.icon as { type?: unknown })?.type).toBe(PluginBitbucketIcon);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps exactly one separator between each nonempty card group", () => {
|
||||
registerPrimaryAction();
|
||||
|
||||
const entries = buildKanbanCardMenuEntries({
|
||||
...movementArgs(),
|
||||
currentStepId: "s1",
|
||||
currentPriority: "high",
|
||||
onSelectPriority: vi.fn(),
|
||||
onEdit: vi.fn(),
|
||||
onLinkPullRequest: vi.fn(),
|
||||
parentTaskId: "parent-1",
|
||||
onDetach: vi.fn(),
|
||||
onMoveToStep: vi.fn(),
|
||||
onSendToWorkflow: vi.fn(),
|
||||
onArchive: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
});
|
||||
|
||||
expect(entryKeys(entries)).toEqual([
|
||||
"priority",
|
||||
"edit-separator",
|
||||
"edit",
|
||||
"relationships-separator",
|
||||
"link",
|
||||
"detach",
|
||||
"move-separator",
|
||||
"move-to",
|
||||
"send-to-workflow",
|
||||
"plugins-separator",
|
||||
`plugin-primary-${PLUGIN_ID}-${ACTION_ID}`,
|
||||
"remove-separator",
|
||||
"archive",
|
||||
"delete",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not add a 'primary' entry when visible(context) returns false", () => {
|
||||
registerPrimaryAction(() => false);
|
||||
|
||||
const entries = buildKanbanCardMenuEntries({ workflows: [], stepsByWorkflowId: {} });
|
||||
|
||||
expect(entryKeys(entries)).not.toContain(`plugin-primary-${PLUGIN_ID}-${ACTION_ID}`);
|
||||
});
|
||||
|
||||
it("leaves the 'edit' group submenu unaffected by 'primary' group registrations", () => {
|
||||
registerPrimaryAction();
|
||||
|
||||
const entries = buildKanbanCardMenuEntries({ workflows: [], stepsByWorkflowId: {} });
|
||||
const editMenu = entries.find((entry) => entry.key === "edit");
|
||||
|
||||
expect(editMenu?.kind).toBe("item");
|
||||
});
|
||||
});
|
||||
@@ -212,91 +212,6 @@ describe("buildKanbanCardMenuEntries — !onEdit does not disable plugin edit ac
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildKanbanCardMenuEntries — 'primary' group plugin actions", () => {
|
||||
const PLUGIN_ID = "kandev-plugin-tags";
|
||||
|
||||
afterEach(() => {
|
||||
pluginRegistry.unregisterPlugin(PLUGIN_ID);
|
||||
});
|
||||
|
||||
function entryKeys(entries: KanbanCardMenuEntry[]) {
|
||||
return entries.map((entry) => entry.key);
|
||||
}
|
||||
|
||||
it("renders a 'primary' group action as a flat item between Send to workflow and Link", () => {
|
||||
pluginRegistry.forPlugin(PLUGIN_ID).registerTaskMenuAction({
|
||||
id: "quick-tag",
|
||||
label: "Quick tag",
|
||||
icon: PluginBitbucketIcon,
|
||||
group: "primary",
|
||||
run: vi.fn(),
|
||||
});
|
||||
|
||||
const entries = buildKanbanCardMenuEntries({
|
||||
currentWorkflowId: "wf-1",
|
||||
workflows: [
|
||||
{ id: "wf-1", name: "Workflow 1" },
|
||||
{ id: "wf-2", name: "Workflow 2" },
|
||||
],
|
||||
stepsByWorkflowId: {
|
||||
"wf-1": [
|
||||
{ id: "s1", title: "Step 1" },
|
||||
{ id: "s2", title: "Step 2" },
|
||||
],
|
||||
"wf-2": [{ id: "s3", title: "Step 3" }],
|
||||
},
|
||||
onSendToWorkflow: vi.fn(),
|
||||
onLinkPullRequest: vi.fn(),
|
||||
});
|
||||
|
||||
const keys = entryKeys(entries);
|
||||
const sendToIndex = keys.indexOf("send-to-workflow");
|
||||
const primaryIndex = keys.indexOf(`plugin-primary-${PLUGIN_ID}-quick-tag`);
|
||||
const linkIndex = keys.indexOf("link");
|
||||
|
||||
expect(sendToIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(primaryIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(linkIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(sendToIndex).toBeLessThan(primaryIndex);
|
||||
expect(primaryIndex).toBeLessThan(linkIndex);
|
||||
|
||||
const primaryEntry = entries[primaryIndex];
|
||||
expect(primaryEntry.kind).toBe("item");
|
||||
if (primaryEntry.kind === "item") {
|
||||
expect(primaryEntry.label).toBe("Quick tag");
|
||||
expect((primaryEntry.icon as { type?: unknown })?.type).toBe(PluginBitbucketIcon);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not add a 'primary' entry when visible(context) returns false", () => {
|
||||
pluginRegistry.forPlugin(PLUGIN_ID).registerTaskMenuAction({
|
||||
id: "quick-tag",
|
||||
label: "Quick tag",
|
||||
group: "primary",
|
||||
visible: () => false,
|
||||
run: vi.fn(),
|
||||
});
|
||||
|
||||
const entries = buildKanbanCardMenuEntries({ workflows: [], stepsByWorkflowId: {} });
|
||||
|
||||
expect(entryKeys(entries)).not.toContain(`plugin-primary-${PLUGIN_ID}-quick-tag`);
|
||||
});
|
||||
|
||||
it("leaves the 'edit' group submenu unaffected by 'primary' group registrations", () => {
|
||||
pluginRegistry.forPlugin(PLUGIN_ID).registerTaskMenuAction({
|
||||
id: "quick-tag",
|
||||
label: "Quick tag",
|
||||
group: "primary",
|
||||
run: vi.fn(),
|
||||
});
|
||||
|
||||
const entries = buildKanbanCardMenuEntries({ workflows: [], stepsByWorkflowId: {} });
|
||||
const editMenu = entries.find((entry) => entry.key === "edit");
|
||||
|
||||
expect(editMenu?.kind).toBe("item");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildKanbanCardMenuEntries — detach", () => {
|
||||
const baseArgs = {
|
||||
workflows: [],
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { type ReactNode } from "react";
|
||||
import {
|
||||
IconArchive,
|
||||
IconArrowRight,
|
||||
@@ -26,8 +25,6 @@ import {
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
} from "@kandev/ui/dropdown-menu";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import type { WorkflowStep } from "@/components/kanban-card";
|
||||
import {
|
||||
stepHasAutoStart,
|
||||
type TaskMoveStep,
|
||||
@@ -40,14 +37,13 @@ import {
|
||||
} from "@/lib/tasks/task-priority";
|
||||
import type { TaskPriority } from "@/lib/types/http";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { sortWorkflowStepsByPosition } from "@/lib/kanban/workflow-step-order";
|
||||
import { buildLinkSubmenu } from "./kanban-card-link-submenu";
|
||||
import type { PluginIcon, PluginTaskMenuContext } from "@/lib/plugins/types";
|
||||
import { buildEditMenuEntry } from "./kanban-card-edit-submenu";
|
||||
import { buildPrimaryPluginEntries } from "./plugins/task-menu-actions";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { t } from "@/lib/i18n";
|
||||
import type { WorkflowSnapshotData } from "@/lib/state/slices/kanban/types";
|
||||
export { useKanbanCardMoveTargets } from "@/hooks/use-kanban-card-move-targets";
|
||||
|
||||
type ItemEntry = {
|
||||
kind: "item";
|
||||
@@ -77,6 +73,26 @@ type SubmenuEntry = {
|
||||
|
||||
export type KanbanCardMenuEntry = ItemEntry | SeparatorEntry | SubmenuEntry;
|
||||
|
||||
export type KanbanCardMenuEntryGroup = {
|
||||
key: string;
|
||||
entries: readonly KanbanCardMenuEntry[];
|
||||
};
|
||||
|
||||
/** Inserts one separator between each nonempty top-level menu group. */
|
||||
export function buildGroupedMenuEntries(
|
||||
groups: readonly KanbanCardMenuEntryGroup[],
|
||||
): KanbanCardMenuEntry[] {
|
||||
return groups
|
||||
.filter((group) => group.entries.length > 0)
|
||||
.flatMap((group, index) => {
|
||||
const separator: SeparatorEntry = {
|
||||
kind: "separator",
|
||||
key: `${group.key}-separator`,
|
||||
};
|
||||
return index === 0 ? [...group.entries] : [separator, ...group.entries];
|
||||
});
|
||||
}
|
||||
|
||||
export type KanbanCardMoveTargets = {
|
||||
currentWorkflowId: string | null;
|
||||
workflowItems: TaskMoveWorkflow[];
|
||||
@@ -357,21 +373,12 @@ export function buildKanbanCardMenuEntries({
|
||||
const visibleWorkflows = workflows.filter((workflow) => !workflow.hidden);
|
||||
const currentSteps = currentWorkflowId ? (stepsByWorkflowId[currentWorkflowId] ?? []) : [];
|
||||
const isProcessing = Boolean(disabled || isDeleting || isArchiving || isDetaching);
|
||||
const entries: KanbanCardMenuEntry[] = [
|
||||
buildEditMenuEntry({
|
||||
onEdit,
|
||||
disabled: isProcessing,
|
||||
context: resolvePluginMenuContext(pluginMenuContext),
|
||||
forceFlat: forceFlatEdit,
|
||||
}),
|
||||
];
|
||||
|
||||
const priorityEntry = buildPriorityMenuEntry({
|
||||
currentPriority,
|
||||
disabled: isProcessing,
|
||||
onSelectPriority,
|
||||
});
|
||||
if (priorityEntry) entries.push(priorityEntry);
|
||||
|
||||
const moveToEntry = buildMoveToCurrentWorkflowSubmenu({
|
||||
steps: currentSteps,
|
||||
@@ -379,7 +386,6 @@ export function buildKanbanCardMenuEntries({
|
||||
disabled: isProcessing,
|
||||
onMoveToStep,
|
||||
});
|
||||
if (moveToEntry) entries.push(moveToEntry);
|
||||
|
||||
const sendToEntry = buildSendToWorkflowSubmenu({
|
||||
currentWorkflowId,
|
||||
@@ -388,14 +394,11 @@ export function buildKanbanCardMenuEntries({
|
||||
disabled: isProcessing,
|
||||
onSendToWorkflow,
|
||||
});
|
||||
if (sendToEntry) entries.push(sendToEntry);
|
||||
|
||||
entries.push(
|
||||
...buildPrimaryPluginEntries({
|
||||
disabled: isProcessing,
|
||||
context: resolvePluginMenuContext(pluginMenuContext),
|
||||
}),
|
||||
);
|
||||
const pluginEntries = buildPrimaryPluginEntries({
|
||||
disabled: isProcessing,
|
||||
context: resolvePluginMenuContext(pluginMenuContext),
|
||||
});
|
||||
|
||||
const linkEntry = buildLinkSubmenu({
|
||||
disabled: isProcessing,
|
||||
@@ -407,17 +410,43 @@ export function buildKanbanCardMenuEntries({
|
||||
onLinkSentryIssue,
|
||||
pluginLinkActions,
|
||||
});
|
||||
if (linkEntry) entries.push(linkEntry);
|
||||
|
||||
entries.push(buildArchiveEntry({ isArchiving, isProcessing, onArchive }));
|
||||
|
||||
const detachEntry = buildDetachEntry({ parentTaskId, onDetach, isDetaching, isProcessing });
|
||||
if (detachEntry) entries.push(detachEntry);
|
||||
|
||||
entries.push({ kind: "separator", key: "delete-separator" });
|
||||
entries.push(buildDeleteEntry({ isDeleting, isProcessing, onDelete }));
|
||||
|
||||
return entries;
|
||||
return buildGroupedMenuEntries([
|
||||
{ key: "priority", entries: priorityEntry ? [priorityEntry] : [] },
|
||||
{
|
||||
key: "edit",
|
||||
entries: [
|
||||
buildEditMenuEntry({
|
||||
onEdit,
|
||||
disabled: isProcessing,
|
||||
context: resolvePluginMenuContext(pluginMenuContext),
|
||||
forceFlat: forceFlatEdit,
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "relationships",
|
||||
entries: [linkEntry, detachEntry].filter(
|
||||
(entry): entry is KanbanCardMenuEntry => entry !== null,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "move",
|
||||
entries: [moveToEntry, sendToEntry].filter(
|
||||
(entry): entry is KanbanCardMenuEntry => entry !== null,
|
||||
),
|
||||
},
|
||||
{ key: "plugins", entries: pluginEntries },
|
||||
{
|
||||
key: "remove",
|
||||
entries: [
|
||||
buildArchiveEntry({ isArchiving, isProcessing, onArchive }),
|
||||
buildDeleteEntry({ isDeleting, isProcessing, onDelete }),
|
||||
],
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
export function buildArchiveEntry({
|
||||
@@ -491,68 +520,6 @@ function buildDetachEntry({
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The card's hot render path: a caller with column context already passes a
|
||||
* `steps` list pre-filtered for this workflow, and its `currentWorkflowId`
|
||||
* always resolves from `kanbanMulti.snapshots`. Kept to exactly these two
|
||||
* subscriptions (system design: no `kanban.tasks`/`hiddenWorkflowStepIds`
|
||||
* reads here) so unrelated writes to either slice never re-render every
|
||||
* visible card. Callers without column context (preview panel, detail top
|
||||
* bar) use `useTaskActionsMenuMoveTargets` instead, which layers the
|
||||
* flat-list and hidden-step fallbacks this hook intentionally omits.
|
||||
*/
|
||||
export function useKanbanCardMoveTargets(
|
||||
taskId: string,
|
||||
steps?: WorkflowStep[],
|
||||
): KanbanCardMoveTargets {
|
||||
const workflows = useAppStore((state) => state.workflows.items);
|
||||
const currentWorkflowId = useAppStore((state) => {
|
||||
for (const [workflowId, snapshot] of Object.entries(state.kanbanMulti.snapshots)) {
|
||||
if (snapshot.tasks.some((task) => task.id === taskId)) return workflowId;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const snapshotStepsByWorkflowId = useAppStore(
|
||||
useShallow((state): Record<string, WorkflowSnapshotData["steps"]> => {
|
||||
const result: Record<string, WorkflowSnapshotData["steps"]> = {};
|
||||
for (const [workflowId, snapshot] of Object.entries(state.kanbanMulti.snapshots)) {
|
||||
result[workflowId] = snapshot.steps;
|
||||
}
|
||||
return result;
|
||||
}),
|
||||
);
|
||||
|
||||
const workflowItems = useMemo<TaskMoveWorkflow[]>(() => {
|
||||
const current = workflows.find((workflow) => workflow.id === currentWorkflowId);
|
||||
return workflows
|
||||
.filter((workflow) => workflow.workspaceId === current?.workspaceId && !workflow.hidden)
|
||||
.map((workflow) => ({ id: workflow.id, name: workflow.name, hidden: workflow.hidden }));
|
||||
}, [workflows, currentWorkflowId]);
|
||||
|
||||
const stepsByWorkflowId = useMemo<Record<string, TaskMoveStep[]>>(() => {
|
||||
const result: Record<string, TaskMoveStep[]> = {};
|
||||
for (const [workflowId, snapshotSteps] of Object.entries(snapshotStepsByWorkflowId)) {
|
||||
result[workflowId] = sortWorkflowStepsByPosition(snapshotSteps).map((step) => ({
|
||||
id: step.id,
|
||||
title: step.title,
|
||||
color: step.color,
|
||||
events: step.events,
|
||||
}));
|
||||
}
|
||||
if (currentWorkflowId && steps) {
|
||||
result[currentWorkflowId] = steps.map((step) => ({
|
||||
id: step.id,
|
||||
title: step.title,
|
||||
color: step.color,
|
||||
events: step.events,
|
||||
}));
|
||||
}
|
||||
return result;
|
||||
}, [snapshotStepsByWorkflowId, currentWorkflowId, steps]);
|
||||
|
||||
return { currentWorkflowId, workflowItems, stepsByWorkflowId };
|
||||
}
|
||||
|
||||
function ContextEntry({ entry }: { entry: KanbanCardMenuEntry }) {
|
||||
if (entry.kind === "separator") return <ContextMenuSeparator />;
|
||||
if (entry.kind === "submenu") {
|
||||
|
||||
@@ -70,8 +70,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 — positioned between
|
||||
* "Move to"/"Send to workflow" and "Link".
|
||||
* submenu), these render directly in the card menu — after the movement
|
||||
* group and before the Archive/Delete removal group.
|
||||
*/
|
||||
export function buildPrimaryPluginEntries({
|
||||
disabled,
|
||||
|
||||
@@ -88,16 +88,18 @@ export function TaskDeleteItem({
|
||||
taskId,
|
||||
isDeleting,
|
||||
onDeleteTask,
|
||||
showSeparator = true,
|
||||
}: {
|
||||
taskId: string;
|
||||
isDeleting?: boolean;
|
||||
onDeleteTask?: (taskId: string) => void;
|
||||
showSeparator?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!onDeleteTask) return null;
|
||||
return (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
{showSeparator ? <ContextMenuSeparator /> : null}
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
disabled={isDeleting}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { StateProvider } from "@/components/state-provider";
|
||||
import { ToastProvider } from "@/components/toast-provider";
|
||||
import { TaskItemWithContextMenu } from "./task-switcher-context-menu";
|
||||
import type { TaskSwitcherItem } from "./task-switcher-types";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function task(overrides: Partial<TaskSwitcherItem> = {}): TaskSwitcherItem {
|
||||
return { id: "task-1", title: "Task 1", state: "IN_PROGRESS", ...overrides };
|
||||
}
|
||||
|
||||
function ArchiveAwareRow() {
|
||||
return <div data-testid="task-row">Task 1</div>;
|
||||
}
|
||||
|
||||
describe("TaskItemWithContextMenu — single-selection bulk archive", () => {
|
||||
it("keeps Archive for a one-row selection with only the bulk archive handler", async () => {
|
||||
const onBulkArchive = vi.fn();
|
||||
render(
|
||||
<StateProvider>
|
||||
<ToastProvider>
|
||||
<TaskItemWithContextMenu
|
||||
task={task()}
|
||||
selectedTaskIds={new Set(["task-1"])}
|
||||
onBulkArchive={onBulkArchive}
|
||||
>
|
||||
<ArchiveAwareRow />
|
||||
</TaskItemWithContextMenu>
|
||||
</ToastProvider>
|
||||
</StateProvider>,
|
||||
);
|
||||
|
||||
fireEvent.contextMenu(screen.getByTestId("task-row"));
|
||||
const archive = await screen.findByRole("menuitem", { name: "Archive" });
|
||||
|
||||
fireEvent.click(archive);
|
||||
|
||||
expect(onBulkArchive).toHaveBeenCalledWith(["task-1"]);
|
||||
});
|
||||
|
||||
it("does not show a removal group for a non-selected row with only the bulk archive handler", async () => {
|
||||
render(
|
||||
<StateProvider>
|
||||
<ToastProvider>
|
||||
<TaskItemWithContextMenu
|
||||
task={task()}
|
||||
selectedTaskIds={new Set(["task-2"])}
|
||||
onBulkArchive={vi.fn()}
|
||||
>
|
||||
<ArchiveAwareRow />
|
||||
</TaskItemWithContextMenu>
|
||||
</ToastProvider>
|
||||
</StateProvider>,
|
||||
);
|
||||
|
||||
fireEvent.contextMenu(screen.getByTestId("task-row"));
|
||||
const menu = await screen.findByRole("menu");
|
||||
|
||||
expect(within(menu).queryByRole("menuitem", { name: /archive/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,568 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Fragment, type ReactNode } from "react";
|
||||
import {
|
||||
IconCopy,
|
||||
IconEdit,
|
||||
IconPencil,
|
||||
IconPin,
|
||||
IconPinFilled,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import { ContextMenuItem, ContextMenuSeparator } from "@kandev/ui/context-menu";
|
||||
import type { TaskMoveWorkflow } from "@/components/task/task-move-context-menu";
|
||||
import {
|
||||
KanbanCardContextMenuItems,
|
||||
type KanbanCardMenuEntry,
|
||||
} from "@/components/kanban-card-menu-items";
|
||||
import { TaskNestContextMenuItems } from "@/components/task/task-nest-context-menu";
|
||||
import { useTaskWorkflowMove } from "@/hooks/use-task-workflow-move";
|
||||
import { useUpdateTaskPriority } from "@/hooks/use-update-task-priority";
|
||||
import { TaskColorMenu } from "./task-switcher-color-menu";
|
||||
import { TaskPluginLinkMenu, selectTaskLinkActions } from "./task-switcher-link-menu";
|
||||
import {
|
||||
TaskArchiveItem,
|
||||
TaskCreateSubtaskItem,
|
||||
TaskDeleteItem,
|
||||
TaskDetachItem,
|
||||
} from "./task-switcher-action-items";
|
||||
import {
|
||||
useTaskPluginLinkActions,
|
||||
type PluginLinkMenuAction,
|
||||
} from "./task-session-sidebar-link-actions";
|
||||
import type { StepDef, TaskSwitcherItem } from "./task-switcher-types";
|
||||
import { useTaskPluginPrimaryMenuEntries } from "./task-switcher-plugin-menu-items";
|
||||
import { TaskPriorityContextMenu } from "./task-priority-context-menu";
|
||||
import type { TaskContextMenuItemsProps } from "./task-switcher-context-menu";
|
||||
import { TaskMoveItems } from "./task-switcher-context-menu-move-items";
|
||||
|
||||
type SingleSelectionMenuProps = TaskContextMenuItemsProps & {
|
||||
actingIds: string[];
|
||||
actingOnSelection: boolean;
|
||||
};
|
||||
|
||||
type LinkActions = ReturnType<typeof selectTaskLinkActions>;
|
||||
|
||||
type SingleGroupProps = Pick<
|
||||
SingleSelectionMenuProps,
|
||||
| "task"
|
||||
| "isDeleting"
|
||||
| "isArchiving"
|
||||
| "isPinned"
|
||||
| "onTogglePin"
|
||||
| "onEditTask"
|
||||
| "onRenameTask"
|
||||
| "onCreateSubtask"
|
||||
| "onDetachTask"
|
||||
| "onArchiveTask"
|
||||
| "onDeleteTask"
|
||||
| "closeMenu"
|
||||
| "actingIds"
|
||||
| "actingOnSelection"
|
||||
| "onBulkArchive"
|
||||
| "onClearSelection"
|
||||
| "onBulkMove"
|
||||
| "workflows"
|
||||
| "stepsByWorkflowId"
|
||||
| "steps"
|
||||
| "onMoveToStep"
|
||||
| "isMixedWorkflowSelection"
|
||||
| "moveTasks"
|
||||
> & {
|
||||
linkActions?: LinkActions;
|
||||
pluginLinkActions?: PluginLinkMenuAction[];
|
||||
hasLinkGroup?: boolean;
|
||||
};
|
||||
|
||||
type TaskContextMenuGroup = {
|
||||
key: string;
|
||||
visible: boolean;
|
||||
content: ReactNode;
|
||||
};
|
||||
|
||||
function TaskContextMenuGroups({ groups }: { groups: readonly TaskContextMenuGroup[] }) {
|
||||
const visibleGroups = groups.filter((group) => group.visible);
|
||||
return (
|
||||
<>
|
||||
{visibleGroups.map((group, index) => (
|
||||
<Fragment key={group.key}>
|
||||
{index > 0 && <ContextMenuSeparator />}
|
||||
{group.content}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type SingleMenuState = {
|
||||
pluginPrimaryEntries: KanbanCardMenuEntry[];
|
||||
pluginLinkActions: PluginLinkMenuAction[];
|
||||
linkActions: LinkActions;
|
||||
hasMoveGroup: boolean;
|
||||
hasLinkGroup: boolean;
|
||||
hasRelationshipsGroup: boolean;
|
||||
onDelete: ((id: string) => void) | undefined;
|
||||
onDetach: ((id: string) => void) | undefined;
|
||||
};
|
||||
|
||||
function useSingleMenuState(props: SingleSelectionMenuProps): SingleMenuState {
|
||||
const { task, closeMenu, stepsByWorkflowId, steps, workflows, actingOnSelection } = props;
|
||||
const pluginPrimaryEntries = useTaskPluginPrimaryMenuEntries(task, props.isDeleting);
|
||||
const pluginLinkActions = useTaskPluginLinkActions(task.id, task.repositoryLinks ?? []);
|
||||
const linkActions = selectTaskLinkActions(task, closeMenu, props);
|
||||
const workflowId = task.workflowId;
|
||||
const moveStepsByWorkflowId =
|
||||
stepsByWorkflowId ?? (workflowId && steps ? { [workflowId]: steps } : undefined);
|
||||
const currentMoveSteps = workflowId ? (moveStepsByWorkflowId?.[workflowId] ?? []) : [];
|
||||
const hasMoveGroup = hasSingleMoveGroup({
|
||||
task,
|
||||
workflows,
|
||||
workflowId,
|
||||
currentMoveSteps,
|
||||
actingOnSelection,
|
||||
isMixedWorkflowSelection: props.isMixedWorkflowSelection,
|
||||
});
|
||||
const hasLinkGroup = Object.values(linkActions).some(Boolean) || pluginLinkActions.length > 0;
|
||||
const hasRelationshipsGroup = Boolean(
|
||||
props.onCreateSubtask ||
|
||||
(!task.isArchived && task.workflowId) ||
|
||||
hasLinkGroup ||
|
||||
(task.parentTaskId && props.onDetachTask),
|
||||
);
|
||||
return {
|
||||
pluginPrimaryEntries,
|
||||
pluginLinkActions,
|
||||
linkActions,
|
||||
hasMoveGroup,
|
||||
hasLinkGroup,
|
||||
hasRelationshipsGroup,
|
||||
onDelete: withSelectionClear(actingOnSelection, props.onClearSelection, props.onDeleteTask),
|
||||
onDetach: withSelectionClear(actingOnSelection, props.onClearSelection, props.onDetachTask),
|
||||
};
|
||||
}
|
||||
|
||||
function SingleSelectionMenuItems(props: SingleSelectionMenuProps) {
|
||||
return <SingleSelectionMenuGroups {...props} {...useSingleMenuState(props)} />;
|
||||
}
|
||||
|
||||
function SingleSelectionMenuGroups(props: SingleSelectionMenuProps & SingleMenuState) {
|
||||
const { task } = props;
|
||||
return (
|
||||
<TaskContextMenuGroups
|
||||
groups={[
|
||||
{
|
||||
key: "mark",
|
||||
visible: Boolean(props.onTogglePin || !task.isArchived),
|
||||
content: <SingleMarkGroup {...props} />,
|
||||
},
|
||||
{
|
||||
key: "edit",
|
||||
visible: Boolean(!task.isArchived || props.onRenameTask),
|
||||
content: <SingleEditGroup {...props} />,
|
||||
},
|
||||
{
|
||||
key: "relationships",
|
||||
visible: props.hasRelationshipsGroup,
|
||||
content: <SingleRelationshipsGroup {...props} onDetachTask={props.onDetach} />,
|
||||
},
|
||||
{
|
||||
key: "move",
|
||||
visible: props.hasMoveGroup,
|
||||
content: <TaskMoveItems {...props} />,
|
||||
},
|
||||
{
|
||||
key: "plugins",
|
||||
visible: props.pluginPrimaryEntries.length > 0,
|
||||
content: <KanbanCardContextMenuItems entries={props.pluginPrimaryEntries} />,
|
||||
},
|
||||
{
|
||||
key: "remove",
|
||||
visible: hasSingleRemoveGroup(props),
|
||||
content: <SingleRemoveGroup {...props} onDeleteTask={props.onDelete} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function hasSingleMoveGroup({
|
||||
task,
|
||||
workflows,
|
||||
workflowId,
|
||||
currentMoveSteps,
|
||||
actingOnSelection,
|
||||
isMixedWorkflowSelection,
|
||||
}: Pick<
|
||||
SingleSelectionMenuProps,
|
||||
"task" | "workflows" | "actingOnSelection" | "isMixedWorkflowSelection"
|
||||
> & {
|
||||
workflowId: string | undefined;
|
||||
currentMoveSteps: StepDef[];
|
||||
}) {
|
||||
if (task.isArchived || !workflowId) return false;
|
||||
const hasSameWorkflowMove =
|
||||
currentMoveSteps.length > 1 && (!actingOnSelection || !isMixedWorkflowSelection);
|
||||
const hasCrossWorkflowMove = (workflows ?? []).some(
|
||||
(workflow) => !workflow.hidden && workflow.id !== workflowId,
|
||||
);
|
||||
return hasSameWorkflowMove || hasCrossWorkflowMove;
|
||||
}
|
||||
|
||||
function hasSingleRemoveGroup({
|
||||
actingOnSelection,
|
||||
onArchiveTask,
|
||||
onBulkArchive,
|
||||
onDeleteTask,
|
||||
}: Pick<
|
||||
SingleSelectionMenuProps,
|
||||
"actingOnSelection" | "onArchiveTask" | "onBulkArchive" | "onDeleteTask"
|
||||
>) {
|
||||
return Boolean(onArchiveTask || onDeleteTask || (actingOnSelection && onBulkArchive));
|
||||
}
|
||||
|
||||
function SingleMarkGroup({
|
||||
task,
|
||||
isPinned,
|
||||
isDeleting,
|
||||
actingOnSelection,
|
||||
onClearSelection,
|
||||
onTogglePin,
|
||||
}: Pick<
|
||||
SingleGroupProps,
|
||||
"task" | "isPinned" | "isDeleting" | "actingOnSelection" | "onClearSelection" | "onTogglePin"
|
||||
>) {
|
||||
const updateTaskPriority = useUpdateTaskPriority();
|
||||
return (
|
||||
<>
|
||||
<TaskPinItem
|
||||
taskId={task.id}
|
||||
isPinned={isPinned}
|
||||
disabled={isDeleting}
|
||||
onTogglePin={withSelectionClear(actingOnSelection, onClearSelection, onTogglePin)}
|
||||
/>
|
||||
{!task.isArchived && (
|
||||
<TaskColorMenu
|
||||
taskId={task.id}
|
||||
disabled={isDeleting}
|
||||
automaticColorSource={task.automaticColorSource}
|
||||
/>
|
||||
)}
|
||||
{!task.isArchived && (
|
||||
<TaskPriorityContextMenu
|
||||
currentPriority={task.priority}
|
||||
disabled={isDeleting}
|
||||
onSelect={(priority) => void updateTaskPriority(task.id, priority)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SingleEditGroup({
|
||||
task,
|
||||
isDeleting,
|
||||
onEditTask,
|
||||
onRenameTask,
|
||||
}: Pick<SingleGroupProps, "task" | "isDeleting" | "onEditTask" | "onRenameTask">) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<TaskEditItem task={task} disabled={isDeleting} onEditTask={onEditTask} />
|
||||
<TaskRenameItem task={task} disabled={isDeleting} onRenameTask={onRenameTask} />
|
||||
{!task.isArchived && (
|
||||
<ContextMenuItem disabled>
|
||||
<IconCopy className="mr-2 h-4 w-4" />
|
||||
{t("settings:duplicate")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SingleRelationshipsGroup({
|
||||
task,
|
||||
isDeleting,
|
||||
onCreateSubtask,
|
||||
onDetachTask,
|
||||
closeMenu,
|
||||
linkActions,
|
||||
pluginLinkActions,
|
||||
hasLinkGroup,
|
||||
}: Pick<
|
||||
SingleGroupProps,
|
||||
| "task"
|
||||
| "isDeleting"
|
||||
| "onCreateSubtask"
|
||||
| "onDetachTask"
|
||||
| "closeMenu"
|
||||
| "linkActions"
|
||||
| "pluginLinkActions"
|
||||
| "hasLinkGroup"
|
||||
>) {
|
||||
return (
|
||||
<>
|
||||
<TaskCreateSubtaskItem task={task} disabled={isDeleting} onCreateSubtask={onCreateSubtask} />
|
||||
{!task.isArchived && <TaskNestContextMenuItems task={task} disabled={isDeleting} />}
|
||||
{hasLinkGroup && linkActions && pluginLinkActions && (
|
||||
<TaskPluginLinkMenu
|
||||
disabled={isDeleting}
|
||||
closeMenu={closeMenu}
|
||||
linkActions={linkActions}
|
||||
pluginLinkActions={pluginLinkActions}
|
||||
/>
|
||||
)}
|
||||
<TaskDetachItem task={task} disabled={isDeleting} onDetachTask={onDetachTask} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SingleRemoveGroup({
|
||||
task,
|
||||
isDeleting,
|
||||
isArchiving,
|
||||
onArchiveTask,
|
||||
onDeleteTask,
|
||||
actingIds,
|
||||
actingOnSelection,
|
||||
onBulkArchive,
|
||||
}: Pick<
|
||||
SingleGroupProps,
|
||||
| "task"
|
||||
| "isDeleting"
|
||||
| "isArchiving"
|
||||
| "onArchiveTask"
|
||||
| "onDeleteTask"
|
||||
| "actingIds"
|
||||
| "actingOnSelection"
|
||||
| "onBulkArchive"
|
||||
>) {
|
||||
return (
|
||||
<>
|
||||
<TaskArchiveItem
|
||||
taskId={task.id}
|
||||
actingIds={actingIds}
|
||||
actingOnSelection={actingOnSelection}
|
||||
disabled={isDeleting || isArchiving}
|
||||
onArchiveTask={onArchiveTask}
|
||||
onBulkArchive={onBulkArchive}
|
||||
/>
|
||||
<TaskDeleteItem
|
||||
taskId={task.id}
|
||||
isDeleting={isDeleting}
|
||||
onDeleteTask={onDeleteTask}
|
||||
showSeparator={false}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function withSelectionClear(
|
||||
actingOnSelection: boolean,
|
||||
onClearSelection: (() => void) | undefined,
|
||||
handler: ((id: string) => void) | undefined,
|
||||
) {
|
||||
if (!actingOnSelection || !onClearSelection || !handler) return handler;
|
||||
return (id: string) => {
|
||||
onClearSelection();
|
||||
handler(id);
|
||||
};
|
||||
}
|
||||
|
||||
type BulkSelectionMenuProps = {
|
||||
task: TaskSwitcherItem;
|
||||
actingIds: string[];
|
||||
workflows?: TaskMoveWorkflow[];
|
||||
stepsByWorkflowId?: Record<string, StepDef[]>;
|
||||
steps?: StepDef[];
|
||||
isMixedWorkflowSelection?: boolean;
|
||||
pinnedTaskIds?: string[];
|
||||
onBulkPin?: (taskIds: string[]) => void;
|
||||
onBulkArchive?: (taskIds: string[]) => void;
|
||||
onBulkDelete?: (taskIds: string[]) => void;
|
||||
onBulkMove?: (taskIds: string[], targetWorkflowId: string, targetStepId: string) => void;
|
||||
closeMenu: () => void;
|
||||
moveTasks: ReturnType<typeof useTaskWorkflowMove>;
|
||||
};
|
||||
|
||||
function BulkSelectionMenuItems(props: BulkSelectionMenuProps) {
|
||||
const { task, workflows, stepsByWorkflowId, steps, isMixedWorkflowSelection } = props;
|
||||
const hasMoveGroup = hasBulkMoveGroup({
|
||||
task,
|
||||
workflows,
|
||||
stepsByWorkflowId,
|
||||
steps,
|
||||
isMixedWorkflowSelection,
|
||||
});
|
||||
return (
|
||||
<TaskContextMenuGroups
|
||||
groups={[
|
||||
{
|
||||
key: "mark",
|
||||
visible: Boolean(props.onBulkPin),
|
||||
content: <BulkPinMenuItem {...props} />,
|
||||
},
|
||||
{
|
||||
key: "move",
|
||||
visible: hasMoveGroup,
|
||||
content: <BulkMoveGroup {...props} />,
|
||||
},
|
||||
{
|
||||
key: "remove",
|
||||
visible: Boolean(props.onBulkArchive || props.onBulkDelete),
|
||||
content: <BulkRemoveGroup {...props} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function hasBulkMoveGroup({
|
||||
task,
|
||||
workflows,
|
||||
stepsByWorkflowId,
|
||||
steps,
|
||||
isMixedWorkflowSelection,
|
||||
}: Pick<
|
||||
BulkSelectionMenuProps,
|
||||
"task" | "workflows" | "stepsByWorkflowId" | "steps" | "isMixedWorkflowSelection"
|
||||
>) {
|
||||
const workflowId = task.workflowId;
|
||||
const moveStepsByWorkflowId =
|
||||
stepsByWorkflowId ?? (workflowId && steps ? { [workflowId]: steps } : undefined);
|
||||
const currentMoveSteps = workflowId ? (moveStepsByWorkflowId?.[workflowId] ?? []) : [];
|
||||
return Boolean(
|
||||
workflowId &&
|
||||
((!isMixedWorkflowSelection && currentMoveSteps.length > 1) ||
|
||||
(workflows ?? []).some((workflow) => !workflow.hidden && workflow.id !== workflowId)),
|
||||
);
|
||||
}
|
||||
|
||||
function BulkPinMenuItem({
|
||||
actingIds,
|
||||
pinnedTaskIds,
|
||||
onBulkPin,
|
||||
}: Pick<BulkSelectionMenuProps, "actingIds" | "pinnedTaskIds" | "onBulkPin">) {
|
||||
const { t } = useTranslation();
|
||||
if (!onBulkPin) return null;
|
||||
const allPinned =
|
||||
actingIds.length > 0 && actingIds.every((id) => pinnedTaskIds?.includes(id) ?? false);
|
||||
const label = allPinned
|
||||
? t("task:unpinTasksCount", { count: actingIds.length })
|
||||
: t("task:pinTasksCount", { count: actingIds.length });
|
||||
return (
|
||||
<ContextMenuItem onSelect={() => onBulkPin(actingIds)}>
|
||||
{allPinned ? (
|
||||
<IconPinFilled className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<IconPin className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{label}
|
||||
</ContextMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function BulkMoveGroup(props: BulkSelectionMenuProps) {
|
||||
return (
|
||||
<TaskMoveItems
|
||||
task={props.task}
|
||||
workflows={props.workflows}
|
||||
stepsByWorkflowId={props.stepsByWorkflowId}
|
||||
steps={props.steps}
|
||||
onMoveToStep={undefined}
|
||||
actingIds={props.actingIds}
|
||||
actingOnSelection
|
||||
onBulkMove={props.onBulkMove}
|
||||
isMixedWorkflowSelection={props.isMixedWorkflowSelection}
|
||||
closeMenu={props.closeMenu}
|
||||
moveTasks={props.moveTasks}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BulkRemoveGroup({
|
||||
task,
|
||||
actingIds,
|
||||
onBulkArchive,
|
||||
onBulkDelete,
|
||||
}: Pick<BulkSelectionMenuProps, "task" | "actingIds" | "onBulkArchive" | "onBulkDelete">) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<TaskArchiveItem
|
||||
taskId={task.id}
|
||||
actingIds={actingIds}
|
||||
actingOnSelection
|
||||
onArchiveTask={undefined}
|
||||
onBulkArchive={onBulkArchive}
|
||||
/>
|
||||
{onBulkDelete && (
|
||||
<ContextMenuItem variant="destructive" onSelect={() => onBulkDelete(actingIds)}>
|
||||
<IconTrash className="mr-2 h-4 w-4" />
|
||||
{t("task:deleteTasksCount", { count: actingIds.length })}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskPinItem({
|
||||
taskId,
|
||||
isPinned,
|
||||
disabled,
|
||||
onTogglePin,
|
||||
}: {
|
||||
taskId: string;
|
||||
isPinned?: boolean;
|
||||
disabled?: boolean;
|
||||
onTogglePin?: (taskId: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!onTogglePin) return null;
|
||||
return (
|
||||
<ContextMenuItem disabled={disabled} onSelect={() => onTogglePin(taskId)}>
|
||||
{isPinned ? <IconPinFilled className="mr-2 h-4 w-4" /> : <IconPin className="mr-2 h-4 w-4" />}
|
||||
{isPinned ? t("task:unpin") : t("task:annotationPin")}
|
||||
</ContextMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskRenameItem({
|
||||
task,
|
||||
disabled,
|
||||
onRenameTask,
|
||||
}: {
|
||||
task: TaskSwitcherItem;
|
||||
disabled?: boolean;
|
||||
onRenameTask?: (taskId: string, currentTitle: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!onRenameTask) return null;
|
||||
return (
|
||||
<ContextMenuItem disabled={disabled} onSelect={() => onRenameTask(task.id, task.title)}>
|
||||
<IconPencil className="mr-2 h-4 w-4" />
|
||||
{t("task:rename")}
|
||||
</ContextMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskEditItem({
|
||||
task,
|
||||
disabled,
|
||||
onEditTask,
|
||||
}: {
|
||||
task: TaskSwitcherItem;
|
||||
disabled?: boolean;
|
||||
onEditTask?: (task: TaskSwitcherItem) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!onEditTask || task.isArchived || !task.workflowId || !task.workflowStepId) return null;
|
||||
return (
|
||||
<ContextMenuItem disabled={disabled} onSelect={() => onEditTask(task)}>
|
||||
<IconEdit className="mr-2 h-4 w-4" />
|
||||
{t("common:edit")}
|
||||
</ContextMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
export { BulkSelectionMenuItems, SingleSelectionMenuItems };
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { TaskMoveContextMenuItems } from "@/components/task/task-move-context-menu";
|
||||
import type { TaskContextMenuItemsProps } from "./task-switcher-context-menu";
|
||||
|
||||
export function TaskMoveItems({
|
||||
task,
|
||||
workflows,
|
||||
stepsByWorkflowId,
|
||||
steps,
|
||||
isDeleting,
|
||||
onMoveToStep,
|
||||
onMoveToStepWithOptions,
|
||||
onSubmitWithOptions,
|
||||
moveOptionsBusy,
|
||||
actingIds,
|
||||
actingOnSelection,
|
||||
onBulkMove,
|
||||
isMixedWorkflowSelection,
|
||||
closeMenu,
|
||||
moveTasks,
|
||||
}: Omit<TaskContextMenuItemsProps, "onRenameTask" | "onArchiveTask" | "onDeleteTask"> & {
|
||||
actingIds: string[];
|
||||
actingOnSelection: boolean;
|
||||
}) {
|
||||
if (!task.workflowId) return null;
|
||||
const workflowId = task.workflowId;
|
||||
const runSelectionMove = (
|
||||
targetWorkflowId: string,
|
||||
stepId: string,
|
||||
destination: "step" | "workflow",
|
||||
) => {
|
||||
closeMenu();
|
||||
if (onBulkMove) {
|
||||
onBulkMove(actingIds, targetWorkflowId, stepId);
|
||||
return;
|
||||
}
|
||||
void moveTasks(actingIds, targetWorkflowId, stepId, destination).catch(() => {
|
||||
// useTaskWorkflowMove already shows the failure toast.
|
||||
});
|
||||
};
|
||||
|
||||
let moveToStep: ((stepId: string) => void) | undefined;
|
||||
if (actingOnSelection) {
|
||||
moveToStep = isMixedWorkflowSelection
|
||||
? undefined
|
||||
: (stepId) => runSelectionMove(workflowId, stepId, "step");
|
||||
} else {
|
||||
moveToStep = (stepId) => {
|
||||
closeMenu();
|
||||
if (onMoveToStep) {
|
||||
onMoveToStep(task.id, workflowId, stepId);
|
||||
return;
|
||||
}
|
||||
void moveTasks([task.id], workflowId, stepId, "step").catch(() => {
|
||||
// useTaskWorkflowMove already shows the failure toast.
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<TaskMoveContextMenuItems
|
||||
currentWorkflowId={workflowId}
|
||||
currentStepId={actingOnSelection ? undefined : task.workflowStepId}
|
||||
workflows={workflows ?? []}
|
||||
stepsByWorkflowId={stepsByWorkflowId ?? (steps ? { [workflowId]: steps } : {})}
|
||||
disabled={isDeleting || task.isArchived}
|
||||
showSeparator={false}
|
||||
onMoveToStep={moveToStep}
|
||||
onMoveToStepWithOptions={
|
||||
actingIds.length === 1 && onMoveToStepWithOptions ? onMoveToStepWithOptions : undefined
|
||||
}
|
||||
onSubmitWithOptions={
|
||||
actingIds.length === 1 && onSubmitWithOptions ? onSubmitWithOptions : undefined
|
||||
}
|
||||
isMoving={moveOptionsBusy}
|
||||
onSendToWorkflow={(targetWorkflowId, stepId) => {
|
||||
if (actingOnSelection) {
|
||||
runSelectionMove(targetWorkflowId, stepId, "workflow");
|
||||
return;
|
||||
}
|
||||
closeMenu();
|
||||
void moveTasks([task.id], targetWorkflowId, stepId, "workflow").catch(() => {
|
||||
// useTaskWorkflowMove already shows the failure toast.
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,9 @@ import type { TaskSwitcherItem } from "./task-switcher-types";
|
||||
const PLUGIN_ID = "example-task-actions";
|
||||
const PLUGIN_ACTION_LABEL = "Inspect task";
|
||||
const WORKFLOW_1_ID = "workflow-1";
|
||||
const WORKFLOW_ID = WORKFLOW_1_ID;
|
||||
const STEP_ID = "step-1";
|
||||
const SEPARATOR_SLOT = "context-menu-separator";
|
||||
const MOVE_TO_TEST_ID = "task-context-move-to";
|
||||
const STEP_2_TEST_ID = "task-context-step-step-2";
|
||||
const INSTRUCTIONS_TEST_ID = "workflow-move-instructions";
|
||||
@@ -95,7 +98,7 @@ function renderPluginMenu(run: (context: PluginTaskMenuContext) => void = vi.fn(
|
||||
render(
|
||||
<StateProvider initialState={{ workspaces: { items: [], activeId: "workspace-1" } }}>
|
||||
<ToastProvider>
|
||||
<TaskItemWithContextMenu task={task({ workflowStepId: "step-1" })}>
|
||||
<TaskItemWithContextMenu task={task({ workflowStepId: STEP_ID })}>
|
||||
<div data-testid="plugin-task-row">Task 1</div>
|
||||
</TaskItemWithContextMenu>
|
||||
</ToastProvider>
|
||||
@@ -116,7 +119,7 @@ describe("TaskItemWithContextMenu — plugin primary actions", () => {
|
||||
workspaceId: "workspace-1",
|
||||
taskId: "task-1",
|
||||
taskTitle: "Task 1",
|
||||
workflowStepId: "step-1",
|
||||
workflowStepId: STEP_ID,
|
||||
presentation: "desktop",
|
||||
});
|
||||
});
|
||||
@@ -133,6 +136,144 @@ describe("TaskItemWithContextMenu — plugin primary actions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskItemWithContextMenu — grouped single-task actions", () => {
|
||||
it("renders actions in group order with one divider between nonempty groups", async () => {
|
||||
pluginRegistry.forPlugin(PLUGIN_ID).registerTaskMenuAction({
|
||||
id: "inspect-task",
|
||||
label: PLUGIN_ACTION_LABEL,
|
||||
group: "primary",
|
||||
run: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<StateProvider initialState={{ workspaces: { items: [], activeId: "workspace-1" } }}>
|
||||
<ToastProvider>
|
||||
<TaskItemWithContextMenu
|
||||
task={task({
|
||||
workflowId: WORKFLOW_ID,
|
||||
workflowStepId: STEP_ID,
|
||||
parentTaskId: "parent-1",
|
||||
})}
|
||||
workflows={[
|
||||
{ id: WORKFLOW_ID, name: "Workflow 1" },
|
||||
{ id: "workflow-2", name: "Workflow 2" },
|
||||
]}
|
||||
stepsByWorkflowId={{
|
||||
[WORKFLOW_ID]: [
|
||||
{ id: STEP_ID, title: "Step 1" },
|
||||
{ id: "step-2", title: "Step 2" },
|
||||
],
|
||||
}}
|
||||
onTogglePin={vi.fn()}
|
||||
onEditTask={vi.fn()}
|
||||
onRenameTask={vi.fn()}
|
||||
onArchiveTask={vi.fn()}
|
||||
onCreateSubtask={vi.fn()}
|
||||
onDeleteTask={vi.fn()}
|
||||
onDetachTask={vi.fn()}
|
||||
onMoveToStep={vi.fn()}
|
||||
onLinkPullRequest={vi.fn()}
|
||||
>
|
||||
<ArchiveAwareRow />
|
||||
</TaskItemWithContextMenu>
|
||||
</ToastProvider>
|
||||
</StateProvider>,
|
||||
);
|
||||
|
||||
fireEvent.contextMenu(screen.getByTestId("task-row"));
|
||||
const menu = await screen.findByRole("menu");
|
||||
const labels = within(menu)
|
||||
.getAllByRole("menuitem")
|
||||
.map((item) => item.textContent?.replace(/\s+/g, " ").trim());
|
||||
|
||||
expect(labels).toEqual([
|
||||
"Pin",
|
||||
"Color",
|
||||
"Priority",
|
||||
"Edit",
|
||||
"Rename",
|
||||
"Duplicate",
|
||||
"Create Subtask",
|
||||
"Nest under",
|
||||
"Link",
|
||||
"Detach from parent",
|
||||
"Move to",
|
||||
"Send to workflow",
|
||||
PLUGIN_ACTION_LABEL,
|
||||
"Archive",
|
||||
"Delete",
|
||||
]);
|
||||
|
||||
const directChildren = Array.from(menu.children);
|
||||
const separators = directChildren.filter(
|
||||
(child) => child.getAttribute("data-slot") === SEPARATOR_SLOT,
|
||||
);
|
||||
expect(separators).toHaveLength(5);
|
||||
expect(directChildren[0]?.getAttribute("data-slot")).not.toBe(SEPARATOR_SLOT);
|
||||
expect(directChildren.at(-1)?.getAttribute("data-slot")).not.toBe(SEPARATOR_SLOT);
|
||||
expect(
|
||||
directChildren.some(
|
||||
(child, index) =>
|
||||
child.getAttribute("data-slot") === SEPARATOR_SLOT &&
|
||||
directChildren[index - 1]?.getAttribute("data-slot") === SEPARATOR_SLOT,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskItemWithContextMenu — grouped bulk actions", () => {
|
||||
it("keeps bulk actions in mark, movement, and removal groups", async () => {
|
||||
render(
|
||||
<StateProvider>
|
||||
<ToastProvider>
|
||||
<TaskItemWithContextMenu
|
||||
task={task({ workflowId: WORKFLOW_ID, workflowStepId: STEP_ID })}
|
||||
selectedTaskIds={new Set(["task-1", "task-2"])}
|
||||
workflows={[
|
||||
{ id: WORKFLOW_ID, name: "Workflow 1" },
|
||||
{ id: "workflow-2", name: "Workflow 2" },
|
||||
]}
|
||||
stepsByWorkflowId={{
|
||||
[WORKFLOW_ID]: [
|
||||
{ id: STEP_ID, title: "Step 1" },
|
||||
{ id: "step-2", title: "Step 2" },
|
||||
],
|
||||
}}
|
||||
onBulkPin={vi.fn()}
|
||||
onBulkArchive={vi.fn()}
|
||||
onBulkDelete={vi.fn()}
|
||||
onBulkMove={vi.fn()}
|
||||
>
|
||||
<ArchiveAwareRow />
|
||||
</TaskItemWithContextMenu>
|
||||
</ToastProvider>
|
||||
</StateProvider>,
|
||||
);
|
||||
|
||||
fireEvent.contextMenu(screen.getByTestId("task-row"));
|
||||
const menu = await screen.findByRole("menu");
|
||||
const labels = within(menu)
|
||||
.getAllByRole("menuitem")
|
||||
.map((item) => item.textContent?.replace(/\s+/g, " ").trim());
|
||||
|
||||
expect(labels).toEqual([
|
||||
"Pin 2 tasks",
|
||||
"Move to",
|
||||
"Send to workflow",
|
||||
"Archive 2 tasks",
|
||||
"Delete 2 tasks",
|
||||
]);
|
||||
|
||||
const directChildren = Array.from(menu.children);
|
||||
const separators = directChildren.filter(
|
||||
(child) => child.getAttribute("data-slot") === SEPARATOR_SLOT,
|
||||
);
|
||||
expect(separators).toHaveLength(2);
|
||||
expect(directChildren[0]?.getAttribute("data-slot")).not.toBe(SEPARATOR_SLOT);
|
||||
expect(directChildren.at(-1)?.getAttribute("data-slot")).not.toBe(SEPARATOR_SLOT);
|
||||
});
|
||||
});
|
||||
|
||||
function renderWorkflowMoveMenu({
|
||||
selectedTaskIds,
|
||||
onMoveToStep,
|
||||
@@ -172,9 +313,6 @@ function renderWorkflowMoveMenu({
|
||||
return { bulkMove };
|
||||
}
|
||||
|
||||
// Open the "Move to" submenu and hover the target step so its nested content
|
||||
// is revealed. On fine pointers that content is the inline options form; on
|
||||
// coarse/touch pointers it is the "Move with options" item that opens a Drawer.
|
||||
async function openMoveSubmenu() {
|
||||
fireEvent.contextMenu(screen.getByTestId("task-row"));
|
||||
await screen.findByTestId(MOVE_TO_TEST_ID);
|
||||
@@ -353,8 +491,6 @@ describe("TaskItemWithContextMenu — single-task move options", () => {
|
||||
fireEvent.pointerMove(await screen.findByTestId(STEP_2_TEST_ID), {
|
||||
pointerType: "mouse",
|
||||
});
|
||||
// Fine pointers reveal the options form inline in the step's submenu, not a
|
||||
// separate "Move with options" item that opens a dialog.
|
||||
expect(await screen.findByTestId("workflow-move-skip-step-prompt")).toBeTruthy();
|
||||
expect(screen.getByTestId(INSTRUCTIONS_TEST_ID)).toBeTruthy();
|
||||
expect(screen.getByTestId("workflow-move-submit")).toBeTruthy();
|
||||
|
||||
@@ -1,52 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { cloneElement, isValidElement, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from "@kandev/ui/context-menu";
|
||||
import {
|
||||
IconCopy,
|
||||
IconEdit,
|
||||
IconPencil,
|
||||
IconPin,
|
||||
IconPinFilled,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@kandev/ui/context-menu";
|
||||
import {
|
||||
TaskMoveContextMenuItems,
|
||||
TaskMoveOptionsSurface,
|
||||
type TaskMoveWorkflow,
|
||||
useTaskMoveOptions,
|
||||
} from "@/components/task/task-move-context-menu";
|
||||
import { TaskNestContextMenuItems } from "@/components/task/task-nest-context-menu";
|
||||
import { useTaskWorkflowMove } from "@/hooks/use-task-workflow-move";
|
||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||
import { useTouchDrawer } from "@/hooks/use-compact-task-chrome";
|
||||
import { isWorkflowMoveOptionsTarget } from "@/components/task/workflow-move-surface";
|
||||
import type { WorkflowMoveEntryOptions } from "@/lib/api/domains/kanban-api";
|
||||
import { useUpdateTaskPriority } from "@/hooks/use-update-task-priority";
|
||||
import { TaskColorMenu } from "./task-switcher-color-menu";
|
||||
import {
|
||||
TaskPluginLinkMenu,
|
||||
selectTaskLinkActions,
|
||||
type TaskLinkHandlers,
|
||||
} from "./task-switcher-link-menu";
|
||||
import {
|
||||
TaskArchiveItem,
|
||||
TaskCreateSubtaskItem,
|
||||
TaskDeleteItem,
|
||||
TaskDetachItem,
|
||||
} from "./task-switcher-action-items";
|
||||
import type { TaskLinkHandlers } from "./task-switcher-link-menu";
|
||||
import type { StepDef, TaskSwitcherItem } from "./task-switcher-types";
|
||||
import { TaskPluginPrimaryMenuItems } from "./task-switcher-plugin-menu-items";
|
||||
import { useTaskSwitcherArchiveConfirmation } from "./task-switcher-archive-confirmation";
|
||||
import { TaskPriorityContextMenu } from "./task-priority-context-menu";
|
||||
import {
|
||||
BulkSelectionMenuItems,
|
||||
SingleSelectionMenuItems,
|
||||
} from "./task-switcher-context-menu-items";
|
||||
import { useMenuTouchDragCancel } from "./task-switcher-touch-drag-cancel";
|
||||
|
||||
export type { StepDef } from "./task-switcher-types";
|
||||
export { createTaskLinkSelectAction } from "./task-switcher-link-menu";
|
||||
|
||||
@@ -158,10 +132,10 @@ export function TaskItemWithContextMenu(props: ContextMenuProps) {
|
||||
// The menu renders in a portal whose fiber ancestors include the
|
||||
// dnd-kit drag handle that wraps the row. React synthetic events
|
||||
// bubble through the fiber tree, not the DOM, so without these guards
|
||||
// a mousedown/pointerdown/touchstart on any menu item (e.g. the Color
|
||||
// submenu trigger or a swatch) reaches the handle's sensor listeners
|
||||
// and starts a row drag, and a click activates the row. Bubble-phase
|
||||
// guards run after the item's own handlers, so menu actions still work.
|
||||
// a mousedown/pointerdown/touchstart on any menu item reaches the
|
||||
// handle's sensor listeners and starts a row drag, and a click
|
||||
// activates the row. Bubble-phase guards run after the item's own
|
||||
// handlers, so menu actions still work.
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onTouchStart={(event) => event.stopPropagation()}
|
||||
@@ -180,7 +154,7 @@ export function TaskItemWithContextMenu(props: ContextMenuProps) {
|
||||
);
|
||||
}
|
||||
|
||||
type TaskContextMenuItemsProps = Omit<
|
||||
export type TaskContextMenuItemsProps = Omit<
|
||||
ContextMenuProps,
|
||||
"children" | "onBeforeMoveOptionsOpen" | "onRequestMoveOptions"
|
||||
> & {
|
||||
@@ -225,15 +199,14 @@ function buildTaskContextMenuItemsProps(
|
||||
|
||||
function TaskContextMenuItems(props: TaskContextMenuItemsProps) {
|
||||
const { task, selectedTaskIds } = props;
|
||||
// Right-clicking any row that's part of the active selection acts on the whole
|
||||
// selection (even a one-row selection, so the action clears it); right-clicking
|
||||
// a non-selected row acts on just that task and leaves the selection intact.
|
||||
// Right-clicking any row that's part of the active selection acts on the
|
||||
// whole selection, even a one-row selection. Right-clicking a non-selected
|
||||
// row acts on just that task and leaves the selection intact.
|
||||
const actingOnSelection = !!selectedTaskIds?.has(task.id);
|
||||
const actingIds = actingOnSelection ? [...selectedTaskIds!] : [task.id];
|
||||
|
||||
// With several tasks selected, only actions that make sense for all of them
|
||||
// are offered (Pin / Move / Archive / Delete) — the single-task actions
|
||||
// (Rename, Color, Link, Duplicate) are hidden.
|
||||
// are offered. Single-task actions are hidden for that reduced menu.
|
||||
if (actingOnSelection && actingIds.length > 1) {
|
||||
return <BulkSelectionMenuItems {...props} actingIds={actingIds} />;
|
||||
}
|
||||
@@ -246,247 +219,6 @@ function TaskContextMenuItems(props: TaskContextMenuItemsProps) {
|
||||
);
|
||||
}
|
||||
|
||||
type TaskHeaderMenuItemsProps = Pick<
|
||||
TaskContextMenuItemsProps,
|
||||
| "task"
|
||||
| "isDeleting"
|
||||
| "isPinned"
|
||||
| "onClearSelection"
|
||||
| "onTogglePin"
|
||||
| "onEditTask"
|
||||
| "onRenameTask"
|
||||
| "onCreateSubtask"
|
||||
> & { actingOnSelection: boolean };
|
||||
|
||||
function TaskHeaderMenuItems({
|
||||
task,
|
||||
isDeleting,
|
||||
isPinned,
|
||||
actingOnSelection,
|
||||
onClearSelection,
|
||||
onTogglePin,
|
||||
onEditTask,
|
||||
onRenameTask,
|
||||
onCreateSubtask,
|
||||
}: TaskHeaderMenuItemsProps) {
|
||||
const { t } = useTranslation();
|
||||
const updateTaskPriority = useUpdateTaskPriority();
|
||||
return (
|
||||
<>
|
||||
<TaskPinItem
|
||||
taskId={task.id}
|
||||
isPinned={isPinned}
|
||||
disabled={isDeleting}
|
||||
onTogglePin={withSelectionClear(actingOnSelection, onClearSelection, onTogglePin)}
|
||||
/>
|
||||
<TaskEditItem task={task} disabled={isDeleting} onEditTask={onEditTask} />
|
||||
{!task.isArchived && (
|
||||
<TaskPriorityContextMenu
|
||||
currentPriority={task.priority}
|
||||
disabled={isDeleting}
|
||||
onSelect={(priority) => void updateTaskPriority(task.id, priority)}
|
||||
/>
|
||||
)}
|
||||
<TaskRenameItem task={task} disabled={isDeleting} onRenameTask={onRenameTask} />
|
||||
<TaskCreateSubtaskItem task={task} disabled={isDeleting} onCreateSubtask={onCreateSubtask} />
|
||||
{!task.isArchived && (
|
||||
<ContextMenuItem disabled>
|
||||
<IconCopy className="mr-2 h-4 w-4" />
|
||||
{t("settings:duplicate")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SingleSelectionMenuItems({
|
||||
task,
|
||||
workflows,
|
||||
stepsByWorkflowId,
|
||||
steps,
|
||||
onEditTask,
|
||||
onRenameTask,
|
||||
onArchiveTask,
|
||||
onCreateSubtask,
|
||||
onDeleteTask,
|
||||
onDetachTask,
|
||||
onMoveToStep,
|
||||
onMoveToStepWithOptions,
|
||||
onSubmitWithOptions,
|
||||
moveOptionsBusy,
|
||||
onTogglePin,
|
||||
isPinned,
|
||||
isDeleting,
|
||||
isArchiving,
|
||||
onBulkArchive,
|
||||
onBulkMove,
|
||||
onClearSelection,
|
||||
isMixedWorkflowSelection,
|
||||
closeMenu,
|
||||
moveTasks,
|
||||
actingIds,
|
||||
actingOnSelection,
|
||||
...linkHandlers
|
||||
}: TaskContextMenuItemsProps & { actingIds: string[]; actingOnSelection: boolean }) {
|
||||
// Acting on a lone selected row (Pin / Delete) must drop it from the selection
|
||||
// so later plain clicks navigate instead of toggling.
|
||||
const onDelete = withSelectionClear(actingOnSelection, onClearSelection, onDeleteTask);
|
||||
const onDetach = withSelectionClear(actingOnSelection, onClearSelection, onDetachTask);
|
||||
return (
|
||||
<>
|
||||
<TaskHeaderMenuItems
|
||||
task={task}
|
||||
isDeleting={isDeleting}
|
||||
isPinned={isPinned}
|
||||
actingOnSelection={actingOnSelection}
|
||||
onClearSelection={onClearSelection}
|
||||
onTogglePin={onTogglePin}
|
||||
onEditTask={onEditTask}
|
||||
onRenameTask={onRenameTask}
|
||||
onCreateSubtask={onCreateSubtask}
|
||||
/>
|
||||
<TaskArchiveItem
|
||||
taskId={task.id}
|
||||
actingIds={actingIds}
|
||||
actingOnSelection={actingOnSelection}
|
||||
disabled={isDeleting || isArchiving}
|
||||
onArchiveTask={onArchiveTask}
|
||||
onBulkArchive={onBulkArchive}
|
||||
/>
|
||||
{!task.isArchived && (
|
||||
<TaskColorMenu
|
||||
taskId={task.id}
|
||||
disabled={isDeleting}
|
||||
automaticColorSource={task.automaticColorSource}
|
||||
/>
|
||||
)}
|
||||
<TaskNestContextMenuItems task={task} disabled={isDeleting} />
|
||||
<TaskPluginPrimaryMenuItems task={task} disabled={isDeleting} />
|
||||
<TaskPluginLinkMenu
|
||||
task={task}
|
||||
disabled={isDeleting}
|
||||
closeMenu={closeMenu}
|
||||
linkActions={selectTaskLinkActions(task, closeMenu, linkHandlers)}
|
||||
/>
|
||||
{!task.isArchived && (
|
||||
<TaskMoveItems
|
||||
task={task}
|
||||
workflows={workflows}
|
||||
stepsByWorkflowId={stepsByWorkflowId}
|
||||
steps={steps}
|
||||
isDeleting={isDeleting}
|
||||
onMoveToStep={onMoveToStep}
|
||||
onMoveToStepWithOptions={onMoveToStepWithOptions}
|
||||
onSubmitWithOptions={onSubmitWithOptions}
|
||||
moveOptionsBusy={moveOptionsBusy}
|
||||
actingIds={actingIds}
|
||||
actingOnSelection={actingOnSelection}
|
||||
onBulkMove={onBulkMove}
|
||||
isMixedWorkflowSelection={isMixedWorkflowSelection}
|
||||
closeMenu={closeMenu}
|
||||
moveTasks={moveTasks}
|
||||
/>
|
||||
)}
|
||||
<TaskDetachItem task={task} disabled={isDeleting} onDetachTask={onDetach} />
|
||||
<TaskDeleteItem taskId={task.id} isDeleting={isDeleting} onDeleteTask={onDelete} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function withSelectionClear(
|
||||
actingOnSelection: boolean,
|
||||
onClearSelection: (() => void) | undefined,
|
||||
handler: ((id: string) => void) | undefined,
|
||||
) {
|
||||
if (!actingOnSelection || !onClearSelection || !handler) return handler;
|
||||
return (id: string) => {
|
||||
onClearSelection();
|
||||
handler(id);
|
||||
};
|
||||
}
|
||||
|
||||
/** Reduced menu shown when 2+ tasks are selected — only bulk-valid actions. */
|
||||
function BulkSelectionMenuItems({
|
||||
task,
|
||||
actingIds,
|
||||
workflows,
|
||||
stepsByWorkflowId,
|
||||
steps,
|
||||
isMixedWorkflowSelection,
|
||||
pinnedTaskIds,
|
||||
onBulkPin,
|
||||
onBulkArchive,
|
||||
onBulkDelete,
|
||||
onBulkMove,
|
||||
closeMenu,
|
||||
moveTasks,
|
||||
}: {
|
||||
task: TaskSwitcherItem;
|
||||
actingIds: string[];
|
||||
workflows?: TaskMoveWorkflow[];
|
||||
stepsByWorkflowId?: Record<string, StepDef[]>;
|
||||
steps?: StepDef[];
|
||||
isMixedWorkflowSelection?: boolean;
|
||||
pinnedTaskIds?: string[];
|
||||
onBulkPin?: (taskIds: string[]) => void;
|
||||
onBulkArchive?: (taskIds: string[]) => void;
|
||||
onBulkDelete?: (taskIds: string[]) => void;
|
||||
onBulkMove?: (taskIds: string[], targetWorkflowId: string, targetStepId: string) => void;
|
||||
closeMenu: () => void;
|
||||
moveTasks: ReturnType<typeof useTaskWorkflowMove>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const n = actingIds.length;
|
||||
const allPinned =
|
||||
actingIds.length > 0 && actingIds.every((id) => pinnedTaskIds?.includes(id) ?? false);
|
||||
const pinLabel = allPinned
|
||||
? t("task:unpinTasksCount", { count: n })
|
||||
: t("task:pinTasksCount", { count: n });
|
||||
return (
|
||||
<>
|
||||
{onBulkPin && (
|
||||
<ContextMenuItem onSelect={() => onBulkPin(actingIds)}>
|
||||
{allPinned ? (
|
||||
<IconPinFilled className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<IconPin className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{pinLabel}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<TaskArchiveItem
|
||||
taskId={task.id}
|
||||
actingIds={actingIds}
|
||||
actingOnSelection
|
||||
onArchiveTask={undefined}
|
||||
onBulkArchive={onBulkArchive}
|
||||
/>
|
||||
<TaskMoveItems
|
||||
task={task}
|
||||
workflows={workflows}
|
||||
stepsByWorkflowId={stepsByWorkflowId}
|
||||
steps={steps}
|
||||
onMoveToStep={undefined}
|
||||
actingIds={actingIds}
|
||||
actingOnSelection
|
||||
onBulkMove={onBulkMove}
|
||||
isMixedWorkflowSelection={isMixedWorkflowSelection}
|
||||
closeMenu={closeMenu}
|
||||
moveTasks={moveTasks}
|
||||
/>
|
||||
{onBulkDelete && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem variant="destructive" onSelect={() => onBulkDelete(actingIds)}>
|
||||
<IconTrash className="mr-2 h-4 w-4" />
|
||||
{t("task:deleteTasksCount", { count: n })}
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function cloneWithMenuOpen(
|
||||
children: React.ReactElement<{ menuOpen?: boolean; archiveConfirmation?: ReactNode }>,
|
||||
menuOpen: boolean,
|
||||
@@ -495,155 +227,3 @@ function cloneWithMenuOpen(
|
||||
if (isValidElement(children)) return cloneElement(children, { menuOpen, archiveConfirmation });
|
||||
return children;
|
||||
}
|
||||
|
||||
function TaskPinItem({
|
||||
taskId,
|
||||
isPinned,
|
||||
disabled,
|
||||
onTogglePin,
|
||||
}: {
|
||||
taskId: string;
|
||||
isPinned?: boolean;
|
||||
disabled?: boolean;
|
||||
onTogglePin?: (taskId: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!onTogglePin) return null;
|
||||
return (
|
||||
<ContextMenuItem disabled={disabled} onSelect={() => onTogglePin(taskId)}>
|
||||
{isPinned ? <IconPinFilled className="mr-2 h-4 w-4" /> : <IconPin className="mr-2 h-4 w-4" />}
|
||||
{isPinned ? t("task:unpin") : t("task:annotationPin")}
|
||||
</ContextMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskRenameItem({
|
||||
task,
|
||||
disabled,
|
||||
onRenameTask,
|
||||
}: {
|
||||
task: TaskSwitcherItem;
|
||||
disabled?: boolean;
|
||||
onRenameTask?: (taskId: string, currentTitle: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!onRenameTask) return null;
|
||||
return (
|
||||
<ContextMenuItem disabled={disabled} onSelect={() => onRenameTask(task.id, task.title)}>
|
||||
<IconPencil className="mr-2 h-4 w-4" />
|
||||
{t("task:rename")}
|
||||
</ContextMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskEditItem({
|
||||
task,
|
||||
disabled,
|
||||
onEditTask,
|
||||
}: {
|
||||
task: TaskSwitcherItem;
|
||||
disabled?: boolean;
|
||||
onEditTask?: (task: TaskSwitcherItem) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!onEditTask || task.isArchived || !task.workflowId || !task.workflowStepId) return null;
|
||||
return (
|
||||
<ContextMenuItem disabled={disabled} onSelect={() => onEditTask(task)}>
|
||||
<IconEdit className="mr-2 h-4 w-4" />
|
||||
{t("common:edit")}
|
||||
</ContextMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskMoveItems({
|
||||
task,
|
||||
workflows,
|
||||
stepsByWorkflowId,
|
||||
steps,
|
||||
isDeleting,
|
||||
onMoveToStep,
|
||||
onMoveToStepWithOptions,
|
||||
onSubmitWithOptions,
|
||||
moveOptionsBusy,
|
||||
actingIds,
|
||||
actingOnSelection,
|
||||
onBulkMove,
|
||||
isMixedWorkflowSelection,
|
||||
closeMenu,
|
||||
moveTasks,
|
||||
}: Omit<TaskContextMenuItemsProps, "onRenameTask" | "onArchiveTask" | "onDeleteTask"> & {
|
||||
actingIds: string[];
|
||||
actingOnSelection: boolean;
|
||||
}) {
|
||||
if (!task.workflowId) return null;
|
||||
const workflowId = task.workflowId;
|
||||
// Moving a selection routes through the sidebar hook's bulkMove, which clears
|
||||
// the selection afterwards. Fall back to a raw move when no bulk handler is
|
||||
// wired (e.g. the kanban-less callers that don't manage a selection).
|
||||
const runSelectionMove = (
|
||||
targetWorkflowId: string,
|
||||
stepId: string,
|
||||
destination: "step" | "workflow",
|
||||
) => {
|
||||
closeMenu();
|
||||
if (onBulkMove) {
|
||||
onBulkMove(actingIds, targetWorkflowId, stepId);
|
||||
return;
|
||||
}
|
||||
void moveTasks(actingIds, targetWorkflowId, stepId, destination).catch(() => {
|
||||
// useTaskWorkflowMove already shows the failure toast.
|
||||
});
|
||||
};
|
||||
|
||||
// Single-task right-click keeps the optimistic same-workflow move. A selection
|
||||
// spanning workflows makes "Move to step" of one workflow ambiguous, so disable
|
||||
// it there (Send to workflow remains the explicit path).
|
||||
let moveToStep: ((stepId: string) => void) | undefined;
|
||||
if (actingOnSelection) {
|
||||
moveToStep = isMixedWorkflowSelection
|
||||
? undefined
|
||||
: (stepId) => runSelectionMove(workflowId, stepId, "step");
|
||||
} else {
|
||||
moveToStep = (stepId) => {
|
||||
closeMenu();
|
||||
if (onMoveToStep) {
|
||||
onMoveToStep(task.id, workflowId, stepId);
|
||||
return;
|
||||
}
|
||||
void moveTasks([task.id], workflowId, stepId, "step").catch(() => {
|
||||
// useTaskWorkflowMove already shows the failure toast.
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<TaskMoveContextMenuItems
|
||||
currentWorkflowId={workflowId}
|
||||
// For a selection spanning several steps, don't disable the clicked row's
|
||||
// step — the backend bulk move skips tasks already there, and the other
|
||||
// selected rows still need it as a target.
|
||||
currentStepId={actingOnSelection ? undefined : task.workflowStepId}
|
||||
workflows={workflows ?? []}
|
||||
stepsByWorkflowId={stepsByWorkflowId ?? (steps ? { [workflowId]: steps } : {})}
|
||||
disabled={isDeleting || task.isArchived}
|
||||
onMoveToStep={moveToStep}
|
||||
onMoveToStepWithOptions={
|
||||
actingIds.length === 1 && onMoveToStepWithOptions ? onMoveToStepWithOptions : undefined
|
||||
}
|
||||
onSubmitWithOptions={
|
||||
actingIds.length === 1 && onSubmitWithOptions ? onSubmitWithOptions : undefined
|
||||
}
|
||||
isMoving={moveOptionsBusy}
|
||||
onSendToWorkflow={(targetWorkflowId, stepId) => {
|
||||
if (actingOnSelection) {
|
||||
runSelectionMove(targetWorkflowId, stepId, "workflow");
|
||||
return;
|
||||
}
|
||||
closeMenu();
|
||||
void moveTasks([task.id], targetWorkflowId, stepId, "workflow").catch(() => {
|
||||
// useTaskWorkflowMove already shows the failure toast.
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { ContextMenuItem, ContextMenuSub, ContextMenuSubTrigger } from "@kandev/ui/context-menu";
|
||||
import { TaskContextMenuSubContent as ContextMenuSubContent } from "./task-context-menu-sub-content";
|
||||
import { resolvePluginIcon } from "@/lib/plugins/icons";
|
||||
import type { PluginIcon } from "@/lib/plugins/types";
|
||||
import type { PluginLinkMenuAction } from "./task-session-sidebar-link-actions";
|
||||
import { useTaskPluginLinkActions } from "./task-session-sidebar-link-actions";
|
||||
import type { TaskSwitcherItem } from "./task-switcher-types";
|
||||
|
||||
@@ -99,6 +99,40 @@ export function TaskPluginLinkMenu({
|
||||
disabled,
|
||||
closeMenu,
|
||||
linkActions,
|
||||
pluginLinkActions: suppliedPluginLinkActions,
|
||||
}: {
|
||||
task?: TaskSwitcherItem;
|
||||
disabled?: boolean;
|
||||
closeMenu: () => void;
|
||||
linkActions: Partial<ReturnType<typeof selectTaskLinkActions>>;
|
||||
pluginLinkActions?: PluginLinkMenuAction[];
|
||||
}) {
|
||||
if (suppliedPluginLinkActions) {
|
||||
return (
|
||||
<TaskLinkMenuWithActions
|
||||
disabled={disabled}
|
||||
closeMenu={closeMenu}
|
||||
linkActions={linkActions}
|
||||
pluginLinkActions={suppliedPluginLinkActions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (!task) return null;
|
||||
return (
|
||||
<TaskLinkMenuWithTask
|
||||
task={task}
|
||||
disabled={disabled}
|
||||
closeMenu={closeMenu}
|
||||
linkActions={linkActions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskLinkMenuWithTask({
|
||||
task,
|
||||
disabled,
|
||||
closeMenu,
|
||||
linkActions,
|
||||
}: {
|
||||
task: TaskSwitcherItem;
|
||||
disabled?: boolean;
|
||||
@@ -106,6 +140,27 @@ export function TaskPluginLinkMenu({
|
||||
linkActions: Partial<ReturnType<typeof selectTaskLinkActions>>;
|
||||
}) {
|
||||
const pluginLinkActions = useTaskPluginLinkActions(task.id, task.repositoryLinks ?? []);
|
||||
return (
|
||||
<TaskLinkMenuWithActions
|
||||
disabled={disabled}
|
||||
closeMenu={closeMenu}
|
||||
linkActions={linkActions}
|
||||
pluginLinkActions={pluginLinkActions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskLinkMenuWithActions({
|
||||
disabled,
|
||||
closeMenu,
|
||||
linkActions,
|
||||
pluginLinkActions,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
closeMenu: () => void;
|
||||
linkActions: Partial<ReturnType<typeof selectTaskLinkActions>>;
|
||||
pluginLinkActions: PluginLinkMenuAction[];
|
||||
}) {
|
||||
return (
|
||||
<TaskLinkMenu
|
||||
disabled={disabled}
|
||||
@@ -138,7 +193,7 @@ function TaskLinkMenu({
|
||||
onLinkJiraTicket?: () => void;
|
||||
onLinkLinearIssue?: () => void;
|
||||
onLinkSentryIssue?: () => void;
|
||||
pluginLinkActions?: { id: string; label: string; icon?: PluginIcon; onSelect: () => void }[];
|
||||
pluginLinkActions?: PluginLinkMenuAction[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { KanbanCardContextMenuItems } from "@/components/kanban-card-menu-items";
|
||||
import {
|
||||
KanbanCardContextMenuItems,
|
||||
type KanbanCardMenuEntry,
|
||||
} from "@/components/kanban-card-menu-items";
|
||||
import { buildPrimaryPluginEntries } from "@/components/plugins/task-menu-actions";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||
@@ -15,10 +18,18 @@ export function TaskPluginPrimaryMenuItems({
|
||||
task: TaskSwitcherItem;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const entries = useTaskPluginPrimaryMenuEntries(task, disabled);
|
||||
return <KanbanCardContextMenuItems entries={entries} />;
|
||||
}
|
||||
|
||||
export function useTaskPluginPrimaryMenuEntries(
|
||||
task: TaskSwitcherItem,
|
||||
disabled?: boolean,
|
||||
): KanbanCardMenuEntry[] {
|
||||
usePluginRegistry();
|
||||
const workspaceId = useAppStore((state) => state.workspaces.activeId);
|
||||
const { isMobile } = useResponsiveBreakpoint();
|
||||
if (!workspaceId) return null;
|
||||
if (!workspaceId) return [];
|
||||
|
||||
const context: PluginTaskMenuContext = {
|
||||
workspaceId,
|
||||
@@ -27,5 +38,5 @@ export function TaskPluginPrimaryMenuItems({
|
||||
workflowStepId: task.workflowStepId ?? null,
|
||||
presentation: isMobile ? "mobile" : "desktop",
|
||||
};
|
||||
return <KanbanCardContextMenuItems entries={buildPrimaryPluginEntries({ disabled, context })} />;
|
||||
return buildPrimaryPluginEntries({ disabled, context });
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ test.describe("Kanban card actions menu — delete/archive does not navigate", (
|
||||
const task = await apiClient.createTask(seedData.workspaceId, "Card Menu Delete Task", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
});
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
@@ -21,6 +22,13 @@ test.describe("Kanban card actions menu — delete/archive does not navigate", (
|
||||
|
||||
await kanban.openTaskActionsMenu(task.id);
|
||||
|
||||
const menu = testPage.locator('[data-slot="dropdown-menu-content"][data-state="open"]').last();
|
||||
const menuLabels = (await menu.locator(":scope > [role='menuitem']").allTextContents()).map(
|
||||
(text) => text.replace(/\s+/g, " ").trim(),
|
||||
);
|
||||
expect(menuLabels).toEqual(["Priority", "Edit", "Link", "Move to", "Archive", "Delete"]);
|
||||
await expect(menu.locator(":scope > [data-slot='dropdown-menu-separator']")).toHaveCount(4);
|
||||
|
||||
const deleteItem = testPage.getByRole("menuitem", { name: "Delete" });
|
||||
await expect(deleteItem).toBeVisible();
|
||||
await deleteItem.click();
|
||||
|
||||
@@ -91,6 +91,18 @@ test.describe("Mobile kanban — task priority", () => {
|
||||
where: (payload) => payload.task_id === task.id && payload.priority === "low",
|
||||
});
|
||||
await kanban.openTaskActionsMenu(task.id);
|
||||
const menu = testPage.locator('[data-slot="dropdown-menu-content"]:visible').last();
|
||||
const menuLabels = (await menu.locator(":scope > [role='menuitem']").allTextContents()).map(
|
||||
(text) => text.replace(/\s+/g, " ").trim(),
|
||||
);
|
||||
expect(menuLabels).toEqual(["Priority", "Edit", "Link", "Move to", "Archive", "Delete"]);
|
||||
await expect(menu.locator(":scope > [data-slot='dropdown-menu-separator']")).toHaveCount(4);
|
||||
const deleteItem = menu.getByRole("menuitem", { name: "Delete", exact: true });
|
||||
await deleteItem.scrollIntoViewIfNeeded();
|
||||
const deleteBox = await deleteItem.boundingBox();
|
||||
expect(deleteBox).not.toBeNull();
|
||||
expect(Math.round(deleteBox!.height)).toBeGreaterThanOrEqual(44);
|
||||
await expect(deleteItem).toBeInViewport();
|
||||
await expect(kanban.contextPriority()).toBeVisible();
|
||||
await kanban.openPrioritySubmenu();
|
||||
await expect(kanban.contextPriorityCurrent("critical")).toBeVisible();
|
||||
|
||||
@@ -33,6 +33,13 @@ test.describe("Task detail top bar actions menu", () => {
|
||||
await expect(trigger).toHaveAttribute("aria-label", "More options");
|
||||
await trigger.click();
|
||||
|
||||
const menu = testPage.locator('[data-slot="dropdown-menu-content"][data-state="open"]').last();
|
||||
const menuLabels = (await menu.locator(":scope > [role='menuitem']").allTextContents()).map(
|
||||
(text) => text.replace(/\s+/g, " ").trim(),
|
||||
);
|
||||
expect(menuLabels).toEqual(["Priority", "Edit", "Link", "Move to", "Archive", "Delete"]);
|
||||
await expect(menu.locator(":scope > [data-slot='dropdown-menu-separator']")).toHaveCount(4);
|
||||
|
||||
await expect(testPage.getByRole("menuitem", { name: "Edit" })).toBeVisible();
|
||||
await expect(testPage.getByRole("menuitem", { name: "Delete" })).toBeVisible();
|
||||
const archiveItem = testPage.getByRole("menuitem", { name: "Archive" });
|
||||
|
||||
@@ -33,6 +33,13 @@ test.describe("Task preview panel actions menu", () => {
|
||||
await expect(trigger).toHaveAttribute("aria-label", "More options");
|
||||
await trigger.click();
|
||||
|
||||
const menu = testPage.locator('[data-slot="dropdown-menu-content"][data-state="open"]').last();
|
||||
const menuLabels = (await menu.locator(":scope > [role='menuitem']").allTextContents()).map(
|
||||
(text) => text.replace(/\s+/g, " ").trim(),
|
||||
);
|
||||
expect(menuLabels).toEqual(["Priority", "Edit", "Link", "Move to", "Archive", "Delete"]);
|
||||
await expect(menu.locator(":scope > [data-slot='dropdown-menu-separator']")).toHaveCount(4);
|
||||
|
||||
const archiveItem = testPage.getByRole("menuitem", { name: "Archive" });
|
||||
await expect(archiveItem).toBeVisible();
|
||||
await archiveItem.click();
|
||||
|
||||
@@ -485,6 +485,7 @@ test.describe("Mobile sidebar task actions", () => {
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
prCapture,
|
||||
}) => {
|
||||
const taskTitle = "Mobile task with diff stats";
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
@@ -547,6 +548,9 @@ test.describe("Mobile sidebar task actions", () => {
|
||||
.map((animation) => animation.finished.catch(() => undefined)),
|
||||
),
|
||||
);
|
||||
await prCapture.screenshot("mobile-task-row-menu-grouping", {
|
||||
caption: "Phone task-row actions grouped inside the scrolling menu",
|
||||
});
|
||||
const [menuBox, itemBox] = await Promise.all([menu.boundingBox(), archiveItem.boundingBox()]);
|
||||
const viewport = testPage.viewportSize();
|
||||
if (!menuBox || !itemBox || !viewport) throw new Error("mobile action sheet has no layout box");
|
||||
@@ -564,6 +568,37 @@ test.describe("Mobile sidebar task actions", () => {
|
||||
scrollHeight: element.scrollHeight,
|
||||
}));
|
||||
expect(menuOverflow.scrollHeight).toBeGreaterThan(menuOverflow.clientHeight);
|
||||
const menuLabels = (await menu.locator(":scope > [role='menuitem']").allTextContents()).map(
|
||||
(text) => text.replace(/\s+/g, " ").trim(),
|
||||
);
|
||||
const menuIndex = (label: string) => menuLabels.indexOf(label);
|
||||
for (const label of [
|
||||
"Pin",
|
||||
"Color",
|
||||
"Priority",
|
||||
"Edit",
|
||||
"Rename",
|
||||
"Duplicate",
|
||||
"Create Subtask",
|
||||
"Nest under",
|
||||
"Link",
|
||||
"Move to",
|
||||
"Archive",
|
||||
"Delete",
|
||||
]) {
|
||||
expect(menuIndex(label), `${label} should be in the task action menu`).toBeGreaterThanOrEqual(
|
||||
0,
|
||||
);
|
||||
}
|
||||
expect(menuIndex("Pin")).toBeLessThan(menuIndex("Color"));
|
||||
expect(menuIndex("Color")).toBeLessThan(menuIndex("Priority"));
|
||||
expect(menuIndex("Priority")).toBeLessThan(menuIndex("Edit"));
|
||||
expect(menuIndex("Edit")).toBeLessThan(menuIndex("Create Subtask"));
|
||||
expect(menuIndex("Create Subtask")).toBeLessThan(menuIndex("Link"));
|
||||
expect(menuIndex("Link")).toBeLessThan(menuIndex("Move to"));
|
||||
expect(menuIndex("Move to")).toBeLessThan(menuIndex("Archive"));
|
||||
expect(menuIndex("Archive")).toBeLessThan(menuIndex("Delete"));
|
||||
await expect(menu.locator(":scope > [data-slot='context-menu-separator']")).toHaveCount(4);
|
||||
for (const actionName of [
|
||||
"Pin",
|
||||
"Edit",
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
test.describe("Desktop task-row menu grouping", () => {
|
||||
test("orders the groups and persists a priority choice", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
prCapture,
|
||||
}) => {
|
||||
const sameWorkflowStep =
|
||||
seedData.steps.find((step) => step.id !== seedData.startStepId) ??
|
||||
(await apiClient.createWorkflowStep(seedData.workflowId, "Menu grouping second step", 1)).id;
|
||||
const targetWorkflow = await apiClient.createWorkflow(
|
||||
seedData.workspaceId,
|
||||
"Menu grouping target workflow",
|
||||
);
|
||||
const targetStep = await apiClient.createWorkflowStep(targetWorkflow.id, "Incoming", 0);
|
||||
const task = await apiClient.createTask(seedData.workspaceId, "Menu grouping root task", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
priority: "high",
|
||||
});
|
||||
|
||||
await testPage.goto(`/t/${task.id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
|
||||
const row = session.sidebarTaskItem(task.title).first();
|
||||
await expect(row).toBeVisible();
|
||||
await row.click({ button: "right" });
|
||||
|
||||
const menu = testPage.locator('[data-slot="context-menu-content"][data-state="open"]').last();
|
||||
await expect(menu).toBeVisible();
|
||||
const labels = (await menu.locator(":scope > [role='menuitem']").allTextContents()).map(
|
||||
(text) => text.replace(/\s+/g, " ").trim(),
|
||||
);
|
||||
expect(labels).toEqual([
|
||||
"Pin",
|
||||
"Color",
|
||||
"Priority",
|
||||
"Edit",
|
||||
"Rename",
|
||||
"Duplicate",
|
||||
"Create Subtask",
|
||||
"Nest under",
|
||||
"Link",
|
||||
"Move to",
|
||||
"Send to workflow",
|
||||
"Archive",
|
||||
"Delete",
|
||||
]);
|
||||
|
||||
const separators = menu.locator(":scope > [data-slot='context-menu-separator']");
|
||||
await expect(separators).toHaveCount(4);
|
||||
await expect(menu.getByTestId("task-context-send-to-workflow")).toBeVisible();
|
||||
await prCapture.screenshot("desktop-task-row-menu-grouping", {
|
||||
caption: "Desktop task-row actions grouped by purpose",
|
||||
});
|
||||
|
||||
await menu.getByTestId("task-context-priority").hover();
|
||||
const lowOption = testPage.getByTestId("task-context-priority-low");
|
||||
await expect(lowOption).toBeVisible();
|
||||
await lowOption.click();
|
||||
await expect.poll(async () => (await apiClient.getTask(task.id)).priority).toBe("low");
|
||||
|
||||
// Keep the move fixtures live in the test so the grouping assertion proves
|
||||
// both movement entries are admitted, not just the same-workflow branch.
|
||||
expect(sameWorkflowStep).toBeTruthy();
|
||||
expect(targetStep.id).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import type { WorkflowStep } from "@/components/kanban-card";
|
||||
import type { TaskMoveStep, TaskMoveWorkflow } from "@/components/task/task-move-context-menu";
|
||||
import { sortWorkflowStepsByPosition } from "@/lib/kanban/workflow-step-order";
|
||||
import type { WorkflowSnapshotData } from "@/lib/state/slices/kanban/types";
|
||||
|
||||
export type KanbanCardMoveTargets = {
|
||||
currentWorkflowId: string | null;
|
||||
workflowItems: TaskMoveWorkflow[];
|
||||
stepsByWorkflowId: Record<string, TaskMoveStep[]>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The card's hot render path: a caller with column context already passes a
|
||||
* `steps` list pre-filtered for this workflow, and its `currentWorkflowId`
|
||||
* always resolves from `kanbanMulti.snapshots`. Kept to exactly these two
|
||||
* subscriptions (system design: no `kanban.tasks`/`hiddenWorkflowStepIds`
|
||||
* reads here) so unrelated writes to either slice never re-render every
|
||||
* visible card. Callers without column context (preview panel, detail top
|
||||
* bar) use `useTaskActionsMenuMoveTargets` instead, which layers the
|
||||
* flat-list and hidden-step fallbacks this hook intentionally omits.
|
||||
*/
|
||||
export function useKanbanCardMoveTargets(
|
||||
taskId: string,
|
||||
steps?: WorkflowStep[],
|
||||
): KanbanCardMoveTargets {
|
||||
const workflows = useAppStore((state) => state.workflows.items);
|
||||
const currentWorkflowId = useAppStore((state) => {
|
||||
for (const [workflowId, snapshot] of Object.entries(state.kanbanMulti.snapshots)) {
|
||||
if (snapshot.tasks.some((task) => task.id === taskId)) return workflowId;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const snapshotStepsByWorkflowId = useAppStore(
|
||||
useShallow((state): Record<string, WorkflowSnapshotData["steps"]> => {
|
||||
const result: Record<string, WorkflowSnapshotData["steps"]> = {};
|
||||
for (const [workflowId, snapshot] of Object.entries(state.kanbanMulti.snapshots)) {
|
||||
result[workflowId] = snapshot.steps;
|
||||
}
|
||||
return result;
|
||||
}),
|
||||
);
|
||||
|
||||
const workflowItems = useMemo<TaskMoveWorkflow[]>(() => {
|
||||
const current = workflows.find((workflow) => workflow.id === currentWorkflowId);
|
||||
return workflows
|
||||
.filter((workflow) => workflow.workspaceId === current?.workspaceId && !workflow.hidden)
|
||||
.map((workflow) => ({ id: workflow.id, name: workflow.name, hidden: workflow.hidden }));
|
||||
}, [workflows, currentWorkflowId]);
|
||||
|
||||
const stepsByWorkflowId = useMemo<Record<string, TaskMoveStep[]>>(() => {
|
||||
const result: Record<string, TaskMoveStep[]> = {};
|
||||
for (const [workflowId, snapshotSteps] of Object.entries(snapshotStepsByWorkflowId)) {
|
||||
result[workflowId] = sortWorkflowStepsByPosition(snapshotSteps).map((step) => ({
|
||||
id: step.id,
|
||||
title: step.title,
|
||||
color: step.color,
|
||||
events: step.events,
|
||||
}));
|
||||
}
|
||||
if (currentWorkflowId && steps) {
|
||||
result[currentWorkflowId] = steps.map((step) => ({
|
||||
id: step.id,
|
||||
title: step.title,
|
||||
color: step.color,
|
||||
events: step.events,
|
||||
}));
|
||||
}
|
||||
return result;
|
||||
}, [snapshotStepsByWorkflowId, currentWorkflowId, steps]);
|
||||
|
||||
return { currentWorkflowId, workflowItems, stepsByWorkflowId };
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { buildTaskActionsMenuEntries } from "./task-actions-menu-entries";
|
||||
import type { KanbanCardMenuEntry } from "@/components/kanban-card-menu-items";
|
||||
|
||||
const PLUGIN_ID = "test-task-actions-menu-plugin";
|
||||
const DELETE_SEPARATOR = "delete-separator";
|
||||
const REMOVE_SEPARATOR = "remove-separator";
|
||||
|
||||
function itemKeys(entries: KanbanCardMenuEntry[]): string[] {
|
||||
return entries.map((entry) => entry.key);
|
||||
@@ -59,14 +59,17 @@ describe("buildTaskActionsMenuEntries — normal tier", () => {
|
||||
it("presents the full card order for a resolved, unarchived subject", () => {
|
||||
const entries = buildTaskActionsMenuEntries("normal", baseArgs);
|
||||
expect(itemKeys(entries)).toEqual([
|
||||
"edit",
|
||||
"priority",
|
||||
"edit-separator",
|
||||
"edit",
|
||||
"relationships-separator",
|
||||
"link",
|
||||
"detach",
|
||||
"move-separator",
|
||||
"move-to",
|
||||
"send-to-workflow",
|
||||
"link",
|
||||
REMOVE_SEPARATOR,
|
||||
"archive",
|
||||
"detach",
|
||||
DELETE_SEPARATOR,
|
||||
"delete",
|
||||
]);
|
||||
});
|
||||
@@ -129,7 +132,7 @@ describe("buildTaskActionsMenuEntries — archived tier", () => {
|
||||
const entries = buildTaskActionsMenuEntries("archived", baseArgs);
|
||||
expect(itemKeys(entries)).toEqual([
|
||||
`plugin-primary-${PLUGIN_ID}-primary-action`,
|
||||
DELETE_SEPARATOR,
|
||||
REMOVE_SEPARATOR,
|
||||
"delete",
|
||||
]);
|
||||
});
|
||||
@@ -148,7 +151,7 @@ describe("buildTaskActionsMenuEntries — unresolved board row tier", () => {
|
||||
|
||||
it("presents only Archive and Delete when no plugin action is admitted", () => {
|
||||
const entries = buildTaskActionsMenuEntries("unresolved-row", baseArgs);
|
||||
expect(itemKeys(entries)).toEqual(["archive", DELETE_SEPARATOR, "delete"]);
|
||||
expect(itemKeys(entries)).toEqual(["archive", "delete"]);
|
||||
});
|
||||
|
||||
it("orders admitted plugin primary actions before Archive", () => {
|
||||
@@ -162,8 +165,8 @@ describe("buildTaskActionsMenuEntries — unresolved board row tier", () => {
|
||||
const entries = buildTaskActionsMenuEntries("unresolved-row", baseArgs);
|
||||
expect(itemKeys(entries)).toEqual([
|
||||
`plugin-primary-${PLUGIN_ID}-primary-action`,
|
||||
REMOVE_SEPARATOR,
|
||||
"archive",
|
||||
DELETE_SEPARATOR,
|
||||
"delete",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
buildArchiveEntry,
|
||||
buildGroupedMenuEntries,
|
||||
buildDeleteEntry,
|
||||
buildKanbanCardMenuEntries,
|
||||
resolvePluginMenuContext,
|
||||
@@ -37,12 +38,13 @@ function buildArchivedTaskActionsMenuEntries({
|
||||
disabled: isProcessing,
|
||||
context: resolvePluginMenuContext(pluginMenuContext),
|
||||
});
|
||||
const entries: KanbanCardMenuEntry[] = [...pluginEntries];
|
||||
if (pluginEntries.length > 0) {
|
||||
entries.push({ kind: "separator", key: "delete-separator" });
|
||||
}
|
||||
entries.push(buildDeleteEntry({ isDeleting, isProcessing, onDelete }));
|
||||
return entries;
|
||||
return buildGroupedMenuEntries([
|
||||
{ key: "plugins", entries: pluginEntries },
|
||||
{
|
||||
key: "remove",
|
||||
entries: [buildDeleteEntry({ isDeleting, isProcessing, onDelete })],
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function buildUnresolvedRowTaskActionsMenuEntries({
|
||||
@@ -54,13 +56,20 @@ function buildUnresolvedRowTaskActionsMenuEntries({
|
||||
pluginMenuContext,
|
||||
}: BuildKanbanCardMenuEntriesArgs): KanbanCardMenuEntry[] {
|
||||
const isProcessing = Boolean(disabled || isDeleting || isArchiving);
|
||||
return [
|
||||
...buildPrimaryPluginEntries({
|
||||
disabled: isProcessing,
|
||||
context: resolvePluginMenuContext(pluginMenuContext),
|
||||
}),
|
||||
buildArchiveEntry({ isArchiving, isProcessing, onArchive }),
|
||||
{ kind: "separator", key: "delete-separator" },
|
||||
buildDeleteEntry({ isDeleting, isProcessing, onDelete }),
|
||||
];
|
||||
return buildGroupedMenuEntries([
|
||||
{
|
||||
key: "plugins",
|
||||
entries: buildPrimaryPluginEntries({
|
||||
disabled: isProcessing,
|
||||
context: resolvePluginMenuContext(pluginMenuContext),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "remove",
|
||||
entries: [
|
||||
buildArchiveEntry({ isArchiving, isProcessing, onArchive }),
|
||||
buildDeleteEntry({ isDeleting, isProcessing, onDelete }),
|
||||
],
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -342,8 +342,8 @@ export type PluginTaskMenuContext = PluginSDK.PluginTaskMenuContext;
|
||||
* Registration accepted by `PluginRegistry.registerTaskMenuAction`:
|
||||
* contributes an item to the kanban card context/dropdown menu. Group
|
||||
* "edit" nests the item inside the card's `Edit` submenu; group "primary"
|
||||
* renders it as a flat, top-level menu item, positioned between the "Move
|
||||
* to"/"Send to workflow" submenus and the "Link" submenu.
|
||||
* renders it as a flat, top-level menu item after the movement group and
|
||||
* before the `Archive`/`Delete` removal group.
|
||||
*/
|
||||
export type TaskMenuActionRegistration = PluginSDK.TaskMenuActionRegistration;
|
||||
|
||||
|
||||
@@ -899,8 +899,8 @@ interface PluginRegistry {
|
||||
registerTaskPanel(registration: TaskPanelRegistration): void;
|
||||
|
||||
// Contributes an item to the kanban card's Edit submenu (group "edit") or
|
||||
// a flat, top-level card menu item between "Move to"/"Send to workflow"
|
||||
// and "Link" (group "primary"). See "Kanban card contributions" below.
|
||||
// a flat, top-level card menu item after "Move to"/"Send to workflow"
|
||||
// and before "Archive"/"Delete" (group "primary"). See "Kanban card contributions" below.
|
||||
registerTaskMenuAction(registration: TaskMenuActionRegistration): void;
|
||||
|
||||
// Contributes a client-side filter section to the kanban board's display
|
||||
@@ -1184,8 +1184,8 @@ interface TaskMenuActionRegistration {
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
// "edit" nests the item in the card's Edit submenu; "primary" renders it
|
||||
// as a flat, top-level item between the "Move to"/"Send to workflow"
|
||||
// submenus and the "Link" submenu.
|
||||
// as a flat, top-level item after the "Move to"/"Send to workflow"
|
||||
// submenus and before the "Archive"/"Delete" items.
|
||||
group: "edit" | "primary";
|
||||
visible?(context: PluginTaskMenuContext): boolean; // default: always visible
|
||||
run(context: PluginTaskMenuContext): void | Promise<void>; // a rejection is caught and logged
|
||||
@@ -1300,10 +1300,11 @@ console, and the menu still closes either way (Radix's own close-on-select,
|
||||
independent of the async result).
|
||||
|
||||
Group `"primary"` renders each visible action as its own flat, top-level menu
|
||||
item instead of nesting it under `Edit`. It appears on cards and on the shared
|
||||
desktop/mobile task-row menu. Group `"edit"` remains card-only. Visibility
|
||||
filtering, registration order, and `run()`/error handling are identical; the
|
||||
two groups are independent lists (an action only ever belongs to one).
|
||||
item instead of nesting it under `Edit`. It appears after the movement items
|
||||
and before the Archive/Delete items on cards and on the shared desktop/mobile
|
||||
task-row menu. Group `"edit"` remains card-only. Visibility filtering,
|
||||
registration order, and `run()`/error handling are identical; the two groups
|
||||
are independent lists (an action only ever belongs to one).
|
||||
|
||||
`"task-card-indicators"` (documented above with the other slots) is the
|
||||
matching read-only surface: a small icon/badge rendered beside the PR status
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
---
|
||||
created: 2026-09-10
|
||||
status: complete
|
||||
requirements:
|
||||
- REQ-TASKS-MENU-GROUPING-001
|
||||
system_design:
|
||||
- ../../specs/tasks/system-design/task-menu-grouping.md
|
||||
legacy_specs: []
|
||||
---
|
||||
|
||||
# Implementation Plan: Task menu grouping
|
||||
|
||||
## Overview
|
||||
|
||||
Group existing task actions by purpose across task rows, cards, preview, and desktop detail menus.
|
||||
Implement task-row composition first, then the shared card builder and its consumers.
|
||||
Each work order includes focused unit and browser evidence.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- The agreed action order, thin dividers, conditional groups, and plugin placement.
|
||||
- Existing single-task, bulk, archived, and unresolved menu variants.
|
||||
- Desktop context and dropdown menus, plus existing phone touch menus.
|
||||
- Documentation that describes the previous plugin or task menu position.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- New actions, card Pin/Color parity, working Duplicate, backend changes, or new plugin groups.
|
||||
- New mobile shells, group headings, or localization keys.
|
||||
- Commits, PR creation, or implementation during this design turn.
|
||||
|
||||
## Technical approach
|
||||
|
||||
Task rows use `SingleSelectionMenuItems` and `BulkSelectionMenuItems`.
|
||||
The implementation makes action eligibility explicit enough to omit empty groups.
|
||||
Leaf-owned separators move to the group composition without changing other callers.
|
||||
Existing selection callbacks, confirmations, and event guards remain intact.
|
||||
|
||||
Cards use `buildKanbanCardMenuEntries` for both renderers.
|
||||
Compose nonempty entry arrays and insert one separator between them.
|
||||
`buildTaskActionsMenuEntries` inherits normal ordering and groups its reduced variants consistently.
|
||||
Preserve Edit plugin nesting on cards and flat Edit on preview/detail surfaces.
|
||||
|
||||
The task system owns the requirement because the contract concerns task actions across surfaces.
|
||||
The existing preview/detail pair links to the new grouping pair for ordering.
|
||||
No linked companion plan exists for that pair in this checkout.
|
||||
Existing priority plans remain historical records of their action behavior, which this change preserves.
|
||||
|
||||
## ASCII UI preview
|
||||
|
||||
UI-01: Task-row context menu or visible ellipsis, normal task.
|
||||
Source baseline: Pin, Edit, Priority, Rename, Create subtask, Duplicate, Archive,
|
||||
Color, Nest under, plugins, Link, movement, Detach, Delete. Availability varies.
|
||||
|
||||
Proposed groups:
|
||||
|
||||
```text
|
||||
+---------------------------+
|
||||
| Pin |
|
||||
| Color > |
|
||||
| Priority > |
|
||||
+---------------------------+
|
||||
| Edit |
|
||||
| Rename |
|
||||
| Duplicate (disabled) |
|
||||
+---------------------------+
|
||||
| Create subtask |
|
||||
| Nest under > |
|
||||
| Link > |
|
||||
| Detach from parent* |
|
||||
+---------------------------+
|
||||
| Move to > |
|
||||
| Send to workflow > |
|
||||
+---------------------------+
|
||||
| Primary plugin actions* |
|
||||
+---------------------------+
|
||||
| Archive |
|
||||
| Delete |
|
||||
+---------------------------+
|
||||
```
|
||||
|
||||
UI-02: Card right-click or three-dot menu, normal task.
|
||||
Preview/detail use the same order, with flat Edit and their existing eligibility.
|
||||
Source baseline: Edit, Priority, Move to, Send to workflow, plugins, Link,
|
||||
Archive, Detach, separator, Delete.
|
||||
|
||||
```text
|
||||
+---------------------------+
|
||||
| Priority > |
|
||||
+---------------------------+
|
||||
| Edit * |
|
||||
+---------------------------+
|
||||
| Link > |
|
||||
| Detach from parent* |
|
||||
+---------------------------+
|
||||
| Move to > |
|
||||
| Send to workflow > |
|
||||
+---------------------------+
|
||||
| Primary plugin actions* |
|
||||
+---------------------------+
|
||||
| Archive |
|
||||
| Delete |
|
||||
+---------------------------+
|
||||
```
|
||||
|
||||
An asterisk marks conditional content, not literal copy.
|
||||
Card Edit becomes a submenu only when admitted Edit plugin actions exist.
|
||||
All existing eligibility rules apply. Empty groups and adjacent dividers disappear.
|
||||
Archive is neutral. Delete is red. Group order is required, while spacing is illustrative.
|
||||
|
||||
UI-03: Phone, visible row/card ellipsis, below 640px.
|
||||
The existing inset menu uses the same applicable rows as UI-01 or UI-02.
|
||||
|
||||
```text
|
||||
+-----------------------------+
|
||||
| Task list or task drawer |
|
||||
| Task title ... |
|
||||
| |
|
||||
| dimmed underlying surface |
|
||||
| +-------------------------+ |
|
||||
| | Pin / Color / Priority | |
|
||||
| |------------------------| | |
|
||||
| | Edit / Rename / ... | | | <- internal scroll
|
||||
| |------------------------| | |
|
||||
| | Relationships | | |
|
||||
| | Movement / plugins v | |
|
||||
| |-------------------------| |
|
||||
| | Archive | |
|
||||
| | Delete | |
|
||||
| +-------------------------+ |
|
||||
| safe-area spacing |
|
||||
+-----------------------------+
|
||||
```
|
||||
|
||||
Slash-separated lines abbreviate separate action rows, not combined controls.
|
||||
The entire menu body scrolls, including Archive/Delete. There is no fixed action footer.
|
||||
Touch rows retain at least 44px targets. Submenus retain the existing touch presentation.
|
||||
At 640px and above, existing anchored placement remains in force.
|
||||
|
||||
UI-04: Reduced states.
|
||||
|
||||
```text
|
||||
Bulk row Archived detail Unresolved detail
|
||||
Pin N tasks [plugin actions] [plugin actions]
|
||||
---------------- ---------------- ----------------
|
||||
Move to > Delete Archive
|
||||
Send to workflow > Delete
|
||||
----------------
|
||||
Archive N tasks
|
||||
Delete N tasks
|
||||
```
|
||||
|
||||
Plugin rows and their divider disappear when no primary action is admitted.
|
||||
Archived row menus preserve their own existing eligibility rather than adopting the detail entry set.
|
||||
Disabled and pending actions retain existing styling and cannot invoke a second operation.
|
||||
|
||||
UI-01/02 cover criteria .1-.5 and .8. UI-03 covers .7-.8. UI-04 covers .3-.6.
|
||||
|
||||
## Tests
|
||||
|
||||
| Evidence | Criteria |
|
||||
| --- | --- |
|
||||
| `task-switcher-context-menu.test.tsx`: grouped normal/subtask order, hidden groups, disabled Duplicate | .1, .3, .4 |
|
||||
| Same suite: bulk and archived membership, callbacks and event guards | .4, .6, .8 |
|
||||
| `kanban-card-menu-items.test.tsx`: exact group boundaries and both renderer parity | .2-.4 |
|
||||
| Same suite: primary registration order and visibility changes, Edit/Link placement | .5 |
|
||||
| `task-actions-menu-entries.test.ts`: normal, archived, unresolved, no-plugin variants | .2, .3, .6 |
|
||||
|
||||
Paths above are under `apps/web/components/task`, `apps/web/components`, and `apps/web/lib/kanban` respectively.
|
||||
Use behavioral assertions for visible order and separators, with callback assertions for moved actions.
|
||||
|
||||
## E2E tests
|
||||
|
||||
- New `tests/task/task-menu-grouping.spec.ts`, chromium: row grouping, Color before Priority, keyboard order, and one persisted priority update.
|
||||
- Existing `tests/task/mobile-sidebar-task-actions.spec.ts`, mobile-chrome: tap opener, grouping, nested choices, internal scrolling, and archive confirmation cancellation.
|
||||
- Existing `tests/kanban/card-menu-delete-archive.spec.ts`, chromium: both card openers, grouping, and existing removal outcomes.
|
||||
- Existing `tests/kanban/task-actions-menu-preview.spec.ts` and `task-actions-menu-detail.spec.ts`, chromium: inherited grouping and existing focus/confirmation outcomes.
|
||||
- Existing `tests/kanban/mobile-task-priority.spec.ts`, mobile-chrome: card touch menu grouping and priority persistence.
|
||||
|
||||
Phone checks include long-menu containment, 44px targets, last-action reachability,
|
||||
and no document horizontal overflow. Capture desktop and phone screenshots for preview comparison.
|
||||
Keep all existing mutation scenarios. Add ordering checks alongside them.
|
||||
|
||||
## Work orders
|
||||
|
||||
- [x] [Task 01: Group task-row actions](task-01-task-row-groups.md)
|
||||
- [x] [Task 02: Group card actions](task-02-card-groups.md)
|
||||
|
||||
Execute sequentially. Work orders do not authorize delegation.
|
||||
Exact verification commands are in each work order.
|
||||
|
||||
## Verification results
|
||||
|
||||
Implementation checks (2026-09-10):
|
||||
|
||||
- Focused Vitest: 3 files passed, 50 tests passed.
|
||||
- ESLint on changed source, test, and E2E files: passed with 0 errors; the repository's existing file/function-size and complexity rules reported warnings only.
|
||||
- `pnpm run typecheck`: passed.
|
||||
- `pnpm run i18n:check`: passed, including 3018 guarded files.
|
||||
- Desktop row E2E: 1 passed after the managed runner build; the corrected fixture uses a root task because sidebar Pin is not offered for subtasks.
|
||||
- Mobile sidebar E2E: 17 passed, including grouping, internal scroll, touch-target, and removal reachability checks.
|
||||
- Desktop card/preview/detail E2E: 9 passed.
|
||||
- Mobile card priority E2E: 1 passed.
|
||||
- Fresh desktop and phone menu screenshots were captured, inspected, PNG-compressed, and validated against the two-entry PR asset manifest.
|
||||
|
||||
Document checks (2026-09-10):
|
||||
|
||||
- `python3 scripts/lint-spec-files.test.py`: passed, 36 tests.
|
||||
- `python3 scripts/lint-spec-files.py --all`: all specification files passed.
|
||||
- `git diff --check`: passed.
|
||||
|
||||
## Risks
|
||||
|
||||
- Children that return null can leave dividers around empty groups.
|
||||
- Leaf-owned dividers can duplicate the new group boundaries.
|
||||
- Plugin placement descriptions currently name the old location.
|
||||
- Added dividers increase phone menu height and require real scrolling evidence.
|
||||
- Existing tests can encode the previous order. Update order assertions without removing behavior coverage.
|
||||
@@ -0,0 +1,149 @@
|
||||
---
|
||||
id: "01-task-row-groups"
|
||||
title: "Group task-row actions"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-TASKS-MENU-GROUPING-001
|
||||
acceptance_criteria:
|
||||
- AC-TASKS-MENU-GROUPING-001.1
|
||||
- AC-TASKS-MENU-GROUPING-001.2
|
||||
- AC-TASKS-MENU-GROUPING-001.3
|
||||
- AC-TASKS-MENU-GROUPING-001.4
|
||||
- AC-TASKS-MENU-GROUPING-001.5
|
||||
- AC-TASKS-MENU-GROUPING-001.6
|
||||
- AC-TASKS-MENU-GROUPING-001.7
|
||||
- AC-TASKS-MENU-GROUPING-001.8
|
||||
system_design:
|
||||
- ../../specs/tasks/system-design/task-menu-grouping.md
|
||||
---
|
||||
|
||||
# Task 01: Group task-row actions
|
||||
|
||||
## Summary
|
||||
|
||||
Reorder single-task and bulk task-row menus. Keep groups free of empty or duplicate dividers.
|
||||
Preserve action eligibility, touch interaction, selection clearing, and confirmations.
|
||||
|
||||
## In scope
|
||||
|
||||
- The files and menu variants named below.
|
||||
- Focused unit and browser coverage for changed order, dividers, and existing action outcomes.
|
||||
- TDD: establish failing order/group assertions before production edits.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- New actions, backend changes, plugin registration types, and replacement mobile shells.
|
||||
|
||||
## Acceptance
|
||||
|
||||
1. Normal, subtask, archived, and bulk menu variants have the specified groups with no empty dividers.
|
||||
2. Existing handlers, disabled states, selection behavior, and drag guards pass their regression tests.
|
||||
3. Desktop and phone tests prove ordering and an action outcome, including scrolling to removal actions.
|
||||
|
||||
## ASCII UI preview
|
||||
|
||||
UI-01: Task-row menu. UI-03: Same sequence in the phone inset scrolling menu.
|
||||
|
||||
```text
|
||||
Pin / Color > / Priority >
|
||||
-------------------------
|
||||
Edit / Rename / Duplicate (disabled)
|
||||
-------------------------
|
||||
Create subtask / Nest under > / Link > / Detach*
|
||||
-------------------------
|
||||
Move to > / Send to workflow >
|
||||
-------------------------
|
||||
Primary plugin actions*
|
||||
-------------------------
|
||||
Archive / Delete
|
||||
```
|
||||
|
||||
Each slash denotes a separate action row. Asterisks denote conditional entries.
|
||||
UI-04 bulk order is Pin, movement, removal. Existing archived-row eligibility remains intact.
|
||||
|
||||
The [full previews](plan.md#ascii-ui-preview) define UI-01 through UI-04.
|
||||
Criteria AC-TASKS-MENU-GROUPING-001.1 through .8 apply to the relevant variants.
|
||||
The phone entry point is the existing visible ellipsis.
|
||||
Below 640px, the existing inset menu scrolls internally and preserves safe-area spacing.
|
||||
At wider widths, existing anchored placement remains. Touch rows retain 44px targets.
|
||||
No menu heading or fixed removal footer is added. Compare rendered desktop and phone screenshots with these previews.
|
||||
|
||||
## Verification
|
||||
|
||||
Run from the repository root. Managed E2E commands rebuild the application.
|
||||
Do not overlap desktop and mobile runs. Record discovered tests and final results.
|
||||
|
||||
```bash
|
||||
(cd apps && pnpm install --frozen-lockfile)
|
||||
(cd apps/web && pnpm test components/task/task-switcher-context-menu.test.tsx)
|
||||
(cd apps/web && pnpm exec eslint components/task/task-switcher-context-menu.tsx components/task/task-switcher-action-items.tsx components/task/task-switcher-plugin-menu-items.tsx components/task/task-switcher-link-menu.tsx components/task/task-move-context-menu.tsx)
|
||||
(cd apps/web && pnpm run typecheck)
|
||||
(cd apps/web && pnpm run i18n:check)
|
||||
(cd apps/web && pnpm e2e:run --project chromium tests/task/task-menu-grouping.spec.ts)
|
||||
(cd apps/web && pnpm e2e:run --project mobile-chrome tests/task/mobile-sidebar-task-actions.spec.ts)
|
||||
python3 scripts/lint-spec-files.test.py
|
||||
python3 scripts/lint-spec-files.py --all
|
||||
git diff --check
|
||||
git status --short -- docs/plans/task-menu-grouping
|
||||
```
|
||||
|
||||
Run targeted tests and lint for any additional helper extracted during implementation.
|
||||
The workspace install is required before the first package command in this worktree.
|
||||
Existing tests remain in place. New tests assert group boundaries, not merely label presence.
|
||||
Phone tests must tap the opener and a submenu, complete an action, and reach the final removal row.
|
||||
Use existing confirmation flows and cancel removal when the scenario only checks reachability.
|
||||
For long menus, assert viewport containment, internal scroll, and no document horizontal overflow.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/components/task/task-switcher-context-menu.tsx`
|
||||
- `apps/web/components/task/task-switcher-action-items.tsx`
|
||||
- `apps/web/components/task/task-switcher-plugin-menu-items.tsx`
|
||||
- `apps/web/components/task/task-switcher-link-menu.tsx`
|
||||
- `apps/web/components/task/task-move-context-menu.tsx`
|
||||
- `apps/web/components/task/task-switcher-context-menu.test.tsx`
|
||||
- `apps/web/e2e/tests/task/task-menu-grouping.spec.ts (new)`
|
||||
- `apps/web/e2e/tests/task/mobile-sidebar-task-actions.spec.ts`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Risks
|
||||
|
||||
JSX children can return null after group composition. Existing action components can insert their own separators.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Inputs
|
||||
|
||||
- [Requirements](../../specs/tasks/requirements/task-menu-grouping.md).
|
||||
- [System design](../../specs/tasks/system-design/task-menu-grouping.md).
|
||||
- [Preview/detail contract](../../specs/tasks/requirements/task-actions-menu.md).
|
||||
- Existing tests beside the named source files and the listed E2E scenarios.
|
||||
- Scoped web guidance and the mobile-parity, TDD, and E2E skills.
|
||||
|
||||
## Results
|
||||
|
||||
Implemented the grouped single-task and bulk task-row composition with one
|
||||
conditional divider between nonempty groups. Existing action eligibility,
|
||||
selection clearing, drag guards, archive confirmation, touch sizing, and
|
||||
mobile scrolling remain intact. Primary plugin actions are rendered as a
|
||||
separate group before removal.
|
||||
|
||||
Verification on 2026-09-10:
|
||||
|
||||
- `pnpm test components/task/task-switcher-context-menu.test.tsx ...`: 3 files passed, 50 tests passed.
|
||||
- `pnpm run typecheck`: passed.
|
||||
- `pnpm run i18n:check`: passed.
|
||||
- `pnpm e2e:run --no-build --project chromium tests/task/task-menu-grouping.spec.ts`: 1 passed after the managed runner build.
|
||||
- `pnpm e2e:run --no-build --project mobile-chrome tests/task/mobile-sidebar-task-actions.spec.ts`: 17 passed.
|
||||
- Fresh desktop and phone task-row screenshots were captured, inspected, compressed, and mapped in the PR asset manifest.
|
||||
- `python3 scripts/lint-spec-files.test.py`: 36 tests passed.
|
||||
- `python3 scripts/lint-spec-files.py --all`: passed.
|
||||
- `git diff --check`: passed.
|
||||
@@ -0,0 +1,153 @@
|
||||
---
|
||||
id: "02-card-groups"
|
||||
title: "Group card actions"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on: ["01-task-row-groups"]
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-TASKS-MENU-GROUPING-001
|
||||
acceptance_criteria:
|
||||
- AC-TASKS-MENU-GROUPING-001.1
|
||||
- AC-TASKS-MENU-GROUPING-001.2
|
||||
- AC-TASKS-MENU-GROUPING-001.3
|
||||
- AC-TASKS-MENU-GROUPING-001.4
|
||||
- AC-TASKS-MENU-GROUPING-001.5
|
||||
- AC-TASKS-MENU-GROUPING-001.6
|
||||
- AC-TASKS-MENU-GROUPING-001.7
|
||||
- AC-TASKS-MENU-GROUPING-001.8
|
||||
system_design:
|
||||
- ../../specs/tasks/system-design/task-menu-grouping.md
|
||||
---
|
||||
|
||||
# Task 02: Group card actions
|
||||
|
||||
## Summary
|
||||
|
||||
Reorder the shared card entries and the inherited preview/detail menus.
|
||||
Preserve conditional plugin placement and the archived/unresolved action sets.
|
||||
Synchronize descriptions of the old menu positions in the plugin contract documentation.
|
||||
|
||||
## In scope
|
||||
|
||||
- The files and menu variants named below.
|
||||
- Focused unit and browser coverage for changed order, dividers, and existing action outcomes.
|
||||
- TDD: establish failing order/group assertions before production edits.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- New actions, backend changes, plugin registration types, and replacement mobile shells.
|
||||
|
||||
## Acceptance
|
||||
|
||||
1. Card context/dropdown menus and normal preview/detail menus use the required order and dividers.
|
||||
2. Reduced tiers and plugin visibility changes produce no empty groups or changes to action eligibility.
|
||||
3. Browser checks preserve action outcomes and phone reachability. Placement documentation matches the new groups.
|
||||
|
||||
## ASCII UI preview
|
||||
|
||||
UI-02: Card context/three-dot menu. UI-03: Same sequence in the phone inset scrolling menu.
|
||||
|
||||
```text
|
||||
Priority >
|
||||
-------------------------
|
||||
Edit [submenu only for card plugin actions]
|
||||
-------------------------
|
||||
Link > / Detach from parent*
|
||||
-------------------------
|
||||
Move to > / Send to workflow >
|
||||
-------------------------
|
||||
Primary plugin actions*
|
||||
-------------------------
|
||||
Archive / Delete
|
||||
```
|
||||
|
||||
Each slash denotes a separate row. Asterisks denote conditional entries.
|
||||
UI-04 archived detail: plugins, divider, Delete. Without plugins, show Delete alone.
|
||||
UI-04 unresolved detail: plugins, divider, Archive and Delete together.
|
||||
|
||||
The [full previews](plan.md#ascii-ui-preview) define UI-01 through UI-04.
|
||||
Criteria AC-TASKS-MENU-GROUPING-001.1 through .8 apply to the relevant variants.
|
||||
The phone entry point is the existing visible ellipsis.
|
||||
Below 640px, the existing inset menu scrolls internally and preserves safe-area spacing.
|
||||
At wider widths, existing anchored placement remains. Touch rows retain 44px targets.
|
||||
No menu heading or fixed removal footer is added. Compare rendered desktop and phone screenshots with these previews.
|
||||
|
||||
## Verification
|
||||
|
||||
Run from the repository root. Managed E2E commands rebuild the application.
|
||||
Do not overlap desktop and mobile runs. Record discovered tests and final results.
|
||||
|
||||
```bash
|
||||
(cd apps/web && pnpm test components/kanban-card-menu-items.test.tsx lib/kanban/task-actions-menu-entries.test.ts)
|
||||
(cd apps/web && pnpm exec eslint components/kanban-card-menu-items.tsx lib/kanban/task-actions-menu-entries.ts components/plugins/task-menu-actions.ts)
|
||||
(cd apps/web && pnpm run typecheck)
|
||||
(cd apps/web && pnpm run i18n:check)
|
||||
(cd apps/web && pnpm e2e:run --project chromium tests/kanban/card-menu-delete-archive.spec.ts tests/kanban/task-actions-menu-preview.spec.ts tests/kanban/task-actions-menu-detail.spec.ts)
|
||||
(cd apps/web && pnpm e2e:run --project mobile-chrome tests/kanban/mobile-task-priority.spec.ts)
|
||||
python3 scripts/lint-spec-files.test.py
|
||||
python3 scripts/lint-spec-files.py --all
|
||||
git diff --check
|
||||
git status --short -- docs/plans/task-menu-grouping
|
||||
```
|
||||
|
||||
Run targeted tests and lint for any additional helper extracted during implementation.
|
||||
Task 01 supplies the workspace dependency installation.
|
||||
Existing tests remain in place. New tests assert group boundaries, not merely label presence.
|
||||
Phone tests must tap the opener and a submenu, complete an action, and reach the final removal row.
|
||||
Use existing confirmation flows and cancel removal when the scenario only checks reachability.
|
||||
For long menus, assert viewport containment, internal scroll, and no document horizontal overflow.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/components/kanban-card-menu-items.tsx`
|
||||
- `apps/web/lib/kanban/task-actions-menu-entries.ts`
|
||||
- `apps/web/components/plugins/task-menu-actions.ts`
|
||||
- `apps/web/components/kanban-card-menu-items.test.tsx`
|
||||
- `apps/web/lib/kanban/task-actions-menu-entries.test.ts`
|
||||
- `apps/web/e2e/tests/kanban/card-menu-delete-archive.spec.ts`
|
||||
- `apps/web/e2e/tests/kanban/task-actions-menu-preview.spec.ts`
|
||||
- `apps/web/e2e/tests/kanban/task-actions-menu-detail.spec.ts`
|
||||
- `apps/web/e2e/tests/kanban/mobile-task-priority.spec.ts`
|
||||
- `docs/plans/plugins/PLUGIN-API.md`
|
||||
- `apps/packages/plugin-sdk (placement comments only, if present)`
|
||||
- `apps/web/lib/plugins/types.ts (placement comments only, if present)`
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 01. Keep any shared helper compatible with its task-row consumers.
|
||||
|
||||
## Risks
|
||||
|
||||
The shared builder affects preview/detail automatically. Plugin comments and tests can still describe the old primary-action location.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Inputs
|
||||
|
||||
- [Requirements](../../specs/tasks/requirements/task-menu-grouping.md).
|
||||
- [System design](../../specs/tasks/system-design/task-menu-grouping.md).
|
||||
- [Preview/detail contract](../../specs/tasks/requirements/task-actions-menu.md).
|
||||
- Existing tests beside the named source files and the listed E2E scenarios.
|
||||
- Scoped web guidance and the mobile-parity, TDD, and E2E skills.
|
||||
|
||||
## Results
|
||||
|
||||
Implemented shared card grouping and inherited normal preview/detail ordering.
|
||||
Archived and unresolved tiers now insert separators only between admitted plugin
|
||||
and removal groups. Primary plugin placement documentation now describes the
|
||||
movement-to-removal position, while Edit plugin nesting and Link plugin
|
||||
placement remain unchanged.
|
||||
|
||||
Verification on 2026-09-10:
|
||||
|
||||
- `pnpm test components/kanban-card-menu-items.test.tsx lib/kanban/task-actions-menu-entries.test.ts`: 3 focused files passed, 50 tests passed with the row suite.
|
||||
- `pnpm run typecheck`: passed.
|
||||
- `pnpm run i18n:check`: passed.
|
||||
- `pnpm e2e:run --no-build --project chromium tests/kanban/card-menu-delete-archive.spec.ts tests/kanban/task-actions-menu-preview.spec.ts tests/kanban/task-actions-menu-detail.spec.ts`: 9 passed.
|
||||
- `pnpm e2e:run --no-build --project mobile-chrome tests/kanban/mobile-task-priority.spec.ts`: 1 passed.
|
||||
- `python3 scripts/lint-spec-files.test.py`: 36 tests passed.
|
||||
- `python3 scripts/lint-spec-files.py --all`: passed.
|
||||
- `git diff --check`: passed.
|
||||
@@ -88,6 +88,7 @@ signals, and task-scoped scheduling contracts.
|
||||
- [Session Delete Preserves Task Workspaces](requirements/session-delete-resource-cleanup.md)
|
||||
- [Sidebar Task Editing](requirements/sidebar-task-edit.md)
|
||||
- [Task Actions Menu on Preview and Detail Surfaces](requirements/task-actions-menu.md)
|
||||
- [Task menu grouping](requirements/task-menu-grouping.md)
|
||||
- [Task Actions Menu In-Flight and Concurrency](requirements/task-actions-menu-concurrency.md)
|
||||
- [Task Actions Menu Action Outcomes](requirements/task-actions-menu-outcomes.md)
|
||||
- [Subtasks as Workflow Checklist](requirements/subtask-checklist.md)
|
||||
@@ -158,6 +159,7 @@ signals, and task-scoped scheduling contracts.
|
||||
- [Passthrough Initial Prompt Turn Boundary](system-design/passthrough-initial-prompt-turn-boundary.md)
|
||||
- [Prompt attachments](system-design/prompt-attachments.md)
|
||||
- [Task Actions Menu on Preview and Detail Surfaces](system-design/task-actions-menu.md)
|
||||
- [Task menu grouping](system-design/task-menu-grouping.md)
|
||||
- [Task Archive Confirmation](system-design/archive-confirmation.md)
|
||||
- [Task removal navigation](system-design/removal-navigation.md)
|
||||
- [Task plan content size limit](system-design/plan-content-size-limit.md)
|
||||
|
||||
@@ -158,10 +158,10 @@ in the same order, with the same labels, wherever the menu is opened.
|
||||
in-flight disabled state, which is per surface under
|
||||
AC-TASKS-TASK-ACTIONS-MENU-004.1b and so may differ from the card's for the
|
||||
duration of a request.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-002.2:** The order named in
|
||||
AC-TASKS-TASK-ACTIONS-MENU-002.1 is: Edit, Priority, Move to, Send to
|
||||
workflow, plugin primary actions, Link, Archive, Detach from parent, a
|
||||
separator, Delete. The system shall omit Priority when the card actions menu
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-002.2:** The order and dividers named in
|
||||
AC-TASKS-TASK-ACTIONS-MENU-002.1 follow
|
||||
[Task menu grouping](task-menu-grouping.md).
|
||||
The system shall omit Priority when the card actions menu
|
||||
omits it. It shall omit any of Move to, Send to workflow, Link, and Detach
|
||||
from parent whose availability condition in the card actions menu is unmet,
|
||||
and shall not reorder the entries that remain.
|
||||
@@ -243,7 +243,8 @@ in the same order, with the same labels, wherever the menu is opened.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-002.9:** The system shall present no entry on
|
||||
these two surfaces that the card actions menu does not present for a single,
|
||||
unselected task, and shall present no bulk-selection variant of the menu.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-002.10:** The system shall leave the card actions
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-002.10:** Except for the ordering and dividers
|
||||
defined in [Task menu grouping](task-menu-grouping.md), the system shall leave the card actions
|
||||
menu, the card context menu, and the task switcher sidebar menu unchanged in
|
||||
entry membership, labels, top-level order, and behavior, with one named
|
||||
exception. Adopting `sortWorkflowStepsByPosition` inside the shared
|
||||
@@ -274,9 +275,8 @@ actions. It lives in
|
||||
- **The mobile preview surface.** There is none: on mobile the board navigates
|
||||
straight to the task detail route and never renders the preview panel, so
|
||||
there is no surface to add a trigger to.
|
||||
- **Changing the card actions menu, the card context menu, or the task
|
||||
switcher sidebar menu.** Their entry sets, ordering, and labels are frozen by
|
||||
this requirement, not revised by it.
|
||||
- **Changing card or sidebar action membership and behavior.**
|
||||
[Task menu grouping](task-menu-grouping.md) owns their revised ordering and dividers.
|
||||
- **Reconciling the card menu's entry set with the task switcher sidebar's
|
||||
divergent one.** The sidebar offers Pin, Rename, Create subtask, Duplicate,
|
||||
Color, and Nest, and omits several the card has. These surfaces standardise on
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
status: draft
|
||||
system: tasks
|
||||
created: 2026-09-10
|
||||
owners:
|
||||
- Kandev
|
||||
---
|
||||
|
||||
# Task menu grouping requirements
|
||||
|
||||
## Overview
|
||||
|
||||
Task menus group related actions so users can find them as the action list grows.
|
||||
The task system owns this contract because the groups describe task operations across task surfaces.
|
||||
This document extends [task actions menus](task-actions-menu.md) to cover ordering across cards and task rows.
|
||||
|
||||
## Requirements
|
||||
|
||||
### REQ-TASKS-MENU-GROUPING-001: Consistent action groups
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-TASKS-MENU-GROUPING-001.1:** Single-task row menus shall use the following group order and action order:
|
||||
|
||||
| Group | Actions in order |
|
||||
| --- | --- |
|
||||
| Mark | Pin or Unpin, Color, Priority |
|
||||
| Edit | Edit, Rename, Duplicate |
|
||||
| Relationships | Create subtask, Nest under, Link, Detach from parent |
|
||||
| Move | Move to, Send to workflow |
|
||||
| Plugins | Admitted primary plugin actions in registration order |
|
||||
| Remove | Archive, Delete |
|
||||
|
||||
- **AC-TASKS-MENU-GROUPING-001.2:** Card context and three-dot menus shall use Priority, Edit, Link, Detach from parent, Move to, Send to workflow, primary plugins, Archive, Delete.
|
||||
Group boundaries shall match the table. Preview and desktop detail menus shall inherit this order with their existing availability exceptions.
|
||||
- **AC-TASKS-MENU-GROUPING-001.3:** Menus shall show one thin divider between nonempty groups, with no visible group headings.
|
||||
Menus shall show no leading, trailing, or consecutive dividers. Archive and Delete shall share a group.
|
||||
- **AC-TASKS-MENU-GROUPING-001.4:** Menus shall preserve existing labels, icons, eligibility, disabled states, callbacks, confirmations, selection semantics, and navigation outcomes.
|
||||
Duplicate shall remain disabled where it already appears. Archive shall retain neutral styling, and Delete shall retain destructive styling.
|
||||
- **AC-TASKS-MENU-GROUPING-001.5:** Plugin Edit actions shall remain in the card-only Edit submenu. Plugin Link actions shall remain under Link.
|
||||
Primary plugin actions shall remain flat, reactive, and ordered by registration, in their separate group before removal actions.
|
||||
- **AC-TASKS-MENU-GROUPING-001.6:** Bulk row menus shall retain only existing bulk actions, ordered as Pin, movement actions, then Archive and Delete.
|
||||
Archived and unresolved menus shall retain their existing action sets, with the same grouping and divider rules.
|
||||
- **AC-TASKS-MENU-GROUPING-001.7:** Touch users shall reach the same applicable groups through existing visible task-row and card action controls.
|
||||
Phone menus shall scroll internally, respect safe areas, and keep nested choices and the last action reachable without document horizontal overflow.
|
||||
Touch action rows shall retain targets of at least 44px. Desktop menus shall retain their existing density.
|
||||
- **AC-TASKS-MENU-GROUPING-001.8:** Keyboard users shall reach actions in visual order. Escape shall dismiss the menu and restore the existing opener focus behavior.
|
||||
Menu interaction shall not select, navigate to, or drag the underlying task as a side effect.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Adding Pin, Color, Rename, Duplicate, or nesting capabilities to cards.
|
||||
- New action handlers, backend contracts, persistence, or localization keys.
|
||||
- New plugin registration groups, classification by label, or wider plugin eligibility.
|
||||
- Replacing the mobile menu shell or adding a mobile detail-header trigger.
|
||||
|
||||
## Implementation Plans
|
||||
|
||||
- [Task menu grouping](../../../plans/task-menu-grouping/plan.md)
|
||||
@@ -118,9 +118,9 @@ its own invalidation, and its own tests, to buy nothing a user can see.
|
||||
**Ordering.** The entry order is fixed by the shared builder, so parity is
|
||||
structural rather than asserted per surface.
|
||||
|
||||
For a normal resolved row, the top-level order is Edit, Priority, Move to, Send
|
||||
to workflow, plugin primary actions, Link, Archive, Detach from parent, a
|
||||
separator, and Delete. The Priority submenu uses the row's current priority and
|
||||
For a normal resolved row, the top-level order and dividers follow
|
||||
[Task menu grouping](task-menu-grouping.md).
|
||||
The Priority submenu uses the row's current priority and
|
||||
the same update hook as the card. If the card builder omits an entry because its
|
||||
callback is unavailable, the new surface omits that entry too.
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
status: draft
|
||||
system: tasks
|
||||
requirements:
|
||||
- REQ-TASKS-MENU-GROUPING-001
|
||||
---
|
||||
|
||||
# Task menu grouping system design
|
||||
|
||||
## Purpose and boundaries
|
||||
|
||||
This change reorders existing task operations and centralizes divider placement within each menu composition.
|
||||
The [task actions menu design](task-actions-menu.md) continues to own subject resolution, requests, confirmations, and navigation.
|
||||
This design owns its new ordering. No API, storage, permission, or state subscription changes are necessary.
|
||||
|
||||
## Requirement mapping
|
||||
|
||||
| Criteria | Design section |
|
||||
| --- | --- |
|
||||
| AC-TASKS-MENU-GROUPING-001.1 through .3 | Menu composition |
|
||||
| AC-TASKS-MENU-GROUPING-001.4 through .6 | Conditional entries and plugins |
|
||||
| AC-TASKS-MENU-GROUPING-001.7 and .8 | Responsive interaction |
|
||||
|
||||
## Menu composition
|
||||
|
||||
`buildKanbanCardMenuEntries` in `apps/web/components/kanban-card-menu-items.tsx`
|
||||
already supplies both card renderers. It will assemble arrays for mark, edit,
|
||||
relationships, move, primary plugins, and removal groups.
|
||||
The composition filters empty arrays before it inserts separators.
|
||||
Existing entry keys, submenu children, and callbacks remain stable.
|
||||
|
||||
`buildTaskActionsMenuEntries` in `apps/web/lib/kanban/task-actions-menu-entries.ts`
|
||||
inherits the card order for normal rows. Its archived and unresolved branches
|
||||
will use the same divider rule. An unresolved row has a plugin group, if visible,
|
||||
then Archive and Delete together. An archived row has plugins, if visible, then Delete.
|
||||
|
||||
`SingleSelectionMenuItems` and `BulkSelectionMenuItems` in
|
||||
`apps/web/components/task/task-switcher-context-menu.tsx` compose task-row actions.
|
||||
Their group boundaries must use actual entry eligibility, including children that currently return null.
|
||||
Counting JSX child elements cannot establish whether a group is empty.
|
||||
Keep eligibility beside its existing action owner and expose visible entries or explicit visibility to the composition.
|
||||
Use small focused helpers as necessary. Do not build a general menu registry.
|
||||
|
||||
Inspect separators owned by `task-switcher-action-items.tsx` and
|
||||
`task-move-context-menu.tsx` before moving their items.
|
||||
Remove or make those separators optional for grouped callers, while preserving other callers.
|
||||
The parent composition owns every top-level divider.
|
||||
|
||||
## Conditional entries and plugins
|
||||
|
||||
The change preserves existing distinctions between cards, rows, bulk selection,
|
||||
archived tasks, and unresolved board rows. In particular, it does not add card capabilities.
|
||||
Subtask detachment moves into relationships without changing its eligibility.
|
||||
|
||||
`buildPrimaryPluginEntries` and `TaskPluginPrimaryMenuItems` retain registry order,
|
||||
visibility evaluation, immutable task context, and current failure handling.
|
||||
The primary group moves after movement and before removal.
|
||||
Edit and Link plugin actions retain their current nesting and surface restrictions.
|
||||
No label-based classification or plugin SDK schema change is necessary.
|
||||
|
||||
Existing location descriptions in `docs/plans/plugins/PLUGIN-API.md`, SDK comments,
|
||||
host types, and `components/plugins/task-menu-actions.ts` need synchronization during implementation.
|
||||
Only placement descriptions change. Existing plugin registration and callback types remain intact.
|
||||
|
||||
## Responsive interaction
|
||||
|
||||
The nearest shipped examples are the card dropdown and the task-row menu inside
|
||||
`components/task/mobile/session-task-switcher-sheet.tsx`.
|
||||
The shared Radix menu treatment in `app/globals.css` supplies the inset bottom sheet below 640px.
|
||||
This short-lived action choice retains that shell and its internal scroll owner.
|
||||
At wider widths, existing anchored menus and pointer behavior remain intact.
|
||||
|
||||
Both presentations share action eligibility and handlers.
|
||||
Phone users open the visible ellipsis, tap a submenu, and select its action.
|
||||
The existing sheet uses dynamic viewport limits and safe-area spacing.
|
||||
The implementation must preserve that geometry when additional dividers increase menu height.
|
||||
No new fixed footer, drawer stack, or separate mobile action list is necessary.
|
||||
|
||||
Task-row event propagation guards and touch-drag cancellation remain in place.
|
||||
Escape, focus restoration, confirmation anchors, and close-before-dialog sequencing retain their existing owners.
|
||||
Long-menu tests cover internal scrolling, submenu containment, touch targets, and last-row reachability.
|
||||
|
||||
## Implementation Plans
|
||||
|
||||
- [Task menu grouping](../../../plans/task-menu-grouping/plan.md)
|
||||
Reference in New Issue
Block a user