"use client"; import { cloneElement, isValidElement, useState } from "react"; import { IconArchive, IconBrandSentry, IconCopy, IconCircleDot, IconGitPullRequest, IconLink, IconLoader, IconPencil, IconPin, IconPinFilled, IconTicket, IconTrash, } from "@tabler/icons-react"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger, } from "@kandev/ui/context-menu"; import { TaskMoveContextMenuItems, type TaskMoveWorkflow, } from "@/components/task/task-move-context-menu"; import { useTaskWorkflowMove } from "@/hooks/use-task-workflow-move"; import { TaskColorMenu } from "./task-switcher-color-menu"; import type { TaskSwitcherItem } from "./task-switcher"; export type StepDef = { id: string; title: string; color?: string; events?: { on_enter?: Array<{ type: string; config?: Record }> }; }; type ContextMenuProps = { task: TaskSwitcherItem; workflows?: TaskMoveWorkflow[]; stepsByWorkflowId?: Record; steps?: StepDef[]; children: React.ReactElement<{ menuOpen?: boolean }>; onRenameTask?: (taskId: string, currentTitle: string) => void; onArchiveTask?: (taskId: string) => void; onDeleteTask?: (taskId: string) => void; onLinkPullRequest?: (taskId: string, taskTitle?: string) => void; onLinkIssue?: (taskId: string, taskTitle?: string) => void; onLinkJiraTicket?: (taskId: string, taskTitle?: string) => void; onLinkLinearIssue?: (taskId: string, taskTitle?: string) => void; onLinkSentryIssue?: (taskId: string, taskTitle?: string) => void; onMoveToStep?: (taskId: string, workflowId: string, targetStepId: string) => void; onTogglePin?: (taskId: string) => void; isPinned?: boolean; pinnedTaskIds?: string[]; isDeleting?: boolean; /** Active multi-selection; when this task is part of it, actions apply to the whole set. */ selectedTaskIds?: Set; onBulkArchive?: (taskIds: string[]) => void; onBulkDelete?: (taskIds: string[]) => void; onBulkPin?: (taskIds: string[]) => void; onBulkMove?: (taskIds: string[], targetWorkflowId: string, targetStepId: string) => void; onClearSelection?: () => void; /** True when the selection spans more than one workflow (disables bulk "Move to step"). */ isMixedWorkflowSelection?: boolean; }; export function TaskItemWithContextMenu({ task, workflows, stepsByWorkflowId, steps, children, onRenameTask, onArchiveTask, onDeleteTask, onLinkPullRequest, onLinkIssue, onLinkJiraTicket, onLinkLinearIssue, onLinkSentryIssue, onMoveToStep, onTogglePin, isPinned, pinnedTaskIds, isDeleting, selectedTaskIds, onBulkArchive, onBulkDelete, onBulkPin, onBulkMove, onClearSelection, isMixedWorkflowSelection, }: ContextMenuProps) { const [contextOpen, setContextOpen] = useState(false); const [menuKey, setMenuKey] = useState(0); const moveTasks = useTaskWorkflowMove(); const menuOpen = contextOpen || isDeleting === true; const closeMenu = () => { setContextOpen(false); setMenuKey((k) => k + 1); }; return (
{cloneWithMenuOpen(children, menuOpen)}
); } type TaskContextMenuItemsProps = Omit & { closeMenu: () => void; moveTasks: ReturnType; }; function TaskContextMenuItems(props: TaskContextMenuItemsProps) { const { task, workflows, stepsByWorkflowId, steps, onRenameTask, onArchiveTask, onDeleteTask, onMoveToStep, onTogglePin, isPinned, pinnedTaskIds, isDeleting, selectedTaskIds, onBulkArchive, onBulkDelete, onBulkPin, onBulkMove, onClearSelection, isMixedWorkflowSelection, closeMenu, moveTasks, } = 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. 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. if (actingOnSelection && actingIds.length > 1) { return ( ); } // Acting on a lone selected row (Pin / Delete) must drop it from the selection // so later plain clicks navigate instead of toggling. const withClear = (handler?: (id: string) => void) => actingOnSelection && onClearSelection && handler ? (id: string) => { onClearSelection(); handler(id); } : handler; const onDelete = withClear(onDeleteTask); return ( <> Duplicate ); } /** 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; 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; }) { const n = actingIds.length; const allPinned = actingIds.length > 0 && actingIds.every((id) => pinnedTaskIds?.includes(id) ?? false); const pinLabel = `${allPinned ? "Unpin" : "Pin"} ${n} ${n === 1 ? "task" : "tasks"}`; return ( <> {onBulkPin && ( onBulkPin(actingIds)}> {allPinned ? ( ) : ( )} {pinLabel} )} {onBulkDelete && ( <> onBulkDelete(actingIds)}> Delete {n} tasks )} ); } export function createTaskLinkSelectAction( task: Pick, handler: ((taskId: string, taskTitle?: string) => void) | undefined, closeMenu: () => void, ) { if (!handler) return undefined; return () => { closeMenu(); handler(task.id, task.title); }; } function selectTaskLinkActions( task: Pick, closeMenu: () => void, handlers: Pick< ContextMenuProps, | "onLinkPullRequest" | "onLinkIssue" | "onLinkJiraTicket" | "onLinkLinearIssue" | "onLinkSentryIssue" >, ) { return { onLinkPullRequest: createTaskLinkSelectAction(task, handlers.onLinkPullRequest, closeMenu), onLinkIssue: createTaskLinkSelectAction(task, handlers.onLinkIssue, closeMenu), onLinkJiraTicket: createTaskLinkSelectAction(task, handlers.onLinkJiraTicket, closeMenu), onLinkLinearIssue: createTaskLinkSelectAction(task, handlers.onLinkLinearIssue, closeMenu), onLinkSentryIssue: createTaskLinkSelectAction(task, handlers.onLinkSentryIssue, closeMenu), }; } function cloneWithMenuOpen( children: React.ReactElement<{ menuOpen?: boolean }>, menuOpen: boolean, ): React.ReactNode { if (isValidElement(children)) return cloneElement(children, { menuOpen }); return children; } function TaskPinItem({ taskId, isPinned, disabled, onTogglePin, }: { taskId: string; isPinned?: boolean; disabled?: boolean; onTogglePin?: (taskId: string) => void; }) { if (!onTogglePin) return null; return ( onTogglePin(taskId)}> {isPinned ? : } {isPinned ? "Unpin" : "Pin"} ); } function TaskRenameItem({ task, disabled, onRenameTask, }: { task: TaskSwitcherItem; disabled?: boolean; onRenameTask?: (taskId: string, currentTitle: string) => void; }) { if (!onRenameTask) return null; return ( onRenameTask(task.id, task.title)}> Rename ); } function TaskArchiveItem({ taskId, actingIds, actingOnSelection, disabled, onArchiveTask, onBulkArchive, }: { taskId: string; actingIds: string[]; actingOnSelection: boolean; disabled?: boolean; onArchiveTask?: (taskId: string) => void; onBulkArchive?: (taskIds: string[]) => void; }) { // Acting on the selection routes through the bulk path (which clears the // selection afterwards) even for a single selected row. if (actingOnSelection && onBulkArchive) { const n = actingIds.length; return ( onBulkArchive(actingIds)}> {n > 1 ? `Archive ${n} tasks` : "Archive"} ); } if (!onArchiveTask) return null; return ( onArchiveTask(taskId)}> Archive ); } function TaskMoveItems({ task, workflows, stepsByWorkflowId, steps, isDeleting, onMoveToStep, actingIds, actingOnSelection, onBulkMove, isMixedWorkflowSelection, closeMenu, moveTasks, }: Omit & { 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) => { closeMenu(); if (onBulkMove) { onBulkMove(actingIds, targetWorkflowId, stepId); return; } void moveTasks(actingIds, targetWorkflowId, stepId).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); } else { moveToStep = selectMoveAction(task.id, workflowId, onMoveToStep, closeMenu); } return ( { if (actingOnSelection) { runSelectionMove(targetWorkflowId, stepId); return; } closeMenu(); void moveTasks([task.id], targetWorkflowId, stepId).catch(() => { // useTaskWorkflowMove already shows the failure toast. }); }} /> ); } function selectMoveAction( taskId: string, workflowId: string, handler: ((taskId: string, workflowId: string, targetStepId: string) => void) | undefined, closeMenu: () => void, ) { if (!handler) return undefined; return (stepId: string) => { closeMenu(); handler(taskId, workflowId, stepId); }; } function TaskDeleteItem({ taskId, isDeleting, onDeleteTask, }: { taskId: string; isDeleting?: boolean; onDeleteTask?: (taskId: string) => void; }) { if (!onDeleteTask) return null; return ( <> onDeleteTask(taskId)} > {isDeleting ? ( ) : ( )} Delete ); } function TaskLinkMenu({ disabled, onLinkPullRequest, onLinkIssue, onLinkJiraTicket, onLinkLinearIssue, onLinkSentryIssue, }: { disabled?: boolean; onLinkPullRequest?: () => void; onLinkIssue?: () => void; onLinkJiraTicket?: () => void; onLinkLinearIssue?: () => void; onLinkSentryIssue?: () => void; }) { if ( !onLinkPullRequest && !onLinkIssue && !onLinkJiraTicket && !onLinkLinearIssue && !onLinkSentryIssue ) { return null; } return ( Link {onLinkPullRequest && ( GitHub Pull Request )} {onLinkIssue && ( GitHub Issue )} {onLinkJiraTicket && ( Jira Ticket )} {onLinkLinearIssue && ( Linear Issue )} {onLinkSentryIssue && ( Sentry Issue )} ); }