Files
kandev/apps/web/components/kanban-card-menu.tsx
T
2026-09-23 11:00:23 +01:00

329 lines
12 KiB
TypeScript

"use client";
import { useRef } from "react";
import {
buildCardPluginEntries,
buildKanbanCardMenuEntries,
useKanbanCardMoveTargets,
} from "@/components/kanban-card-menu-items";
import { useTaskPluginLinkActions } from "@/components/task/task-session-sidebar-link-actions";
import { cleanupSharesParentWorkspace } from "@/components/task/task-cleanup-summary";
import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog";
import {
TaskExternalLinkDialog,
type ExternalLinkProvider,
} from "@/components/task/task-external-link-dialog";
import { TaskGitHubIssueDialog } from "@/components/task/task-github-issue-dialog";
import { TaskGitHubPRDialog } from "@/components/task/task-github-pr-dialog";
import { TaskMRLinkDialog } from "@/components/gitlab/task-mr-link-dialog";
import { useTaskWorkflowMove } from "@/hooks/use-task-workflow-move";
import { useTaskMultiSelectStore } from "@/hooks/use-task-multi-select";
import { useDetachTask } from "@/hooks/use-detach-task";
import { useUpdateTaskPriority } from "@/hooks/use-update-task-priority";
import { useTaskMenuDialogState } from "@/hooks/use-task-menu-dialog-state";
import type { Repository, TaskPriority } from "@/lib/types/http";
import type { PluginTaskMenuContext } from "@/lib/plugins/types";
import { usePluginRegistry } from "@/lib/plugins/registry";
import type { KanbanExternalLinkAvailability } from "./kanban-external-link-availability";
import type { KanbanPresentation, Task, WorkflowStep } from "@/components/kanban-card";
/** Inputs shared by both the Kanban card and the pipeline row's task menu. */
export interface TaskCardMenuParams {
task: Task;
workspaceId: string | null;
presentation?: KanbanPresentation;
externalLinkAvailability: KanbanExternalLinkAvailability;
steps?: WorkflowStep[];
isDeleting?: boolean;
isArchiving?: boolean;
/** Row-local in-flight move guard: disables move/send-to-workflow entries. */
isMoving?: boolean;
isSelected?: boolean;
selectedIds?: Set<string>;
onEdit?: (task: Task) => void;
onDelete?: (task: Task, opts?: { cascade?: boolean; discardWorktreeChanges?: boolean }) => void;
onArchive?: (task: Task, opts?: { cascade?: boolean }) => void;
onMove?: (task: Task, targetStepId: string) => void;
}
export function useKanbanCardMoveMenuActions({
task,
steps,
isSelected,
selectedIds,
onMove,
}: Pick<TaskCardMenuParams, "task" | "steps" | "isSelected" | "selectedIds" | "onMove">) {
const moveTargets = useKanbanCardMoveTargets(task.id, steps);
const moveTasks = useTaskWorkflowMove();
const { sortByDisplayOrder, getWorkflowIdForTask, eligibleSelectedIds } =
useTaskMultiSelectStore();
const runMoveTasks = (
taskIds: string[],
workflowId: string,
stepId: string,
destination: "step" | "workflow",
) => {
void moveTasks(taskIds, workflowId, stepId, destination).catch(() => {
// useTaskWorkflowMove already shows the failure toast.
});
};
const moveToStepFromDropdown = (stepId: string) => {
if (onMove) {
onMove(task, stepId);
return;
}
if (moveTargets.currentWorkflowId) {
runMoveTasks([task.id], moveTargets.currentWorkflowId, stepId, "step");
}
};
const selectedTaskIds =
isSelected && selectedIds?.size ? eligibleSelectedIds([...selectedIds]) : [task.id];
const orderedSelectedIds = () => sortByDisplayOrder(selectedTaskIds);
const isMixedWorkflowSelection =
selectedTaskIds.length > 1 &&
new Set(selectedTaskIds.map((id) => getWorkflowIdForTask(id))).size > 1;
const moveSelectedToStep = (stepId: string) => {
if (selectedTaskIds.length === 1 && selectedTaskIds[0] === task.id && onMove) {
onMove(task, stepId);
return;
}
if (!moveTargets.currentWorkflowId) return;
runMoveTasks(orderedSelectedIds(), moveTargets.currentWorkflowId, stepId, "step");
};
return {
moveTargets,
moveToStepFromDropdown,
moveSelectedToStep: isMixedWorkflowSelection ? undefined : moveSelectedToStep,
sendTaskToWorkflow: (workflowId: string, stepId: string) => {
runMoveTasks([task.id], workflowId, stepId, "workflow");
},
sendSelectionToWorkflow: (workflowId: string, stepId: string) => {
runMoveTasks(orderedSelectedIds(), workflowId, stepId, "workflow");
},
};
}
function externalLinkHandlers(
availability: TaskCardMenuParams["externalLinkAvailability"],
setExternalLinkProvider: (provider: ExternalLinkProvider) => void,
) {
return {
onLinkJiraTicket: availability.jira ? () => setExternalLinkProvider("jira") : undefined,
onLinkLinearIssue: availability.linear ? () => setExternalLinkProvider("linear") : undefined,
onLinkSentryIssue: availability.sentry ? () => setExternalLinkProvider("sentry") : undefined,
};
}
/** Link-dialog openers shared by both the dropdown and context menu builds. */
function buildLinkDialogHandlers(
externalLinkAvailability: KanbanExternalLinkAvailability,
dialogs: ReturnType<typeof useTaskMenuDialogState>,
) {
return {
onLinkPullRequest: () => dialogs.setShowPRDialog(true),
onLinkIssue: () => dialogs.setShowIssueDialog(true),
onLinkMergeRequest: externalLinkAvailability.gitlab
? () => dialogs.setShowMRDialog(true)
: undefined,
...externalLinkHandlers(externalLinkAvailability, dialogs.setExternalLinkProvider),
};
}
export function buildPluginMenuContext(
task: Task,
workspaceId: string | null,
presentation: KanbanPresentation,
): PluginTaskMenuContext {
return {
workspaceId: workspaceId ?? "",
taskId: task.id,
taskTitle: task.title,
workflowStepId: task.workflowStepId ?? null,
presentation,
};
}
export function useKanbanCardMenus({
task,
workspaceId,
presentation = "desktop",
steps,
isDeleting,
isArchiving,
isMoving,
isSelected,
selectedIds,
onEdit,
onDelete,
onArchive,
onMove,
externalLinkAvailability,
}: TaskCardMenuParams) {
const pluginLinkActions = useTaskPluginLinkActions(task.id, task.repositories ?? []);
// Plugins load asynchronously and can be disabled/uninstalled at runtime;
// re-render on any registry change so a menu action a plugin registers
// after this card already mounted still appears, and one whose plugin was
// just disabled doesn't linger as a stale entry.
usePluginRegistry();
const moveMenu = useKanbanCardMoveMenuActions({ task, steps, isSelected, selectedIds, onMove });
const dialogs = useTaskMenuDialogState();
const { detachTask, detachingTaskId } = useDetachTask();
const updateTaskPriority = useUpdateTaskPriority();
const detachAnchorRef = useRef<HTMLDivElement>(null);
const detachFocusReturnRef = useRef<HTMLButtonElement>(null);
const isDetaching = detachingTaskId === task.id;
const disabled = Boolean(isDeleting || isArchiving || isDetaching);
const moveDisabled = Boolean(disabled || isMoving);
const actingOnMultiSelection = Boolean(isSelected && selectedIds && selectedIds.size > 1);
const handleDetachConfirm = async () => {
try {
await detachTask(task.id);
dialogs.setShowDetachConfirm(false);
} catch (error) {
console.error("Failed to detach task:", error);
}
};
const requestDetachConfirmation = () => {
// Let Radix finish the menu's pointer sequence before the non-modal
// popover opens; otherwise the initiating menu event is an outside click.
window.setTimeout(() => dialogs.setShowDetachConfirm(true), 300);
};
const requestArchiveConfirmation = () => {
// Let Radix finish the menu's pointer sequence before the local surface
// opens; otherwise the initiating menu event is treated as outside input.
window.setTimeout(() => dialogs.setShowArchiveConfirm(true), 300);
};
const menuBase = {
currentWorkflowId: moveMenu.moveTargets.currentWorkflowId,
currentStepId: task.workflowStepId,
workflows: moveMenu.moveTargets.workflowItems,
stepsByWorkflowId: moveMenu.moveTargets.stepsByWorkflowId,
disabled,
moveDisabled,
isDeleting,
isArchiving,
isDetaching,
parentTaskId: task.parentTaskId,
currentPriority: task.priority,
onSelectPriority: (priority: TaskPriority) => void updateTaskPriority(task.id, priority),
onEdit: onEdit ? () => onEdit(task) : undefined,
onArchive: onArchive ? requestArchiveConfirmation : undefined,
onDelete: onDelete ? () => dialogs.setShowDeleteConfirm(true) : undefined,
onDetach: task.parentTaskId && !actingOnMultiSelection ? requestDetachConfirmation : undefined,
...buildLinkDialogHandlers(externalLinkAvailability, dialogs),
pluginLinkActions,
};
const pluginMenuContext = buildPluginMenuContext(task, workspaceId, presentation);
// Both variants below share every input the plugin entries depend on -- the
// processing flags, the edit handler and the context -- so build them once:
// passing one result to both keeps each plugin action's items() to a single
// evaluation per card per render (see buildCardPluginEntries).
const pluginEntries = buildCardPluginEntries({
disabled: menuBase.disabled,
isDeleting: menuBase.isDeleting,
isArchiving: menuBase.isArchiving,
isDetaching: menuBase.isDetaching,
onEdit: menuBase.onEdit,
pluginMenuContext,
});
return {
...dialogs,
dropdownMenuEntries: buildKanbanCardMenuEntries({
...menuBase,
onMoveToStep: moveMenu.moveToStepFromDropdown,
onSendToWorkflow: moveMenu.sendTaskToWorkflow,
pluginMenuContext,
pluginEntries,
}),
contextMenuEntries: buildKanbanCardMenuEntries({
...menuBase,
onMoveToStep: moveMenu.moveSelectedToStep,
onSendToWorkflow: moveMenu.sendSelectionToWorkflow,
pluginMenuContext,
pluginEntries,
}),
isDetaching,
detachAnchorRef,
detachFocusReturnRef,
archiveAnchorRef: detachFocusReturnRef,
archiveFocusReturnRef: detachFocusReturnRef,
handleDetachConfirm,
};
}
export type KanbanCardMenuState = ReturnType<typeof useKanbanCardMenus>;
/** Renders the non-anchored menu dialogs (delete, PR/issue/MR link, external link). */
export function KanbanCardDialogs({
task,
workspaceId,
repositories,
menu,
isDeleting,
onDelete,
}: {
task: Task;
workspaceId: string | null;
repositories: Repository[];
menu: KanbanCardMenuState;
isDeleting?: boolean;
onDelete?: TaskCardMenuParams["onDelete"];
}) {
return (
<>
<TaskDeleteConfirmDialog
open={menu.showDeleteConfirm}
onOpenChange={menu.setShowDeleteConfirm}
taskTitle={task.title}
taskId={task.id}
executorType={task.primaryExecutorType}
sharesParentWorkspace={cleanupSharesParentWorkspace(task.workspaceMode)}
isDeleting={isDeleting}
onConfirm={(opts) => onDelete?.(task, opts)}
/>
<TaskGitHubPRDialog
workspaceId={workspaceId}
open={menu.showPRDialog}
onOpenChange={menu.setShowPRDialog}
task={task}
repositories={repositories}
/>
<TaskGitHubIssueDialog
open={menu.showIssueDialog}
onOpenChange={menu.setShowIssueDialog}
task={task}
repositories={repositories}
/>
{workspaceId && (
<TaskMRLinkDialog
open={menu.showMRDialog}
onOpenChange={menu.setShowMRDialog}
taskId={task.id}
workspaceId={workspaceId}
taskRepositories={task.repositories ?? []}
repositories={repositories}
/>
)}
{menu.externalLinkProvider && workspaceId && (
<TaskExternalLinkDialog
open={true}
onOpenChange={(open) => {
if (!open) menu.setExternalLinkProvider(null);
}}
provider={menu.externalLinkProvider}
task={task}
workspaceId={workspaceId}
/>
)}
</>
);
}