982cdb63eb
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
174 lines
6.4 KiB
TypeScript
174 lines
6.4 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { getWebSocketClient } from "@/lib/ws/connection";
|
|
import { TaskActionsMenuTrigger } from "@/components/task/task-actions-menu-trigger";
|
|
import { TaskActionsMenuDialogs } from "@/components/task/task-actions-menu-dialogs";
|
|
import { useTaskActionsMenu, type TaskActionsMenuBoardRow } from "@/hooks/use-task-actions-menu";
|
|
import { useArchiveAndSwitchTask, useDeleteAndSwitchTask } from "@/hooks/use-task-actions";
|
|
|
|
/**
|
|
* The detail surface has no board of its own to lean on, so a workflow move
|
|
* (Move to / Send to workflow) landing the subject in a workflow this client
|
|
* has never tracked would otherwise leave it absent from every board
|
|
* collection even though it still exists. Subscribing to the task directly
|
|
* guarantees its `task.updated` reaches this client regardless of workflow
|
|
* tracking, and the resulting cache update (removal from the old workflow,
|
|
* placeholder-snapshot insertion into the new one) lands in the same state
|
|
* transition, so `existsInBoard` below never observes a false gap for it.
|
|
*/
|
|
function useSubscribeToTask(taskId: string | null) {
|
|
useEffect(() => {
|
|
if (!taskId) return;
|
|
const client = getWebSocketClient();
|
|
if (!client) return;
|
|
const unsubscribe = client.subscribe(taskId);
|
|
return () => {
|
|
unsubscribe();
|
|
};
|
|
}, [taskId]);
|
|
}
|
|
|
|
/**
|
|
* Closes the menu only when the subject is genuinely gone -- deleted, or
|
|
* archived by another actor (AC-TASKS-TASK-ACTIONS-MENU-004.5). This
|
|
* deliberately listens for the task's own lifecycle events rather than
|
|
* inferring removal from board-collection presence: a task's board row can
|
|
* also disappear while the task is still alive (e.g. a move to a workflow
|
|
* this client has not tracked as a snapshot yet), which is the unresolved-row
|
|
* case AC-TASKS-TASK-ACTIONS-MENU-002.6/004.1c governs and must demote
|
|
* entries in place rather than close the menu. Board presence and "genuinely
|
|
* gone" happen to coincide for an ordinary delete/archive, but they are not
|
|
* the same predicate, so this hook never reads board state at all. Also
|
|
* resets any pending confirm/link dialog: a subject that is gone must not
|
|
* leave a stale, still-actionable confirmation open
|
|
* (AC-TASKS-TASK-ACTIONS-MENU-004.5a's no-retarget rule applies here too).
|
|
*/
|
|
function useCloseMenuOnTaskGone(
|
|
taskId: string | null,
|
|
setOpen: (open: boolean) => void,
|
|
closeDialogs: () => void,
|
|
) {
|
|
useSubscribeToTask(taskId);
|
|
useEffect(() => {
|
|
if (!taskId) return;
|
|
const client = getWebSocketClient();
|
|
if (!client) return;
|
|
const close = () => {
|
|
setOpen(false);
|
|
closeDialogs();
|
|
};
|
|
const unsubscribeDeleted = client.on("task.deleted", (message) => {
|
|
if (message.payload.task_id === taskId) close();
|
|
});
|
|
const unsubscribeUpdated = client.on("task.updated", (message) => {
|
|
if (message.payload.task_id === taskId && message.payload.archived_at) close();
|
|
});
|
|
return () => {
|
|
unsubscribeDeleted();
|
|
unsubscribeUpdated();
|
|
};
|
|
}, [taskId, setOpen, closeDialogs]);
|
|
}
|
|
|
|
type TaskTopBarActionsMenuProps = {
|
|
/** The subject's own identifier/title, independent of `boardRow`: known as
|
|
* soon as the detail page has loaded the task, even when the board row is
|
|
* unresolvable, so the identifier-only tier (AC-TASKS-TASK-ACTIONS-MENU-002.5)
|
|
* stays reachable instead of the trigger disappearing entirely. */
|
|
taskId: string | null;
|
|
taskTitle: string;
|
|
boardRow: TaskActionsMenuBoardRow | null;
|
|
workspaceId: string | null;
|
|
isArchived?: boolean;
|
|
/** The subject's own workflow step and executor type, independent of
|
|
* `boardRow`: the board excludes archived tasks and can lag/miss a
|
|
* cross-workflow row, but plugin context and the delete confirmation's
|
|
* executor-specific cleanup copy should not go stale just because the
|
|
* board row is momentarily or permanently unresolvable. */
|
|
subjectWorkflowStepId?: string | null;
|
|
subjectPrimaryExecutorType?: string | null;
|
|
};
|
|
|
|
/** Tracks in-flight state for a one-shot switch-after-action call, since
|
|
* `useArchiveAndSwitchTask`/`useDeleteAndSwitchTask` carry no pending flag of
|
|
* their own (unlike `useTaskCRUD`, which the board and preview surfaces use). */
|
|
function useTrackedSwitchAfterAction(
|
|
run: (taskId: string, opts?: { cascade?: boolean }) => Promise<unknown>,
|
|
) {
|
|
const [pending, setPending] = useState(false);
|
|
const invoke = useCallback(
|
|
async (taskId: string, opts: { cascade: boolean }) => {
|
|
setPending(true);
|
|
try {
|
|
await run(taskId, opts);
|
|
} finally {
|
|
setPending(false);
|
|
}
|
|
},
|
|
[run],
|
|
);
|
|
return { pending, invoke };
|
|
}
|
|
|
|
/**
|
|
* Detail top bar's "More options" trigger. Unlike the board and preview
|
|
* surfaces, the detail surface has no board subscription to prune
|
|
* optimistically, so a confirmed Archive or Delete uses the same
|
|
* archive-and-switch / switch-to-next-task outcome as every other
|
|
* detail-surface entry point (AC-TASKS-TASK-ACTIONS-MENU-003.4/003.5).
|
|
*/
|
|
export function TaskTopBarActionsMenu({
|
|
taskId,
|
|
taskTitle,
|
|
boardRow,
|
|
workspaceId,
|
|
isArchived,
|
|
subjectWorkflowStepId,
|
|
subjectPrimaryExecutorType,
|
|
}: TaskTopBarActionsMenuProps) {
|
|
const [open, setOpen] = useState(false);
|
|
const archiveAndSwitch = useArchiveAndSwitchTask();
|
|
const deleteAndSwitch = useDeleteAndSwitchTask();
|
|
const archiving = useTrackedSwitchAfterAction(archiveAndSwitch);
|
|
const deleting = useTrackedSwitchAfterAction(deleteAndSwitch);
|
|
|
|
const menu = useTaskActionsMenu({
|
|
taskId,
|
|
taskTitle,
|
|
workspaceId,
|
|
isArchived: Boolean(isArchived),
|
|
boardRow,
|
|
isArchiving: archiving.pending,
|
|
isDeleting: deleting.pending,
|
|
onArchive: (opts) => (taskId ? archiving.invoke(taskId, opts) : undefined),
|
|
onDelete: (opts) => (taskId ? deleting.invoke(taskId, opts) : undefined),
|
|
subjectWorkflowStepId,
|
|
});
|
|
useCloseMenuOnTaskGone(taskId, setOpen, menu.closeDialogs);
|
|
|
|
if (!taskId) return null;
|
|
|
|
return (
|
|
<>
|
|
<TaskActionsMenuTrigger
|
|
entries={menu.entries}
|
|
testId="task-topbar-actions-menu"
|
|
triggerRef={menu.triggerRef}
|
|
open={open}
|
|
onOpenChange={setOpen}
|
|
/>
|
|
<TaskActionsMenuDialogs
|
|
taskId={taskId}
|
|
taskTitle={taskTitle}
|
|
workspaceId={workspaceId}
|
|
boardRow={boardRow}
|
|
isArchiving={archiving.pending}
|
|
isDeleting={deleting.pending}
|
|
menu={menu}
|
|
subjectExecutorType={subjectPrimaryExecutorType}
|
|
/>
|
|
</>
|
|
);
|
|
}
|