Files
2026-09-16 12:13:03 +01:00

274 lines
8.3 KiB
TypeScript

"use client";
import { useDraggable } from "@dnd-kit/core";
import { KanbanCardContextMenu } from "@/components/kanban-card-context-menu";
import { KanbanCardShell } from "@/components/kanban-card-content";
import { useActiveWorkspaceRepositories } from "@/components/kanban-card-repositories";
export { resolveTaskRepositoryChips } from "@/components/kanban-card-repositories";
import {
KanbanCardDialogs,
useKanbanCardMenus,
type KanbanCardMenuState,
} from "@/components/kanban-card-menu";
export {
buildPluginMenuContext,
useKanbanCardMoveMenuActions,
} from "@/components/kanban-card-menu";
import { useAppStore } from "@/components/state-provider";
import { TaskArchiveConfirmation } from "@/components/task/task-archive-confirmation";
import { TaskDetachConfirmationSurface } from "@/components/task/task-detach-confirm-dialog";
import type { KanbanExternalLinkAvailability } from "./kanban-external-link-availability";
import type { TaskActionOptions } from "@/hooks/use-task-actions";
import type { Task, RepositoryChip, WorkflowStep, KanbanPresentation } from "./kanban-card-types";
export type { Task, RepositoryChip, WorkflowStep, KanbanPresentation };
export interface KanbanCardProps {
task: Task;
workspaceId: string | null;
presentation?: KanbanPresentation;
externalLinkAvailability: KanbanExternalLinkAvailability;
/** Display labels and hover paths of every repository linked to the task, primary first. */
repositoryChips?: RepositoryChip[];
onClick?: (task: Task) => void;
onEdit?: (task: Task) => void;
onDelete?: (task: Task, opts?: TaskActionOptions) => void;
onArchive?: (task: Task, opts?: TaskActionOptions) => void;
onOpenFullPage?: (task: Task) => void;
onMove?: (task: Task, targetStepId: string) => void;
steps?: WorkflowStep[];
showMaximizeButton?: boolean;
isDeleting?: boolean;
isArchiving?: boolean;
isSelected?: boolean;
selectedIds?: Set<string>;
onToggleSelect?: (taskId: string) => void;
/** Shift-click range select within this card's column. */
onRangeSelect?: (taskId: string) => void;
isMultiSelectMode?: boolean;
/** Keyboard reorder (REQ-TASKS-KANBAN-TASK-REORDERING-001.12): pick up/move/drop/cancel. */
onCardKeyDown?: (event: React.KeyboardEvent, task: Task) => void;
/** Whether this card is the one currently picked up for a keyboard reorder. */
isPickedUpForReorder?: boolean;
}
/**
* Cmd/Ctrl-click toggles a single card; Shift-click range-selects within the
* column; either modifier enters multi-select mode without the toggle button.
* A plain click toggles while in multi-select mode, otherwise previews/opens.
*/
/** @internal Exported for unit testing the four-branch click dispatch. */
export function dispatchKanbanCardClick(
e: React.MouseEvent,
taskId: string,
task: Task,
handlers: {
onToggleSelect?: (taskId: string) => void;
onRangeSelect?: (taskId: string) => void;
onClick?: (task: Task) => void;
isMultiSelectMode?: boolean;
},
): void {
// Only intercept a modifier click when the matching handler is wired, so a
// card rendered without selection handlers still opens on Cmd/Shift click.
if ((e.metaKey || e.ctrlKey) && handlers.onToggleSelect) {
e.preventDefault();
handlers.onToggleSelect(taskId);
return;
}
if (e.shiftKey && handlers.onRangeSelect) {
e.preventDefault();
handlers.onRangeSelect(taskId);
return;
}
if (handlers.isMultiSelectMode && handlers.onToggleSelect) {
handlers.onToggleSelect(taskId);
return;
}
handlers.onClick?.(task);
}
function KanbanCardFrame({
task,
presentation,
repositoryChips,
draggable,
menu,
isPreviewed,
isSelected,
isMultiSelectMode,
showMaximizeButton,
isDeleting,
isArchiving,
onArchive,
onClick,
onToggleSelect,
onOpenFullPage,
onKeyDown,
isPickedUpForReorder,
}: Pick<
KanbanCardProps,
| "task"
| "presentation"
| "repositoryChips"
| "isSelected"
| "isMultiSelectMode"
| "showMaximizeButton"
| "isDeleting"
| "isArchiving"
| "onArchive"
| "onToggleSelect"
| "onOpenFullPage"
| "isPickedUpForReorder"
> & {
draggable: ReturnType<typeof useDraggable>;
menu: KanbanCardMenuState;
isPreviewed: boolean;
onClick: (e: React.MouseEvent) => void;
onKeyDown?: (event: React.KeyboardEvent) => void;
}) {
return (
<>
<div ref={menu.detachAnchorRef} className="w-full">
<KanbanCardContextMenu entries={menu.contextMenuEntries}>
<KanbanCardShell
task={task}
presentation={presentation}
repositoryChips={repositoryChips}
attributes={draggable.attributes}
listeners={draggable.listeners}
setNodeRef={draggable.setNodeRef}
transform={draggable.transform}
isDragging={draggable.isDragging}
isPreviewed={isPreviewed}
isSelected={isSelected}
isMultiSelectMode={isMultiSelectMode}
showMaximizeButton={showMaximizeButton}
isDeleting={isDeleting}
isArchiving={isArchiving}
menuEntries={menu.dropdownMenuEntries}
menuTriggerRef={menu.detachFocusReturnRef}
onClick={onClick}
onCheckboxClick={(e) => {
e.stopPropagation();
onToggleSelect?.(task.id);
}}
onOpenFullPage={onOpenFullPage}
onKeyDown={onKeyDown}
isPickedUpForReorder={isPickedUpForReorder}
/>
</KanbanCardContextMenu>
</div>
<TaskDetachConfirmationSurface
taskId={task.id}
open={menu.showDetachConfirm}
anchorRef={menu.detachAnchorRef}
focusReturnRef={menu.detachFocusReturnRef}
taskTitle={task.title}
sharesParentWorkspace={task.workspaceMode === "inherit_parent"}
onOpenChange={menu.setShowDetachConfirm}
onConfirm={menu.handleDetachConfirm}
/>
<TaskArchiveConfirmation
open={menu.showArchiveConfirm}
anchorRef={menu.archiveAnchorRef}
focusReturnRef={menu.archiveFocusReturnRef}
taskTitle={task.title}
taskId={task.id}
executorType={task.primaryExecutorType}
isArchiving={isArchiving}
forceDialog={presentation === "mobile"}
onOpenChange={menu.setShowArchiveConfirm}
onConfirm={({ cascade }) => onArchive?.(task, { cascade })}
/>
</>
);
}
export function KanbanCard({
task,
workspaceId,
presentation = "desktop",
externalLinkAvailability,
repositoryChips,
onClick,
onEdit,
onDelete,
onArchive,
onOpenFullPage,
onMove,
steps,
showMaximizeButton = false,
isDeleting,
isArchiving,
isSelected,
selectedIds,
onToggleSelect,
onRangeSelect,
isMultiSelectMode,
onCardKeyDown,
isPickedUpForReorder,
}: KanbanCardProps) {
const draggable = useDraggable({
id: task.id,
disabled: presentation === "mobile" || isMultiSelectMode,
});
const isPreviewed = useAppStore((state) => state.kanbanPreviewedTaskId === task.id);
const repositories = useActiveWorkspaceRepositories();
const menu = useKanbanCardMenus({
task,
workspaceId,
presentation,
externalLinkAvailability,
steps,
isDeleting,
isArchiving,
isSelected,
selectedIds,
onEdit,
onDelete,
onArchive,
onMove,
});
const handleClick = (e: React.MouseEvent) =>
dispatchKanbanCardClick(e, task.id, task, {
onToggleSelect,
onRangeSelect,
onClick,
isMultiSelectMode,
});
return (
<>
<KanbanCardFrame
task={task}
presentation={presentation}
repositoryChips={repositoryChips}
draggable={draggable}
menu={menu}
isPreviewed={isPreviewed}
isSelected={isSelected}
isMultiSelectMode={isMultiSelectMode}
showMaximizeButton={showMaximizeButton}
isDeleting={isDeleting}
isArchiving={isArchiving}
onArchive={onArchive}
onClick={handleClick}
onToggleSelect={onToggleSelect}
onOpenFullPage={onOpenFullPage}
onKeyDown={onCardKeyDown ? (event) => onCardKeyDown(event, task) : undefined}
isPickedUpForReorder={isPickedUpForReorder}
/>
<KanbanCardDialogs
task={task}
workspaceId={workspaceId}
repositories={repositories}
menu={menu}
isDeleting={isDeleting}
onDelete={onDelete}
/>
</>
);
}