Files
kandev/apps/web/hooks/use-task-actions-menu.ts
2026-09-25 10:30:33 +01:00

327 lines
11 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef } from "react";
import type { KanbanPresentation } from "@/components/kanban-card";
import type { KanbanCardMoveTargets } from "@/components/kanban-card-menu-items";
import type { WorkflowStep } from "@/components/kanban-card";
import { useTaskActionsMenuMoveTargets } from "@/hooks/use-task-actions-menu-move-targets";
import { useTaskWorkflowMove } from "@/hooks/use-task-workflow-move";
import {
useTaskMenuDialogState,
useTaskMenuEditDialogState,
} from "@/hooks/use-task-menu-dialog-state";
import { useDetachTask } from "@/hooks/use-detach-task";
import { useUpdateTaskPriority } from "@/hooks/use-update-task-priority";
import { useTaskPluginLinkActions } from "@/components/task/task-session-sidebar-link-actions";
import { useKanbanExternalLinkAvailability } from "@/components/kanban-external-link-availability";
import { usePluginRegistry } from "@/lib/plugins/registry";
import type { PluginTaskMenuContext } from "@/lib/plugins/types";
import type { TaskPriority, TaskState } from "@/lib/types/http";
import { buildTaskActionsMenuEntries } from "@/lib/kanban/task-actions-menu-entries";
export type TaskActionsMenuBoardRow = {
id: string;
title: string;
description?: string;
workflowStepId?: string | null;
state?: TaskState;
priority?: TaskPriority;
repositoryId?: string;
repositories?: Array<{
id: string;
repository_id: string;
position: number;
base_branch?: string;
checkout_branch?: string;
branch_policy_id?: string;
branch_policy_name?: string;
branch_policy_base_branch?: string;
branch_policy_branch_template?: string;
branch_policy_pull_request_target?: string;
}>;
parentTaskId?: string | null;
primaryExecutorType?: string | null;
workspaceMode?: "inherit_parent" | "new_workspace" | "shared_group";
};
function resolveTaskActionsMenuTier(
isArchived: boolean,
boardRow: TaskActionsMenuBoardRow | null,
): "archived" | "normal" | "unresolved-row" {
if (isArchived) return "archived";
return boardRow ? "normal" : "unresolved-row";
}
function buildLinkHandlers(
dialogs: ReturnType<typeof useTaskMenuDialogState>,
availability: ReturnType<typeof useKanbanExternalLinkAvailability>,
) {
return {
onLinkPullRequest: () => dialogs.setShowPRDialog(true),
onLinkIssue: () => dialogs.setShowIssueDialog(true),
onLinkMergeRequest: availability.gitlab ? () => dialogs.setShowMRDialog(true) : undefined,
onLinkJiraTicket: availability.jira ? () => dialogs.setExternalLinkProvider("jira") : undefined,
onLinkLinearIssue: availability.linear
? () => dialogs.setExternalLinkProvider("linear")
: undefined,
onLinkSentryIssue: availability.sentry
? () => dialogs.setExternalLinkProvider("sentry")
: undefined,
};
}
type ComputeEntriesArgs = {
taskId: string | null;
tier: ReturnType<typeof resolveTaskActionsMenuTier>;
boardRow: TaskActionsMenuBoardRow | null;
moveTargets: KanbanCardMoveTargets;
disabled: boolean;
isDeleting?: boolean;
isArchiving?: boolean;
isDetaching: boolean;
editDialog: ReturnType<typeof useTaskMenuEditDialogState>;
dialogs: ReturnType<typeof useTaskMenuDialogState>;
requestArchiveConfirmation: () => void;
requestDetachConfirmation: () => void;
linkHandlers: ReturnType<typeof buildLinkHandlers>;
pluginLinkActions: ReturnType<typeof useTaskPluginLinkActions>;
onMoveToStep: (stepId: string) => void;
onChangeWorkflow: () => void;
pluginMenuContext: PluginTaskMenuContext;
onSelectPriority: (priority: TaskPriority) => void;
};
function useTaskPrioritySelector(taskId: string | null) {
const updateTaskPriority = useUpdateTaskPriority();
return useCallback(
(priority: TaskPriority) => {
if (!taskId) return;
void updateTaskPriority(taskId, priority);
},
[taskId, updateTaskPriority],
);
}
function computeTaskActionsMenuEntries(args: ComputeEntriesArgs) {
if (args.taskId == null) return [];
const { boardRow, moveTargets, editDialog, dialogs } = args;
return buildTaskActionsMenuEntries(args.tier, {
currentWorkflowId: moveTargets.currentWorkflowId,
currentStepId: boardRow?.workflowStepId ?? null,
workflows: moveTargets.workflowItems,
stepsByWorkflowId: moveTargets.stepsByWorkflowId,
disabled: args.disabled,
isDeleting: args.isDeleting,
isArchiving: args.isArchiving,
isDetaching: args.isDetaching,
parentTaskId: boardRow?.parentTaskId,
currentPriority: boardRow?.priority,
onSelectPriority: args.onSelectPriority,
onEdit: boardRow ? () => editDialog.setShowEditDialog(true) : undefined,
onArchive: args.requestArchiveConfirmation,
onDelete: () => dialogs.setShowDeleteConfirm(true),
onDetach: boardRow?.parentTaskId ? args.requestDetachConfirmation : undefined,
...args.linkHandlers,
pluginLinkActions: args.pluginLinkActions,
onMoveToStep: args.onMoveToStep,
onChangeWorkflow: args.onChangeWorkflow,
pluginMenuContext: args.pluginMenuContext,
});
}
/**
* Keeps every confirm/link dialog scoped to the subject that opened it
* (AC-TASKS-TASK-ACTIONS-MENU-004.5a's no-retarget rule): resets them all on
* a subject-identity change, and guards the two 300ms-delayed confirmations
* so a subject swap during that window silently drops the request instead of
* opening for the new subject.
*/
function useSubjectScopedDialogs(
taskId: string | null,
dialogs: ReturnType<typeof useTaskMenuDialogState>,
editDialog: ReturnType<typeof useTaskMenuEditDialogState>,
) {
const taskIdRef = useRef(taskId);
taskIdRef.current = taskId;
const closeDialogs = useCallback(() => {
dialogs.closeAll();
editDialog.closeAll();
}, [dialogs, editDialog]);
// The preview panel re-renders this hook with a new `task` prop without
// unmounting, and the confirm/link popovers are non-modal, so nothing else
// would close a dialog opened for the previous subject.
useEffect(() => {
closeDialogs();
// Only the subject identity should trigger this; `closeDialogs` is stable.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [taskId]);
const requestDetachConfirmation = useCallback(() => {
// Let Radix finish the menu's pointer sequence before the non-modal
// popover opens; otherwise the initiating menu event is an outside click.
const requestedForTaskId = taskId;
window.setTimeout(() => {
if (taskIdRef.current !== requestedForTaskId) return;
dialogs.setShowDetachConfirm(true);
}, 300);
}, [dialogs, taskId]);
const requestArchiveConfirmation = useCallback(() => {
const requestedForTaskId = taskId;
window.setTimeout(() => {
if (taskIdRef.current !== requestedForTaskId) return;
dialogs.setShowArchiveConfirm(true);
}, 300);
}, [dialogs, taskId]);
const requestChangeWorkflow = useCallback(() => {
const requestedForTaskId = taskId;
window.setTimeout(() => {
if (taskIdRef.current !== requestedForTaskId) return;
dialogs.setShowChangeWorkflow(true);
}, 300);
}, [dialogs.setShowChangeWorkflow, taskId]);
return {
closeDialogs,
requestDetachConfirmation,
requestArchiveConfirmation,
requestChangeWorkflow,
};
}
type UseTaskActionsMenuArgs = {
taskId: string | null;
taskTitle: string;
workspaceId: string | null;
presentation?: KanbanPresentation;
isArchived: boolean;
/** The subject's board row, when resolvable. Null demotes to the identifier-only tier. */
boardRow: TaskActionsMenuBoardRow | null;
/** Current-workflow steps for the Move to submenu. */
steps?: WorkflowStep[];
isArchiving?: boolean;
isDeleting?: boolean;
onArchive: (opts: { cascade: boolean }) => void | Promise<void>;
onDelete: (opts: { cascade: boolean }) => void | Promise<void>;
/** The subject's own workflow step, independent of `boardRow` (AC-002.4b's
* plugin task-menu context must not go stale just because the board
* excludes the subject, e.g. an archived task). Falls back to
* `boardRow?.workflowStepId` for callers with no independent source. */
subjectWorkflowStepId?: string | null;
};
export function useTaskActionsMenu({
taskId,
taskTitle,
workspaceId,
presentation = "desktop",
isArchived,
boardRow,
steps,
isArchiving,
isDeleting,
onArchive,
onDelete,
subjectWorkflowStepId,
}: UseTaskActionsMenuArgs) {
const dialogs = useTaskMenuDialogState();
const editDialog = useTaskMenuEditDialogState();
const { detachTask, detachingTaskId } = useDetachTask();
const onSelectPriority = useTaskPrioritySelector(taskId);
const isDetaching = taskId != null && detachingTaskId === taskId;
const externalLinkAvailability = useKanbanExternalLinkAvailability(workspaceId);
const pluginLinkActions = useTaskPluginLinkActions(taskId ?? "", boardRow?.repositories ?? []);
// Re-render on any registry mutation so a menu action a plugin registers or
// disables at runtime appears/disappears in an already-open menu (AC-002.8).
usePluginRegistry();
const moveTargets = useTaskActionsMenuMoveTargets(taskId ?? "", steps);
const moveTasks = useTaskWorkflowMove();
const triggerRef = useRef<HTMLButtonElement>(null);
const disabled = Boolean(isArchiving || isDeleting || isDetaching);
const {
closeDialogs,
requestDetachConfirmation,
requestArchiveConfirmation,
requestChangeWorkflow,
} = useSubjectScopedDialogs(taskId, dialogs, editDialog);
const handleDetachConfirm = useCallback(async () => {
if (!taskId) return;
try {
await detachTask(taskId);
dialogs.setShowDetachConfirm(false);
} catch (error) {
console.error("Failed to detach task:", error);
}
}, [detachTask, dialogs, taskId]);
const onMoveToStep = useCallback(
(stepId: string) => {
if (!taskId || !moveTargets.currentWorkflowId) return;
void moveTasks([taskId], moveTargets.currentWorkflowId, stepId, "step").catch(() => {
// useTaskWorkflowMove already shows the failure toast.
});
},
[moveTasks, moveTargets.currentWorkflowId, taskId],
);
const pluginMenuContext: PluginTaskMenuContext = {
workspaceId: workspaceId ?? "",
taskId: taskId ?? "",
taskTitle,
workflowStepId: subjectWorkflowStepId ?? boardRow?.workflowStepId ?? null,
presentation,
};
const tier = resolveTaskActionsMenuTier(Boolean(isArchived), boardRow);
const linkHandlers = buildLinkHandlers(dialogs, externalLinkAvailability);
const entries = computeTaskActionsMenuEntries({
taskId,
tier,
boardRow,
moveTargets,
disabled,
isDeleting,
isArchiving,
isDetaching,
editDialog,
dialogs,
requestArchiveConfirmation,
requestDetachConfirmation,
linkHandlers,
pluginLinkActions,
onMoveToStep,
onChangeWorkflow: requestChangeWorkflow,
pluginMenuContext,
onSelectPriority,
});
// Strip each state slice's own `closeAll` before spreading: `closeDialogs`
// above is the single composed reset callers should use, and leaving both
// `dialogs.closeAll`/`editDialog.closeAll` in the spread would collide under
// one `closeAll` key that only resets the edit dialog (last spread wins).
const { closeAll: _dialogsCloseAll, ...dialogsRest } = dialogs;
const { closeAll: _editDialogCloseAll, ...editDialogRest } = editDialog;
return {
entries,
...dialogsRest,
...editDialogRest,
isDetaching,
handleDetachConfirm,
triggerRef,
currentWorkflowId: moveTargets.currentWorkflowId,
stepsByWorkflowId: moveTargets.stepsByWorkflowId,
onConfirmArchive: onArchive,
onConfirmDelete: onDelete,
closeDialogs,
};
}