feat(web): add task actions menu to preview panel and detail top bar (#3395)
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
@@ -21,6 +21,7 @@ export type ActionConfirmPopoverProps = {
|
||||
anchorRef: RefObject<HTMLElement | null>;
|
||||
focusReturnRef?: RefObject<HTMLElement | null>;
|
||||
focusBoundaryRef?: RefObject<HTMLElement | null>;
|
||||
restoreFocusOnConfirm?: boolean;
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
cancelLabel: ReactNode;
|
||||
@@ -53,6 +54,7 @@ export function ActionConfirmPopover({
|
||||
anchorRef,
|
||||
focusReturnRef,
|
||||
focusBoundaryRef,
|
||||
restoreFocusOnConfirm = false,
|
||||
title,
|
||||
description,
|
||||
cancelLabel,
|
||||
@@ -129,6 +131,7 @@ export function ActionConfirmPopover({
|
||||
cancelRef={cancelRef}
|
||||
focusReturnRef={focusReturnRef}
|
||||
focusBoundaryRef={focusBoundaryRef}
|
||||
restoreFocusOnConfirm={restoreFocusOnConfirm}
|
||||
confirmedRef={confirmedRef}
|
||||
anchorRef={anchorRef}
|
||||
onCancel={() => handleOpenChange(false)}
|
||||
@@ -155,6 +158,7 @@ type ActionConfirmPopoverContentProps = {
|
||||
cancelRef: RefObject<HTMLButtonElement | null>;
|
||||
focusReturnRef?: RefObject<HTMLElement | null>;
|
||||
focusBoundaryRef?: RefObject<HTMLElement | null>;
|
||||
restoreFocusOnConfirm: boolean;
|
||||
confirmedRef: { current: boolean };
|
||||
anchorRef: RefObject<HTMLElement | null>;
|
||||
onCancel: () => void;
|
||||
@@ -178,6 +182,7 @@ function ActionConfirmPopoverContent({
|
||||
cancelRef,
|
||||
focusReturnRef,
|
||||
focusBoundaryRef,
|
||||
restoreFocusOnConfirm,
|
||||
confirmedRef,
|
||||
anchorRef,
|
||||
onCancel,
|
||||
@@ -209,7 +214,7 @@ function ActionConfirmPopoverContent({
|
||||
}}
|
||||
onCloseAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
if (!confirmedRef.current) {
|
||||
if (!confirmedRef.current || restoreFocusOnConfirm) {
|
||||
const focusReturnTarget = focusReturnRef?.current ?? null;
|
||||
if (isConnected(focusReturnTarget)) focusReturnTarget.focus();
|
||||
else if (isConnected(anchorRef.current)) anchorRef.current.focus();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState, type RefObject } from "react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -16,6 +16,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import { createTaskMR } from "@/lib/api/domains/gitlab-api";
|
||||
import { createFocusReturnHandler } from "@/lib/dialog-focus-return";
|
||||
import type { Repository } from "@/lib/types/http";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -111,6 +112,7 @@ export function TaskMRLinkDialog({
|
||||
workspaceId,
|
||||
taskRepositories,
|
||||
repositories,
|
||||
focusReturnRef,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -118,6 +120,9 @@ export function TaskMRLinkDialog({
|
||||
workspaceId: string;
|
||||
taskRepositories: TaskRepositoryLink[];
|
||||
repositories: Repository[];
|
||||
/** Element to return keyboard focus to on close (AC-TASKS-TASK-ACTIONS-MENU-001.12).
|
||||
* Omitted callers keep Radix's default restore-to-previously-focused-element behavior. */
|
||||
focusReturnRef?: RefObject<HTMLElement | null>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const options = useRepositoryOptions(repositories, taskRepositories);
|
||||
@@ -168,7 +173,10 @@ export function TaskMRLinkDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogContent
|
||||
className="sm:max-w-md"
|
||||
onCloseAutoFocus={createFocusReturnHandler(focusReturnRef)}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("gitlab:linkGitlabMergeRequest")}</DialogTitle>
|
||||
<DialogDescription>{t("gitlab:pasteAMergeRequestUrlFrom")}</DialogDescription>
|
||||
|
||||
@@ -20,12 +20,15 @@ export function buildEditMenuEntry({
|
||||
onEdit,
|
||||
disabled,
|
||||
context,
|
||||
forceFlat,
|
||||
}: {
|
||||
onEdit?: () => void;
|
||||
disabled?: boolean;
|
||||
context: PluginTaskMenuContext;
|
||||
/** Skip the plugin `edit`-group lookup and always return the flat item. */
|
||||
forceFlat?: boolean;
|
||||
}): KanbanCardMenuEntry {
|
||||
const pluginActions = visiblePluginMenuActions("edit", context);
|
||||
const pluginActions = forceFlat ? [] : visiblePluginMenuActions("edit", context);
|
||||
|
||||
const icon = <IconPencil className="mr-2 h-4 w-4" />;
|
||||
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { act, render, renderHook, screen, fireEvent } from "@testing-library/react";
|
||||
import { IconFlag } from "@tabler/icons-react";
|
||||
import type { StoreApi } from "zustand";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@kandev/ui/dropdown-menu";
|
||||
import { pluginRegistry } from "@/lib/plugins/registry";
|
||||
import { StateProvider, useAppStoreApi } from "@/components/state-provider";
|
||||
import type { AppState } from "@/lib/state/store";
|
||||
import {
|
||||
buildKanbanCardMenuEntries,
|
||||
KanbanCardDropdownMenuItems,
|
||||
useKanbanCardMoveTargets,
|
||||
type KanbanCardMenuEntry,
|
||||
} from "./kanban-card-menu-items";
|
||||
|
||||
@@ -413,3 +417,152 @@ describe("buildKanbanCardMenuEntries — priority action", () => {
|
||||
expect(onSelectPriority).toHaveBeenCalledWith("critical");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useKanbanCardMoveTargets — explicit steps (AC-TASKS-TASK-ACTIONS-MENU-002.3b)", () => {
|
||||
const WORKFLOW_ID = "wf-1";
|
||||
const TASK_ID = "task-1";
|
||||
const HIDDEN_STEP_ID = "step-hidden";
|
||||
|
||||
function wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<StateProvider
|
||||
initialState={
|
||||
{
|
||||
kanbanMulti: {
|
||||
snapshots: {
|
||||
[WORKFLOW_ID]: {
|
||||
workflowId: WORKFLOW_ID,
|
||||
workflowName: "Workflow 1",
|
||||
steps: [
|
||||
{ id: "step-a", title: "Todo", color: "blue", position: 0 },
|
||||
{ id: HIDDEN_STEP_ID, title: "Hidden step", color: "gray", position: 1 },
|
||||
{ id: "step-b", title: "Done", color: "green", position: 2 },
|
||||
],
|
||||
tasks: [
|
||||
{
|
||||
id: TASK_ID,
|
||||
workflowId: WORKFLOW_ID,
|
||||
workflowStepId: "step-a",
|
||||
title: "Task 1",
|
||||
position: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
},
|
||||
} as never
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</StateProvider>
|
||||
);
|
||||
}
|
||||
|
||||
it("uses the caller's explicit steps as-is for the current workflow's entry", () => {
|
||||
const explicitSteps = [{ id: HIDDEN_STEP_ID, title: "Hidden step", color: "gray" }];
|
||||
const { result } = renderHook(() => useKanbanCardMoveTargets(TASK_ID, explicitSteps), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
const stepIds = result.current.stepsByWorkflowId[WORKFLOW_ID].map((step) => step.id);
|
||||
expect(stepIds).toEqual([HIDDEN_STEP_ID]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useKanbanCardMoveTargets — card hot path has no kanban.tasks/hiddenWorkflowStepIds subscription", () => {
|
||||
const WORKFLOW_ID = "wf-1";
|
||||
const TASK_ID = "task-1";
|
||||
const CARD_STEPS = [{ id: "step-a", title: "Todo", color: "blue" }];
|
||||
|
||||
let capturedStore: StoreApi<AppState> | null = null;
|
||||
|
||||
function StoreCapture() {
|
||||
capturedStore = useAppStoreApi();
|
||||
return null;
|
||||
}
|
||||
|
||||
function wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<StateProvider
|
||||
initialState={
|
||||
{
|
||||
kanban: { tasks: [] },
|
||||
kanbanMulti: {
|
||||
snapshots: {
|
||||
[WORKFLOW_ID]: {
|
||||
workflowId: WORKFLOW_ID,
|
||||
workflowName: "Workflow 1",
|
||||
steps: [{ id: "step-a", title: "Todo", color: "blue", position: 0 }],
|
||||
tasks: [
|
||||
{
|
||||
id: TASK_ID,
|
||||
workflowId: WORKFLOW_ID,
|
||||
workflowStepId: "step-a",
|
||||
title: "Task 1",
|
||||
position: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
},
|
||||
userSettings: { hiddenWorkflowStepIds: {} },
|
||||
} as never
|
||||
}
|
||||
>
|
||||
<StoreCapture />
|
||||
{children}
|
||||
</StateProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// The card's own render path already knows its workflow's steps (it passes
|
||||
// `steps` explicitly), so this hook must not force every card to re-render
|
||||
// on unrelated `kanban.tasks`/`hiddenWorkflowStepIds` writes just to serve
|
||||
// the preview/detail surfaces' fallback (system design: card hook keeps its
|
||||
// original subscription set).
|
||||
it("does not recompute when kanban.tasks or hiddenWorkflowStepIds change while the caller passes explicit steps", () => {
|
||||
capturedStore = null;
|
||||
let renderCount = 0;
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
renderCount++;
|
||||
return useKanbanCardMoveTargets(TASK_ID, CARD_STEPS);
|
||||
},
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.currentWorkflowId).toBe(WORKFLOW_ID);
|
||||
const countAfterMount = renderCount;
|
||||
|
||||
act(() => {
|
||||
capturedStore!.setState((state) => ({
|
||||
kanban: {
|
||||
...state.kanban,
|
||||
tasks: [
|
||||
...state.kanban.tasks,
|
||||
{
|
||||
id: "other-task",
|
||||
workflowId: WORKFLOW_ID,
|
||||
workflowStepId: "step-a",
|
||||
title: "Other",
|
||||
position: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
}));
|
||||
});
|
||||
expect(renderCount).toBe(countAfterMount);
|
||||
|
||||
act(() => {
|
||||
capturedStore!.setState((state) => ({
|
||||
userSettings: {
|
||||
...state.userSettings,
|
||||
hiddenWorkflowStepIds: { [WORKFLOW_ID]: ["step-a"] },
|
||||
},
|
||||
}));
|
||||
});
|
||||
expect(renderCount).toBe(countAfterMount);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
} from "@/lib/tasks/task-priority";
|
||||
import type { TaskPriority } from "@/lib/types/http";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { sortWorkflowStepsByPosition } from "@/lib/kanban/workflow-step-order";
|
||||
import { buildLinkSubmenu } from "./kanban-card-link-submenu";
|
||||
import type { PluginIcon, PluginTaskMenuContext } from "@/lib/plugins/types";
|
||||
import { buildEditMenuEntry } from "./kanban-card-edit-submenu";
|
||||
@@ -91,7 +92,7 @@ export type KanbanPluginLinkAction = {
|
||||
onSelect: () => void;
|
||||
};
|
||||
|
||||
type BuildKanbanCardMenuEntriesArgs = {
|
||||
export type BuildKanbanCardMenuEntriesArgs = {
|
||||
currentWorkflowId?: string | null;
|
||||
currentStepId?: string | null;
|
||||
workflows: TaskMoveWorkflow[];
|
||||
@@ -119,6 +120,12 @@ type BuildKanbanCardMenuEntriesArgs = {
|
||||
onSendToWorkflow?: (workflowId: string, stepId: string) => void;
|
||||
/** Defaults to an empty-id context (no visible plugin actions match it in practice). */
|
||||
pluginMenuContext?: PluginTaskMenuContext;
|
||||
/**
|
||||
* Forces the flat Edit item regardless of registered plugin `edit`-group
|
||||
* actions. Group `edit` is a card-only plugin contract; surfaces outside
|
||||
* the card set this so they never present the submenu form.
|
||||
*/
|
||||
forceFlatEdit?: boolean;
|
||||
};
|
||||
|
||||
const EMPTY_PLUGIN_MENU_CONTEXT: PluginTaskMenuContext = {
|
||||
@@ -129,7 +136,7 @@ const EMPTY_PLUGIN_MENU_CONTEXT: PluginTaskMenuContext = {
|
||||
presentation: "desktop",
|
||||
};
|
||||
|
||||
function resolvePluginMenuContext(context?: PluginTaskMenuContext): PluginTaskMenuContext {
|
||||
export function resolvePluginMenuContext(context?: PluginTaskMenuContext): PluginTaskMenuContext {
|
||||
return context ?? EMPTY_PLUGIN_MENU_CONTEXT;
|
||||
}
|
||||
|
||||
@@ -345,6 +352,7 @@ export function buildKanbanCardMenuEntries({
|
||||
onMoveToStep,
|
||||
onSendToWorkflow,
|
||||
pluginMenuContext,
|
||||
forceFlatEdit,
|
||||
}: BuildKanbanCardMenuEntriesArgs): KanbanCardMenuEntry[] {
|
||||
const visibleWorkflows = workflows.filter((workflow) => !workflow.hidden);
|
||||
const currentSteps = currentWorkflowId ? (stepsByWorkflowId[currentWorkflowId] ?? []) : [];
|
||||
@@ -354,6 +362,7 @@ export function buildKanbanCardMenuEntries({
|
||||
onEdit,
|
||||
disabled: isProcessing,
|
||||
context: resolvePluginMenuContext(pluginMenuContext),
|
||||
forceFlat: forceFlatEdit,
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -411,7 +420,7 @@ export function buildKanbanCardMenuEntries({
|
||||
return entries;
|
||||
}
|
||||
|
||||
function buildArchiveEntry({
|
||||
export function buildArchiveEntry({
|
||||
isArchiving,
|
||||
isProcessing,
|
||||
onArchive,
|
||||
@@ -434,7 +443,7 @@ function buildArchiveEntry({
|
||||
};
|
||||
}
|
||||
|
||||
function buildDeleteEntry({
|
||||
export function buildDeleteEntry({
|
||||
isDeleting,
|
||||
isProcessing,
|
||||
onDelete,
|
||||
@@ -482,6 +491,16 @@ function buildDetachEntry({
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The card's hot render path: a caller with column context already passes a
|
||||
* `steps` list pre-filtered for this workflow, and its `currentWorkflowId`
|
||||
* always resolves from `kanbanMulti.snapshots`. Kept to exactly these two
|
||||
* subscriptions (system design: no `kanban.tasks`/`hiddenWorkflowStepIds`
|
||||
* reads here) so unrelated writes to either slice never re-render every
|
||||
* visible card. Callers without column context (preview panel, detail top
|
||||
* bar) use `useTaskActionsMenuMoveTargets` instead, which layers the
|
||||
* flat-list and hidden-step fallbacks this hook intentionally omits.
|
||||
*/
|
||||
export function useKanbanCardMoveTargets(
|
||||
taskId: string,
|
||||
steps?: WorkflowStep[],
|
||||
@@ -513,15 +532,12 @@ export function useKanbanCardMoveTargets(
|
||||
const stepsByWorkflowId = useMemo<Record<string, TaskMoveStep[]>>(() => {
|
||||
const result: Record<string, TaskMoveStep[]> = {};
|
||||
for (const [workflowId, snapshotSteps] of Object.entries(snapshotStepsByWorkflowId)) {
|
||||
result[workflowId] = snapshotSteps
|
||||
.slice()
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((step) => ({
|
||||
id: step.id,
|
||||
title: step.title,
|
||||
color: step.color,
|
||||
events: step.events,
|
||||
}));
|
||||
result[workflowId] = sortWorkflowStepsByPosition(snapshotSteps).map((step) => ({
|
||||
id: step.id,
|
||||
title: step.title,
|
||||
color: step.color,
|
||||
events: step.events,
|
||||
}));
|
||||
}
|
||||
if (currentWorkflowId && steps) {
|
||||
result[currentWorkflowId] = steps.map((step) => ({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useRef } from "react";
|
||||
import { useDraggable } from "@dnd-kit/core";
|
||||
import { KanbanCardContextMenu } from "@/components/kanban-card-context-menu";
|
||||
import { KanbanCardShell } from "@/components/kanban-card-content";
|
||||
@@ -24,6 +24,7 @@ import { useTaskMultiSelectStore } from "@/hooks/use-task-multi-select";
|
||||
import type { TaskActionOptions } from "@/hooks/use-task-actions";
|
||||
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 ForegroundActivity,
|
||||
type TaskPendingAction,
|
||||
@@ -230,7 +231,7 @@ function externalLinkHandlers(
|
||||
/** Link-dialog openers shared by both the dropdown and context menu builds. */
|
||||
function buildLinkDialogHandlers(
|
||||
externalLinkAvailability: KanbanExternalLinkAvailability,
|
||||
dialogs: ReturnType<typeof useKanbanCardDialogState>,
|
||||
dialogs: ReturnType<typeof useTaskMenuDialogState>,
|
||||
) {
|
||||
return {
|
||||
onLinkPullRequest: () => dialogs.setShowPRDialog(true),
|
||||
@@ -256,35 +257,6 @@ export function buildPluginMenuContext(
|
||||
};
|
||||
}
|
||||
|
||||
/** Every confirm/link-dialog open flag the card menus and their dialogs share. */
|
||||
function useKanbanCardDialogState() {
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [showArchiveConfirm, setShowArchiveConfirm] = useState(false);
|
||||
const [showDetachConfirm, setShowDetachConfirm] = useState(false);
|
||||
const [showPRDialog, setShowPRDialog] = useState(false);
|
||||
const [showIssueDialog, setShowIssueDialog] = useState(false);
|
||||
const [showMRDialog, setShowMRDialog] = useState(false);
|
||||
const [externalLinkProvider, setExternalLinkProvider] = useState<ExternalLinkProvider | null>(
|
||||
null,
|
||||
);
|
||||
return {
|
||||
showDeleteConfirm,
|
||||
setShowDeleteConfirm,
|
||||
showArchiveConfirm,
|
||||
setShowArchiveConfirm,
|
||||
showDetachConfirm,
|
||||
setShowDetachConfirm,
|
||||
showPRDialog,
|
||||
setShowPRDialog,
|
||||
showIssueDialog,
|
||||
setShowIssueDialog,
|
||||
showMRDialog,
|
||||
setShowMRDialog,
|
||||
externalLinkProvider,
|
||||
setExternalLinkProvider,
|
||||
};
|
||||
}
|
||||
|
||||
function useKanbanCardMenus({
|
||||
task,
|
||||
workspaceId,
|
||||
@@ -322,7 +294,7 @@ function useKanbanCardMenus({
|
||||
// just disabled doesn't linger as a stale entry.
|
||||
usePluginRegistry();
|
||||
const moveMenu = useKanbanCardMoveMenuActions({ task, steps, isSelected, selectedIds, onMove });
|
||||
const dialogs = useKanbanCardDialogState();
|
||||
const dialogs = useTaskMenuDialogState();
|
||||
const { detachTask, detachingTaskId } = useDetachTask();
|
||||
const updateTaskPriority = useUpdateTaskPriority();
|
||||
const detachAnchorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -98,7 +98,15 @@ function useUrlSync(selectedTaskId: string | null, selectedTaskSessionId: string
|
||||
function useEscapeKey(isOpen: boolean, close: () => void, isDisclosureOpen: () => boolean) {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && isOpen && !isDisclosureOpen()) {
|
||||
// `e.defaultPrevented` is the authoritative signal that an open actions
|
||||
// menu already consumed this exact keypress (its `onEscapeKeyDown`
|
||||
// calls `preventDefault()` during document-capture, which always runs
|
||||
// before this window-bubble listener sees the same event). Do not gate
|
||||
// on menu-open state read at listener-fire time instead: Radix's
|
||||
// Escape handling can synchronously re-render and re-attach this very
|
||||
// listener mid-dispatch, so that state can already read "closed" for
|
||||
// the SAME keypress that closed it (AC-TASKS-TASK-ACTIONS-MENU-001.11).
|
||||
if (e.key === "Escape" && isOpen && !isDisclosureOpen() && !e.defaultPrevented) {
|
||||
close();
|
||||
}
|
||||
};
|
||||
@@ -108,6 +116,21 @@ function useEscapeKey(isOpen: boolean, close: () => void, isDisclosureOpen: () =
|
||||
}, [isOpen, close, isDisclosureOpen]);
|
||||
}
|
||||
|
||||
/** Mirrors the previewed task id into the store so kanban cards can highlight
|
||||
* the currently-previewed card without prop-drilling through swimlanes. */
|
||||
function useMirrorPreviewedTaskId(
|
||||
isOpen: boolean,
|
||||
selectedTaskId: string | null | undefined,
|
||||
setKanbanPreviewedTaskId: (taskId: string | null) => void,
|
||||
) {
|
||||
useEffect(() => {
|
||||
setKanbanPreviewedTaskId(isOpen ? (selectedTaskId ?? null) : null);
|
||||
}, [isOpen, selectedTaskId, setKanbanPreviewedTaskId]);
|
||||
useEffect(() => {
|
||||
return () => setKanbanPreviewedTaskId(null);
|
||||
}, [setKanbanPreviewedTaskId]);
|
||||
}
|
||||
|
||||
function useResizeHandler(
|
||||
isResizingRef: React.RefObject<boolean>,
|
||||
previewWidthPx: number,
|
||||
@@ -202,26 +225,15 @@ function useSelectedTask(
|
||||
description: task.description,
|
||||
position: task.position,
|
||||
repositoryId: task.repositoryId,
|
||||
repositories: task.repositories,
|
||||
primarySessionId: task.primarySessionId,
|
||||
parentTaskId: task.parentTaskId,
|
||||
primaryExecutorType: task.primaryExecutorType,
|
||||
workspaceMode: task.workspaceMode,
|
||||
};
|
||||
}, [selectedTaskId, kanbanTasks, snapshots]);
|
||||
}
|
||||
|
||||
// Mirror the previewed task id into the store so kanban cards can highlight
|
||||
// the currently-previewed card without prop-drilling through swimlanes.
|
||||
function useMirrorPreviewedTaskId(
|
||||
isOpen: boolean,
|
||||
selectedTaskId: string | null | undefined,
|
||||
setKanbanPreviewedTaskId: (taskId: string | null) => void,
|
||||
) {
|
||||
useEffect(() => {
|
||||
setKanbanPreviewedTaskId(isOpen ? (selectedTaskId ?? null) : null);
|
||||
}, [isOpen, selectedTaskId, setKanbanPreviewedTaskId]);
|
||||
useEffect(() => {
|
||||
return () => setKanbanPreviewedTaskId(null);
|
||||
}, [setKanbanPreviewedTaskId]);
|
||||
}
|
||||
|
||||
function useCloseMissingSelectedTask(params: {
|
||||
isOpen: boolean;
|
||||
selectedTaskId: string | null | undefined;
|
||||
@@ -325,6 +337,13 @@ export function KanbanWithPreview({ initialTaskId, initialSessionId }: KanbanWit
|
||||
// Track resize state
|
||||
const isResizingRef = useRef(false);
|
||||
|
||||
// Gates the preview's own Escape-to-close so a first Escape only closes an
|
||||
// open actions menu (AC-TASKS-TASK-ACTIONS-MENU-001.11): this reads the
|
||||
// pre-keypress state, since the window `keydown` listener below runs after
|
||||
// Radix's own Escape handling has requested the menu close for this same
|
||||
// keypress but before that state update has re-rendered.
|
||||
const [actionsMenuOpen, setActionsMenuOpen] = useState(false);
|
||||
|
||||
const selectedTask = useSelectedTask(selectedTaskId, kanbanTasks, kanbanMultiSnapshots);
|
||||
const previewStepMove = usePreviewWorkflowStepMove(selectedTaskId, selectedTask);
|
||||
|
||||
@@ -379,7 +398,7 @@ export function KanbanWithPreview({ initialTaskId, initialSessionId }: KanbanWit
|
||||
[isOpen, selectedTaskId, open, close],
|
||||
);
|
||||
|
||||
useEscapeKey(isOpen, close, previewStepMove.isDisclosureOpen);
|
||||
useEscapeKey(isOpen && !actionsMenuOpen, close, previewStepMove.isDisclosureOpen);
|
||||
|
||||
const handleResizeMouseDown = useResizeHandler(isResizingRef, previewWidthPx, updatePreviewWidth);
|
||||
|
||||
@@ -408,6 +427,7 @@ export function KanbanWithPreview({ initialTaskId, initialSessionId }: KanbanWit
|
||||
onClose={close}
|
||||
onSessionChange={setUserSelectedSessionId}
|
||||
onResizeMouseDown={handleResizeMouseDown}
|
||||
onActionsMenuOpenChange={setActionsMenuOpen}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -452,6 +472,7 @@ type PreviewLayoutProps = {
|
||||
onClose: () => void;
|
||||
onSessionChange: (sessionId: string | null) => void;
|
||||
onResizeMouseDown: (e: React.MouseEvent) => void;
|
||||
onActionsMenuOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
function previewPanelStepProps(stepMove: PreviewStepMove) {
|
||||
@@ -479,6 +500,7 @@ function FloatingPreviewLayout({
|
||||
onClose,
|
||||
onSessionChange,
|
||||
onResizeMouseDown,
|
||||
onActionsMenuOpenChange,
|
||||
}: PreviewLayoutProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
@@ -512,6 +534,7 @@ function FloatingPreviewLayout({
|
||||
onMaximize={(task) => onNavigateToTask(task)}
|
||||
onSessionChange={onSessionChange}
|
||||
{...previewPanelStepProps(stepMove)}
|
||||
onActionsMenuOpenChange={onActionsMenuOpenChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -532,6 +555,7 @@ function InlinePreviewLayout({
|
||||
onClose,
|
||||
onSessionChange,
|
||||
onResizeMouseDown,
|
||||
onActionsMenuOpenChange,
|
||||
}: PreviewLayoutProps & { isOpen: boolean }) {
|
||||
return (
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
@@ -557,6 +581,7 @@ function InlinePreviewLayout({
|
||||
onMaximize={(task) => onNavigateToTask(task)}
|
||||
onSessionChange={onSessionChange}
|
||||
{...previewPanelStepProps(stepMove)}
|
||||
onActionsMenuOpenChange={onActionsMenuOpenChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type React from "react";
|
||||
import type { RefObject } from "react";
|
||||
import type {
|
||||
LocalRepository,
|
||||
Repository,
|
||||
@@ -85,6 +86,10 @@ export interface TaskCreateDialogProps {
|
||||
extraFormSlot?: React.ReactNode;
|
||||
bottomSlot?: React.ReactNode;
|
||||
submitBlockedReason?: string | null;
|
||||
/** Element to return keyboard focus to on close, confirmed or cancelled.
|
||||
* Omitted callers keep Radix's default restore-to-previously-focused-element
|
||||
* behavior. */
|
||||
focusReturnRef?: RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
export type DialogPromptEnhance = {
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { createRef, type ReactNode, useEffect, useImperativeHandle, useRef } from "react";
|
||||
import {
|
||||
createRef,
|
||||
type ComponentProps,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -16,20 +23,26 @@ const ENHANCE_PROMPT_BUTTON_TEST_ID = "enhance-prompt-button";
|
||||
|
||||
type EscapeEvent = { preventDefault: () => void };
|
||||
|
||||
type CloseAutoFocusEvent = { preventDefault: () => void };
|
||||
|
||||
let allowProgrammaticSet = true;
|
||||
let mockFs: DialogFormState;
|
||||
let dialogEscapeHandler: ((event: EscapeEvent) => void) | undefined;
|
||||
let dialogCloseAutoFocusHandler: ((event: CloseAutoFocusEvent) => void) | undefined;
|
||||
|
||||
vi.mock("@kandev/ui/dialog", () => ({
|
||||
Dialog: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogContent: ({
|
||||
children,
|
||||
onEscapeKeyDown,
|
||||
onCloseAutoFocus,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
onEscapeKeyDown?: (event: EscapeEvent) => void;
|
||||
onCloseAutoFocus?: (event: CloseAutoFocusEvent) => void;
|
||||
}) => {
|
||||
dialogEscapeHandler = onEscapeKeyDown;
|
||||
dialogCloseAutoFocusHandler = onCloseAutoFocus;
|
||||
return <div>{children}</div>;
|
||||
},
|
||||
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
@@ -351,7 +364,10 @@ function buildMockFs(initialDescription = ORIGINAL_PROMPT): DialogFormState {
|
||||
};
|
||||
}
|
||||
|
||||
function renderDialog(mode: "create" | "edit" | "session" = "create") {
|
||||
function renderDialog(
|
||||
mode: "create" | "edit" | "session" = "create",
|
||||
extraProps: Partial<ComponentProps<typeof TaskCreateDialog>> = {},
|
||||
) {
|
||||
return render(
|
||||
<TaskCreateDialog
|
||||
open
|
||||
@@ -361,6 +377,7 @@ function renderDialog(mode: "create" | "edit" | "session" = "create") {
|
||||
workflowId={null}
|
||||
defaultStepId={null}
|
||||
steps={[]}
|
||||
{...extraProps}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
@@ -375,9 +392,51 @@ beforeEach(() => {
|
||||
toastMock.mockReset();
|
||||
setHasDescriptionMock.mockReset();
|
||||
dialogEscapeHandler = undefined;
|
||||
dialogCloseAutoFocusHandler = undefined;
|
||||
mockFs = buildMockFs();
|
||||
});
|
||||
|
||||
describe("TaskCreateDialog focus return (AC-TASKS-TASK-ACTIONS-MENU-001.12)", () => {
|
||||
it("returns focus to the given focusReturnRef target on close, overriding Radix's default", () => {
|
||||
const target = document.createElement("button");
|
||||
document.body.appendChild(target);
|
||||
target.focus = vi.fn();
|
||||
const focusReturnRef = { current: target };
|
||||
|
||||
renderDialog("edit", { focusReturnRef });
|
||||
expect(dialogCloseAutoFocusHandler).toBeTypeOf("function");
|
||||
|
||||
const event = { preventDefault: vi.fn() };
|
||||
dialogCloseAutoFocusHandler?.(event);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalled();
|
||||
expect(target.focus).toHaveBeenCalled();
|
||||
|
||||
document.body.removeChild(target);
|
||||
});
|
||||
|
||||
it("leaves Radix's default close-focus behavior alone when the target has left the document", () => {
|
||||
const target = document.createElement("button");
|
||||
target.focus = vi.fn();
|
||||
const focusReturnRef = { current: target };
|
||||
|
||||
renderDialog("edit", { focusReturnRef });
|
||||
const event = { preventDefault: vi.fn() };
|
||||
dialogCloseAutoFocusHandler?.(event);
|
||||
|
||||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||
expect(target.focus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves Radix's default close-focus behavior alone when no focusReturnRef is given", () => {
|
||||
renderDialog("edit");
|
||||
const event = { preventDefault: vi.fn() };
|
||||
dialogCloseAutoFocusHandler?.(event);
|
||||
|
||||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskCreateDialog Escape dismissal", () => {
|
||||
it("prevents Escape from dismissing create mode", () => {
|
||||
renderDialog();
|
||||
|
||||
@@ -274,6 +274,12 @@ export function TaskCreateDialog(props: TaskCreateDialogProps) {
|
||||
onEscapeKeyDown={(event) => {
|
||||
if (setup.isCreateMode) event.preventDefault();
|
||||
}}
|
||||
onCloseAutoFocus={(event) => {
|
||||
const target = props.focusReturnRef?.current;
|
||||
if (!target || !document.contains(target)) return;
|
||||
event.preventDefault();
|
||||
target.focus();
|
||||
}}
|
||||
data-testid="create-task-dialog"
|
||||
data-webkit-safe-motion="true"
|
||||
showCloseButton={false}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { StateProvider } from "@/components/state-provider";
|
||||
import { ToastProvider } from "@/components/toast-provider";
|
||||
import { TaskPreviewPanel } from "./task-preview-panel";
|
||||
import type { WorkflowStepperStep } from "./task/workflow-step-disclosure";
|
||||
import type { Task } from "./kanban-card";
|
||||
|
||||
vi.mock("./task/preview-session-tabs", () => ({
|
||||
PreviewSessionTabs: () => <div data-testid="preview-session-tabs" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-compact-task-chrome", () => ({
|
||||
useTouchDrawer: () => false,
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const TASK: Task = {
|
||||
id: "task-1",
|
||||
title: "Fix the login bug",
|
||||
} as Task;
|
||||
|
||||
const OTHER_TASK: Task = {
|
||||
id: "task-2",
|
||||
title: "Write the release notes",
|
||||
} as Task;
|
||||
|
||||
const WORKFLOW_ID = "workflow-1";
|
||||
const STEP_A_ID = "a";
|
||||
const STEPPER_TEST_ID = "workflow-stepper-minimal";
|
||||
const DISCLOSURE_TEST_ID = "workflow-step-disclosure";
|
||||
|
||||
const STEPS: WorkflowStepperStep[] = [
|
||||
{ id: "a", name: "Spec", color: "#111", position: 0 },
|
||||
{ id: "b", name: "Work", color: "#222", position: 1, allow_manual_move: true },
|
||||
];
|
||||
|
||||
function withProviders(ui: ReactNode) {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<StateProvider>{ui}</StateProvider>
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function renderPanel(overrides: Partial<ComponentProps<typeof TaskPreviewPanel>> = {}) {
|
||||
return render(
|
||||
withProviders(
|
||||
<TaskPreviewPanel
|
||||
task={TASK}
|
||||
onClose={vi.fn()}
|
||||
workflowSteps={STEPS}
|
||||
currentStepId={STEP_A_ID}
|
||||
taskWorkflowId={WORKFLOW_ID}
|
||||
onMoveStep={vi.fn()}
|
||||
{...overrides}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe("TaskPreviewPanel step indicator", () => {
|
||||
it("shows no step indicator when there are no resolved workflow steps", () => {
|
||||
renderPanel({ workflowSteps: [] });
|
||||
|
||||
expect(screen.getByText("Fix the login bug")).toBeTruthy();
|
||||
expect(screen.queryByTestId(STEPPER_TEST_ID)).toBeNull();
|
||||
});
|
||||
|
||||
it("shows no step indicator when there is no selected task", () => {
|
||||
renderPanel({ task: null });
|
||||
|
||||
expect(screen.queryByTestId(STEPPER_TEST_ID)).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the current step and total between the title and the panel controls", () => {
|
||||
renderPanel();
|
||||
|
||||
expect(screen.getByTestId(STEPPER_TEST_ID)).toBeTruthy();
|
||||
expect(screen.getByText("1/2")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("issues a move for an eligible target and closes the disclosure on success", async () => {
|
||||
const onMoveStep = vi.fn().mockResolvedValue(true);
|
||||
renderPanel({ onMoveStep });
|
||||
|
||||
fireEvent.mouseEnter(screen.getByTestId(STEPPER_TEST_ID));
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("workflow-step-disclosure-move-b"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onMoveStep).toHaveBeenCalledWith("b");
|
||||
expect(screen.queryByTestId(DISCLOSURE_TEST_ID)).toBeNull();
|
||||
});
|
||||
|
||||
it("notifies the caller when the disclosure opens and closes", () => {
|
||||
const onDisclosureOpenChange = vi.fn();
|
||||
renderPanel({ onDisclosureOpenChange });
|
||||
|
||||
expect(onDisclosureOpenChange).toHaveBeenCalledWith(false);
|
||||
fireEvent.mouseEnter(screen.getByTestId(STEPPER_TEST_ID));
|
||||
expect(onDisclosureOpenChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("renders the move-failure message below the header, not inside it", () => {
|
||||
renderPanel({ moveError: new Error("boom") });
|
||||
|
||||
const banner = screen.getByTestId("task-move-error-banner");
|
||||
expect(banner).toBeTruthy();
|
||||
const headerRow = screen.getByText("Fix the login bug").closest(".border-b");
|
||||
expect(headerRow?.contains(banner)).toBe(false);
|
||||
});
|
||||
|
||||
it("shows no move-failure message when there is none", () => {
|
||||
renderPanel({ workflowSteps: [] });
|
||||
expect(screen.queryByTestId("task-move-error-banner")).toBeNull();
|
||||
});
|
||||
|
||||
it("closes the disclosure when the previewed task changes while it is open", () => {
|
||||
const { rerender } = renderPanel();
|
||||
|
||||
fireEvent.mouseEnter(screen.getByTestId(STEPPER_TEST_ID));
|
||||
expect(screen.getByTestId(DISCLOSURE_TEST_ID)).toBeTruthy();
|
||||
|
||||
rerender(
|
||||
withProviders(
|
||||
<TaskPreviewPanel
|
||||
task={OTHER_TASK}
|
||||
onClose={vi.fn()}
|
||||
workflowSteps={STEPS}
|
||||
currentStepId={STEP_A_ID}
|
||||
taskWorkflowId={WORKFLOW_ID}
|
||||
onMoveStep={vi.fn()}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId(DISCLOSURE_TEST_ID)).toBeNull();
|
||||
expect(screen.getByText(OTHER_TASK.title)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not let a move started before a task switch close the new task's disclosure", async () => {
|
||||
let resolveMove!: (moved: boolean) => void;
|
||||
const onMoveStepForA = vi.fn(() => new Promise<boolean>((resolve) => (resolveMove = resolve)));
|
||||
const { rerender } = renderPanel({ onMoveStep: onMoveStepForA });
|
||||
|
||||
fireEvent.mouseEnter(screen.getByTestId(STEPPER_TEST_ID));
|
||||
fireEvent.click(screen.getByTestId("workflow-step-disclosure-move-b"));
|
||||
expect(onMoveStepForA).toHaveBeenCalledWith("b");
|
||||
|
||||
rerender(
|
||||
withProviders(
|
||||
<TaskPreviewPanel
|
||||
task={OTHER_TASK}
|
||||
onClose={vi.fn()}
|
||||
workflowSteps={STEPS}
|
||||
currentStepId={STEP_A_ID}
|
||||
taskWorkflowId={WORKFLOW_ID}
|
||||
onMoveStep={vi.fn()}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.mouseEnter(screen.getByTestId(STEPPER_TEST_ID));
|
||||
expect(screen.getByTestId(DISCLOSURE_TEST_ID)).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
resolveMove(true);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId(DISCLOSURE_TEST_ID)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the archived badge and offers no move control for an archived task", () => {
|
||||
const onMoveStep = vi.fn();
|
||||
renderPanel({ isArchived: true, onMoveStep });
|
||||
|
||||
expect(screen.queryByTestId("workflow-step-disclosure-move-b")).toBeNull();
|
||||
fireEvent.mouseEnter(screen.getByTestId(STEPPER_TEST_ID));
|
||||
expect(screen.queryByTestId(DISCLOSURE_TEST_ID)).toBeNull();
|
||||
expect(onMoveStep).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskPreviewPanel step indicator lifecycle", () => {
|
||||
it("reports the disclosure closed when the stepper unmounts", () => {
|
||||
const onDisclosureOpenChange = vi.fn();
|
||||
const { rerender } = renderPanel({ onDisclosureOpenChange });
|
||||
|
||||
fireEvent.mouseEnter(screen.getByTestId(STEPPER_TEST_ID));
|
||||
expect(onDisclosureOpenChange).toHaveBeenLastCalledWith(true);
|
||||
|
||||
rerender(withProviders(<TaskPreviewPanel task={TASK} onClose={vi.fn()} workflowSteps={[]} />));
|
||||
|
||||
expect(onDisclosureOpenChange).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
});
|
||||
@@ -1,189 +1,367 @@
|
||||
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { StateProvider } from "@/components/state-provider";
|
||||
import { ToastProvider } from "@/components/toast-provider";
|
||||
import { TaskPreviewPanel } from "./task-preview-panel";
|
||||
import type { WorkflowStepperStep } from "./task/workflow-step-disclosure";
|
||||
import type { Task } from "./kanban-card";
|
||||
|
||||
vi.mock("@/components/state-provider", () => ({
|
||||
useAppStore: () => "workspace-1",
|
||||
}));
|
||||
const detachTaskMock = vi.hoisted(() => vi.fn().mockResolvedValue({ id: "task-1" }));
|
||||
const getSubtaskCountMock = vi.hoisted(() => vi.fn().mockResolvedValue({ count: 0 }));
|
||||
const archiveTaskMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||
const deleteTaskMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||
vi.mock("@/lib/api/domains/kanban-api", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/api/domains/kanban-api")>(
|
||||
"@/lib/api/domains/kanban-api",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
detachTask: detachTaskMock,
|
||||
getSubtaskCount: getSubtaskCountMock,
|
||||
archiveTask: archiveTaskMock,
|
||||
deleteTask: deleteTaskMock,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
detachTaskMock.mockClear();
|
||||
getSubtaskCountMock.mockClear();
|
||||
getSubtaskCountMock.mockResolvedValue({ count: 0 });
|
||||
archiveTaskMock.mockClear();
|
||||
archiveTaskMock.mockResolvedValue(undefined);
|
||||
deleteTaskMock.mockClear();
|
||||
deleteTaskMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
vi.mock("./task/preview-session-tabs", () => ({
|
||||
PreviewSessionTabs: () => <div data-testid="preview-session-tabs" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-compact-task-chrome", () => ({
|
||||
useTouchDrawer: () => false,
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
const TRIGGER_TEST_ID = "task-preview-actions-menu";
|
||||
const DETACH_MENU_ITEM_NAME = "Detach from parent";
|
||||
const DETACH_CONFIRM_POPOVER_TEST_ID = "detach-task-confirm-popover";
|
||||
|
||||
const TASK: Task = {
|
||||
id: "task-1",
|
||||
title: "Fix the login bug",
|
||||
} as Task;
|
||||
title: "Fix the sidebar",
|
||||
workflowStepId: "step-1",
|
||||
};
|
||||
|
||||
const OTHER_TASK: Task = {
|
||||
id: "task-2",
|
||||
title: "Write the release notes",
|
||||
} as Task;
|
||||
const CHILD_TASK: Task = {
|
||||
...TASK,
|
||||
parentTaskId: "task-parent",
|
||||
};
|
||||
|
||||
const WORKFLOW_ID = "workflow-1";
|
||||
const STEP_A_ID = "a";
|
||||
const STEPPER_TEST_ID = "workflow-stepper-minimal";
|
||||
const DISCLOSURE_TEST_ID = "workflow-step-disclosure";
|
||||
|
||||
const STEPS: WorkflowStepperStep[] = [
|
||||
{ id: "a", name: "Spec", color: "#111", position: 0 },
|
||||
{ id: "b", name: "Work", color: "#222", position: 1, allow_manual_move: true },
|
||||
];
|
||||
|
||||
function renderPanel(overrides: Partial<ComponentProps<typeof TaskPreviewPanel>> = {}) {
|
||||
function renderPanel(ui: React.ReactNode) {
|
||||
return render(
|
||||
<TaskPreviewPanel
|
||||
task={TASK}
|
||||
onClose={vi.fn()}
|
||||
workflowSteps={STEPS}
|
||||
currentStepId={STEP_A_ID}
|
||||
taskWorkflowId={WORKFLOW_ID}
|
||||
onMoveStep={vi.fn()}
|
||||
{...overrides}
|
||||
/>,
|
||||
<ToastProvider>
|
||||
<StateProvider>{ui}</StateProvider>
|
||||
</ToastProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("TaskPreviewPanel step indicator", () => {
|
||||
it("shows no step indicator when there are no resolved workflow steps", () => {
|
||||
renderPanel({ workflowSteps: [] });
|
||||
function rerenderPanel(rerender: (ui: React.ReactNode) => void, ui: React.ReactNode) {
|
||||
rerender(
|
||||
<ToastProvider>
|
||||
<StateProvider>{ui}</StateProvider>
|
||||
</ToastProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
expect(screen.getByText("Fix the login bug")).toBeTruthy();
|
||||
expect(screen.queryByTestId(STEPPER_TEST_ID)).toBeNull();
|
||||
function getTrigger() {
|
||||
return screen.getByTestId(TRIGGER_TEST_ID);
|
||||
}
|
||||
|
||||
function openMenu() {
|
||||
const trigger = getTrigger();
|
||||
fireEvent.pointerDown(trigger, { button: 0, pointerId: 1 });
|
||||
fireEvent.click(trigger);
|
||||
return trigger;
|
||||
}
|
||||
|
||||
function expectMenuOpen(open: boolean) {
|
||||
expect(getTrigger().getAttribute("aria-expanded")).toBe(String(open));
|
||||
}
|
||||
|
||||
describe("TaskPreviewPanel actions menu trigger", () => {
|
||||
it("renders no trigger when the panel has no subject task", () => {
|
||||
renderPanel(<TaskPreviewPanel task={null} onClose={vi.fn()} />);
|
||||
|
||||
expect(screen.queryByTestId(TRIGGER_TEST_ID)).toBeNull();
|
||||
});
|
||||
|
||||
it("shows no step indicator when there is no selected task", () => {
|
||||
renderPanel({ task: null });
|
||||
it("renders the trigger before Maximize, with the More options accessible name", () => {
|
||||
renderPanel(<TaskPreviewPanel task={TASK} onClose={vi.fn()} onMaximize={vi.fn()} />);
|
||||
|
||||
expect(screen.queryByTestId(STEPPER_TEST_ID)).toBeNull();
|
||||
const trigger = getTrigger();
|
||||
expect(trigger.getAttribute("aria-label")).toBe("More options");
|
||||
expect(trigger.getAttribute("aria-haspopup")).toBe("menu");
|
||||
|
||||
// AC-TASKS-TASK-ACTIONS-MENU-001.1: before Maximize, which is itself
|
||||
// before Close.
|
||||
const controls = screen.getAllByRole("button");
|
||||
const maximizeIndex = controls.findIndex((el) => el.title === "Open full page");
|
||||
const triggerIndex = controls.indexOf(trigger);
|
||||
expect(triggerIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(triggerIndex).toBeLessThan(maximizeIndex);
|
||||
});
|
||||
|
||||
it("shows the current step and total between the title and the panel controls", () => {
|
||||
renderPanel();
|
||||
it("opens a menu offering Detach from parent for a subject task with a parent", () => {
|
||||
renderPanel(<TaskPreviewPanel task={CHILD_TASK} onClose={vi.fn()} />);
|
||||
|
||||
expect(screen.getByTestId(STEPPER_TEST_ID)).toBeTruthy();
|
||||
expect(screen.getByText("1/2")).toBeTruthy();
|
||||
openMenu();
|
||||
|
||||
expect(screen.getByRole("menuitem", { name: DETACH_MENU_ITEM_NAME })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("issues a move for an eligible target and closes the disclosure on success", async () => {
|
||||
const onMoveStep = vi.fn().mockResolvedValue(true);
|
||||
renderPanel({ onMoveStep });
|
||||
it("offers no Detach from parent when the subject task has none", () => {
|
||||
renderPanel(<TaskPreviewPanel task={TASK} onClose={vi.fn()} />);
|
||||
|
||||
fireEvent.mouseEnter(screen.getByTestId(STEPPER_TEST_ID));
|
||||
openMenu();
|
||||
|
||||
expect(screen.queryByRole("menuitem", { name: DETACH_MENU_ITEM_NAME })).toBeNull();
|
||||
});
|
||||
|
||||
it("requests detach for the subject task and closes the confirmation once it completes (AC-TASKS-TASK-ACTIONS-MENU-003.10)", async () => {
|
||||
vi.useFakeTimers();
|
||||
renderPanel(<TaskPreviewPanel task={CHILD_TASK} onClose={vi.fn()} />);
|
||||
|
||||
openMenu();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: DETACH_MENU_ITEM_NAME }));
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("workflow-step-disclosure-move-b"));
|
||||
await Promise.resolve();
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
expect(screen.getByTestId(DETACH_CONFIRM_POPOVER_TEST_ID)).toBeTruthy();
|
||||
|
||||
expect(onMoveStep).toHaveBeenCalledWith("b");
|
||||
expect(screen.queryByTestId(DISCLOSURE_TEST_ID)).toBeNull();
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("detach-task-confirm"));
|
||||
vi.useRealTimers();
|
||||
|
||||
it("notifies the caller when the disclosure opens and closes", () => {
|
||||
const onDisclosureOpenChange = vi.fn();
|
||||
renderPanel({ onDisclosureOpenChange });
|
||||
|
||||
expect(onDisclosureOpenChange).toHaveBeenCalledWith(false);
|
||||
fireEvent.mouseEnter(screen.getByTestId(STEPPER_TEST_ID));
|
||||
expect(onDisclosureOpenChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("renders the move-failure message below the header, not inside it", () => {
|
||||
renderPanel({ moveError: new Error("boom") });
|
||||
|
||||
const banner = screen.getByTestId("task-move-error-banner");
|
||||
expect(banner).toBeTruthy();
|
||||
const headerRow = screen.getByText("Fix the login bug").closest(".border-b");
|
||||
expect(headerRow?.contains(banner)).toBe(false);
|
||||
});
|
||||
|
||||
it("shows no move-failure message when there is none", () => {
|
||||
renderPanel({ workflowSteps: [] });
|
||||
expect(screen.queryByTestId("task-move-error-banner")).toBeNull();
|
||||
});
|
||||
|
||||
it("closes the disclosure when the previewed task changes while it is open", () => {
|
||||
const { rerender } = renderPanel();
|
||||
|
||||
fireEvent.mouseEnter(screen.getByTestId(STEPPER_TEST_ID));
|
||||
expect(screen.getByTestId(DISCLOSURE_TEST_ID)).toBeTruthy();
|
||||
|
||||
rerender(
|
||||
<TaskPreviewPanel
|
||||
task={OTHER_TASK}
|
||||
onClose={vi.fn()}
|
||||
workflowSteps={STEPS}
|
||||
currentStepId={STEP_A_ID}
|
||||
taskWorkflowId={WORKFLOW_ID}
|
||||
onMoveStep={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId(DISCLOSURE_TEST_ID)).toBeNull();
|
||||
expect(screen.getByText(OTHER_TASK.title)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not let a move started before a task switch close the new task's disclosure", async () => {
|
||||
let resolveMove!: (moved: boolean) => void;
|
||||
const onMoveStepForA = vi.fn(() => new Promise<boolean>((resolve) => (resolveMove = resolve)));
|
||||
const { rerender } = renderPanel({ onMoveStep: onMoveStepForA });
|
||||
|
||||
fireEvent.mouseEnter(screen.getByTestId(STEPPER_TEST_ID));
|
||||
fireEvent.click(screen.getByTestId("workflow-step-disclosure-move-b"));
|
||||
expect(onMoveStepForA).toHaveBeenCalledWith("b");
|
||||
|
||||
rerender(
|
||||
<TaskPreviewPanel
|
||||
task={OTHER_TASK}
|
||||
onClose={vi.fn()}
|
||||
workflowSteps={STEPS}
|
||||
currentStepId={STEP_A_ID}
|
||||
taskWorkflowId={WORKFLOW_ID}
|
||||
onMoveStep={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.mouseEnter(screen.getByTestId(STEPPER_TEST_ID));
|
||||
expect(screen.getByTestId(DISCLOSURE_TEST_ID)).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
resolveMove(true);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId(DISCLOSURE_TEST_ID)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the archived badge and offers no move control for an archived task", () => {
|
||||
const onMoveStep = vi.fn();
|
||||
renderPanel({ isArchived: true, onMoveStep });
|
||||
|
||||
expect(screen.queryByTestId("workflow-step-disclosure-move-b")).toBeNull();
|
||||
fireEvent.mouseEnter(screen.getByTestId(STEPPER_TEST_ID));
|
||||
expect(screen.queryByTestId(DISCLOSURE_TEST_ID)).toBeNull();
|
||||
expect(onMoveStep).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(detachTaskMock).toHaveBeenCalledWith(CHILD_TASK.id));
|
||||
await waitFor(() => expect(screen.queryByTestId(DETACH_CONFIRM_POPOVER_TEST_ID)).toBeNull());
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskPreviewPanel step indicator lifecycle", () => {
|
||||
it("reports the disclosure closed when the stepper unmounts", () => {
|
||||
const onDisclosureOpenChange = vi.fn();
|
||||
const { rerender } = renderPanel({ onDisclosureOpenChange });
|
||||
describe("TaskPreviewPanel actions menu — subject identity change (AC-TASKS-TASK-ACTIONS-MENU-004.5a)", () => {
|
||||
const OTHER_TASK: Task = { id: "task-2", title: "Other task", workflowStepId: "step-1" };
|
||||
|
||||
fireEvent.mouseEnter(screen.getByTestId(STEPPER_TEST_ID));
|
||||
expect(onDisclosureOpenChange).toHaveBeenLastCalledWith(true);
|
||||
it("closes an open menu, without re-targeting it, when the subject task's identifier changes", () => {
|
||||
const onActionsMenuOpenChange = vi.fn();
|
||||
const { rerender } = renderPanel(
|
||||
<TaskPreviewPanel
|
||||
task={TASK}
|
||||
onClose={vi.fn()}
|
||||
onActionsMenuOpenChange={onActionsMenuOpenChange}
|
||||
/>,
|
||||
);
|
||||
openMenu();
|
||||
expectMenuOpen(true);
|
||||
onActionsMenuOpenChange.mockClear();
|
||||
|
||||
rerender(<TaskPreviewPanel task={TASK} onClose={vi.fn()} workflowSteps={[]} />);
|
||||
rerenderPanel(
|
||||
rerender,
|
||||
<TaskPreviewPanel
|
||||
task={OTHER_TASK}
|
||||
onClose={vi.fn()}
|
||||
onActionsMenuOpenChange={onActionsMenuOpenChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(onDisclosureOpenChange).toHaveBeenLastCalledWith(false);
|
||||
expectMenuOpen(false);
|
||||
expect(onActionsMenuOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("leaves an open menu open across a re-render that keeps the same subject identifier", () => {
|
||||
const { rerender } = renderPanel(<TaskPreviewPanel task={TASK} onClose={vi.fn()} />);
|
||||
openMenu();
|
||||
expectMenuOpen(true);
|
||||
|
||||
// A field-only update to the same task (new object, same id) must not
|
||||
// close the menu.
|
||||
rerenderPanel(
|
||||
rerender,
|
||||
<TaskPreviewPanel task={{ ...TASK, title: "Fix the sidebar (renamed)" }} onClose={vi.fn()} />,
|
||||
);
|
||||
|
||||
expectMenuOpen(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskPreviewPanel actions menu — focus return to trigger (AC-TASKS-TASK-ACTIONS-MENU-001.12)", () => {
|
||||
it("returns focus to the trigger once a cancelled Delete confirmation closes", async () => {
|
||||
renderPanel(<TaskPreviewPanel task={TASK} onClose={vi.fn()} />);
|
||||
|
||||
const trigger = openMenu();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
|
||||
expect(await screen.findByRole("alertdialog")).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
|
||||
await waitFor(() => expect(document.activeElement).toBe(trigger));
|
||||
});
|
||||
|
||||
it("returns focus to the trigger once a cancelled Detach confirmation closes", async () => {
|
||||
renderPanel(<TaskPreviewPanel task={CHILD_TASK} onClose={vi.fn()} />);
|
||||
|
||||
const trigger = openMenu();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: DETACH_MENU_ITEM_NAME }));
|
||||
const popover = await screen.findByTestId(DETACH_CONFIRM_POPOVER_TEST_ID);
|
||||
|
||||
fireEvent.click(within(popover).getByRole("button", { name: "Cancel" }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId(DETACH_CONFIRM_POPOVER_TEST_ID)).toBeNull());
|
||||
await waitFor(() => expect(document.activeElement).toBe(trigger));
|
||||
});
|
||||
|
||||
it("returns focus to the trigger once a cancelled Archive confirmation closes for a task with subtasks", async () => {
|
||||
getSubtaskCountMock.mockResolvedValue({ count: 2 });
|
||||
vi.useFakeTimers();
|
||||
renderPanel(<TaskPreviewPanel task={TASK} onClose={vi.fn()} />);
|
||||
|
||||
const trigger = openMenu();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Archive" }));
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
vi.useRealTimers();
|
||||
|
||||
// The subtask count resolves to >0, so this is the AlertDialog cascade
|
||||
// branch (`shouldUseDialog`), not the popover.
|
||||
const dialog = await screen.findByRole("alertdialog");
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
|
||||
await waitFor(() => expect(document.activeElement).toBe(trigger));
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskPreviewPanel actions menu — terminal activation closes the menu (AC-TASKS-TASK-ACTIONS-MENU-004.2)", () => {
|
||||
it("closes the dropdown menu itself as soon as a terminal entry (Archive) is activated", () => {
|
||||
renderPanel(<TaskPreviewPanel task={TASK} onClose={vi.fn()} />);
|
||||
|
||||
openMenu();
|
||||
expectMenuOpen(true);
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Archive" }));
|
||||
|
||||
expectMenuOpen(false);
|
||||
});
|
||||
|
||||
it("closes the dropdown menu itself as soon as a terminal entry (Detach from parent) is activated", () => {
|
||||
renderPanel(<TaskPreviewPanel task={CHILD_TASK} onClose={vi.fn()} />);
|
||||
|
||||
openMenu();
|
||||
expectMenuOpen(true);
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: DETACH_MENU_ITEM_NAME }));
|
||||
|
||||
expectMenuOpen(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskPreviewPanel actions menu — closes on Archive/Delete success (AC-TASKS-TASK-ACTIONS-MENU-003.3)", () => {
|
||||
it("calls onClose once the Archive request resolves, independent of board/snapshot cache state", async () => {
|
||||
vi.useFakeTimers();
|
||||
const onClose = vi.fn();
|
||||
renderPanel(<TaskPreviewPanel task={TASK} onClose={onClose} />);
|
||||
|
||||
openMenu();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Archive" }));
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
vi.useRealTimers();
|
||||
|
||||
const confirmation = await screen.findByTestId("task-archive-confirm-popover");
|
||||
fireEvent.click(within(confirmation).getByTestId("archive-task-confirm"));
|
||||
|
||||
await waitFor(() => expect(archiveTaskMock).toHaveBeenCalledWith("task-1", { cascade: false }));
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("calls onClose once the Delete request resolves", async () => {
|
||||
const onClose = vi.fn();
|
||||
renderPanel(<TaskPreviewPanel task={TASK} onClose={onClose} />);
|
||||
|
||||
openMenu();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
|
||||
|
||||
const dialog = await screen.findByRole("alertdialog");
|
||||
fireEvent.click(within(dialog).getByTestId("delete-discard-worktree-checkbox"));
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(deleteTaskMock).toHaveBeenCalledWith("task-1", {
|
||||
cascade: false,
|
||||
discardWorktreeChanges: true,
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("does not call onClose when the Archive request rejects", async () => {
|
||||
archiveTaskMock.mockRejectedValueOnce(new Error("network error"));
|
||||
vi.useFakeTimers();
|
||||
const onClose = vi.fn();
|
||||
renderPanel(<TaskPreviewPanel task={TASK} onClose={onClose} />);
|
||||
|
||||
openMenu();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Archive" }));
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
vi.useRealTimers();
|
||||
|
||||
const confirmation = await screen.findByTestId("task-archive-confirm-popover");
|
||||
fireEvent.click(within(confirmation).getByTestId("archive-task-confirm"));
|
||||
|
||||
await waitFor(() => expect(archiveTaskMock).toHaveBeenCalledTimes(1));
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskPreviewPanel actions menu — confirmation retargeting (AC-TASKS-TASK-ACTIONS-MENU-004.5a)", () => {
|
||||
const OTHER_CHILD_TASK: Task = {
|
||||
id: "task-3",
|
||||
title: "Other child task",
|
||||
workflowStepId: "step-1",
|
||||
parentTaskId: "task-other-parent",
|
||||
};
|
||||
|
||||
function clickDetach() {
|
||||
openMenu();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: DETACH_MENU_ITEM_NAME }));
|
||||
}
|
||||
|
||||
it("drops a detach confirmation requested for a subject the panel has since swapped away from", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { rerender } = renderPanel(<TaskPreviewPanel task={CHILD_TASK} onClose={vi.fn()} />);
|
||||
|
||||
clickDetach();
|
||||
|
||||
// Still inside the 300ms open-delay when the subject swaps out.
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
rerenderPanel(rerender, <TaskPreviewPanel task={OTHER_CHILD_TASK} onClose={vi.fn()} />);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId(DETACH_CONFIRM_POPOVER_TEST_ID)).toBeNull();
|
||||
});
|
||||
|
||||
it("closes an already-open detach confirmation when the subject swaps away from it", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { rerender } = renderPanel(<TaskPreviewPanel task={CHILD_TASK} onClose={vi.fn()} />);
|
||||
|
||||
clickDetach();
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
expect(screen.getByTestId(DETACH_CONFIRM_POPOVER_TEST_ID)).toBeTruthy();
|
||||
|
||||
rerenderPanel(rerender, <TaskPreviewPanel task={OTHER_CHILD_TASK} onClose={vi.fn()} />);
|
||||
|
||||
expect(screen.queryByTestId(DETACH_CONFIRM_POPOVER_TEST_ID)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { IconArrowsMaximize, IconX } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
@@ -8,6 +9,10 @@ import type { Task } from "./kanban-card";
|
||||
import { PreviewSessionTabs } from "./task/preview-session-tabs";
|
||||
import { TaskMoveErrorBanner } from "./task/task-move-error-banner";
|
||||
import { MinimalWorkflowStepper, type WorkflowStepperStep } from "./task/workflow-step-disclosure";
|
||||
import { TaskActionsMenuTrigger } from "./task/task-actions-menu-trigger";
|
||||
import { TaskActionsMenuDialogs } from "./task/task-actions-menu-dialogs";
|
||||
import { useTaskActionsMenu, type TaskActionsMenuBoardRow } from "@/hooks/use-task-actions-menu";
|
||||
import { useTaskCRUD } from "@/hooks/use-task-crud";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface TaskPreviewPanelProps {
|
||||
@@ -25,6 +30,87 @@ interface TaskPreviewPanelProps {
|
||||
onMoveStep?: (stepId: string) => Promise<boolean>;
|
||||
onDisclosureOpenChange?: (open: boolean) => void;
|
||||
moveError?: unknown;
|
||||
/** Lets the enclosing surface skip its own Escape-close while this menu is open. */
|
||||
onActionsMenuOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
function buildBoardRow(task: Task | null): TaskActionsMenuBoardRow | null {
|
||||
if (!task) return null;
|
||||
return {
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
workflowStepId: task.workflowStepId,
|
||||
state: task.state,
|
||||
priority: task.priority,
|
||||
repositoryId: task.repositoryId,
|
||||
repositories: task.repositories,
|
||||
parentTaskId: task.parentTaskId,
|
||||
primaryExecutorType: task.primaryExecutorType,
|
||||
workspaceMode: task.workspaceMode,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Menu open state lives here (not inside the Radix primitive alone) so a
|
||||
* subject-identity change can force it closed without retargeting
|
||||
* (AC-TASKS-TASK-ACTIONS-MENU-004.5a), and so the panel's own Escape handler
|
||||
* can tell whether an open menu already owns the keypress
|
||||
* (AC-TASKS-TASK-ACTIONS-MENU-001.11).
|
||||
*/
|
||||
function useActionsMenuOpenState(
|
||||
taskId: string | null,
|
||||
onActionsMenuOpenChange?: (open: boolean) => void,
|
||||
) {
|
||||
const [open, setOpenState] = useState(false);
|
||||
const setOpen = (next: boolean) => {
|
||||
setOpenState(next);
|
||||
onActionsMenuOpenChange?.(next);
|
||||
};
|
||||
|
||||
const prevTaskIdRef = useRef(taskId);
|
||||
useEffect(() => {
|
||||
if (prevTaskIdRef.current !== taskId) {
|
||||
prevTaskIdRef.current = taskId;
|
||||
setOpen(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [taskId]);
|
||||
|
||||
return { open, setOpen };
|
||||
}
|
||||
|
||||
function usePreviewActionsMenu(
|
||||
task: Task | null,
|
||||
taskId: string | null,
|
||||
taskTitle: string,
|
||||
workspaceId: string | null,
|
||||
onClose: () => void,
|
||||
) {
|
||||
const taskCRUD = useTaskCRUD();
|
||||
const boardRow = buildBoardRow(task);
|
||||
const isArchiving = task ? taskCRUD.archivingTaskId === task.id : false;
|
||||
const isDeleting = task ? taskCRUD.deletingTaskId === task.id : false;
|
||||
|
||||
const menu = useTaskActionsMenu({
|
||||
taskId,
|
||||
taskTitle,
|
||||
workspaceId,
|
||||
// The board excludes archived tasks, so the preview panel never holds one.
|
||||
isArchived: false,
|
||||
boardRow,
|
||||
isArchiving,
|
||||
isDeleting,
|
||||
// `useTaskCRUD` only prunes `kanban.tasks`, so a task also present in
|
||||
// `kanbanMulti.snapshots` (multi-workflow board view) would otherwise
|
||||
// keep resolving via that snapshot until a WS lifecycle event catches
|
||||
// up, leaving the preview open on an archived/deleted task. Close it
|
||||
// directly on HTTP success instead of depending on cache completeness.
|
||||
onArchive: (opts) => (task ? taskCRUD.handleArchive(task, opts).then(onClose) : undefined),
|
||||
onDelete: (opts) => (task ? taskCRUD.handleDelete(task, opts).then(onClose) : undefined),
|
||||
});
|
||||
|
||||
return { menu, boardRow, isArchiving, isDeleting };
|
||||
}
|
||||
|
||||
interface PreviewPanelHeaderProps {
|
||||
@@ -38,6 +124,10 @@ interface PreviewPanelHeaderProps {
|
||||
movingToStepId: string | null;
|
||||
onMoveStep?: (stepId: string) => Promise<boolean>;
|
||||
onDisclosureOpenChange?: (open: boolean) => void;
|
||||
menuEntries: ReturnType<typeof useTaskActionsMenu>["entries"];
|
||||
triggerRef: ReturnType<typeof useTaskActionsMenu>["triggerRef"];
|
||||
actionsMenuOpen: boolean;
|
||||
setActionsMenuOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
function PreviewPanelHeader({
|
||||
@@ -51,6 +141,10 @@ function PreviewPanelHeader({
|
||||
movingToStepId,
|
||||
onMoveStep,
|
||||
onDisclosureOpenChange,
|
||||
menuEntries,
|
||||
triggerRef,
|
||||
actionsMenuOpen,
|
||||
setActionsMenuOpen,
|
||||
}: PreviewPanelHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
const currentIndex = workflowSteps.findIndex((step) => step.id === currentStepId);
|
||||
@@ -88,6 +182,15 @@ function PreviewPanelHeader({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{task && (
|
||||
<TaskActionsMenuTrigger
|
||||
entries={menuEntries}
|
||||
testId="task-preview-actions-menu"
|
||||
triggerRef={triggerRef}
|
||||
open={actionsMenuOpen}
|
||||
onOpenChange={setActionsMenuOpen}
|
||||
/>
|
||||
)}
|
||||
{onMaximize && task && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -124,9 +227,25 @@ export function TaskPreviewPanel({
|
||||
onMoveStep,
|
||||
onDisclosureOpenChange,
|
||||
moveError = null,
|
||||
onActionsMenuOpenChange,
|
||||
}: TaskPreviewPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const activeWorkspaceId = useAppStore((s) => s.workspaces.activeId);
|
||||
const workspaceId = activeWorkspaceId ?? null;
|
||||
const taskId = task?.id ?? null;
|
||||
const taskTitle = task?.title ?? "";
|
||||
const { open: actionsMenuOpen, setOpen: setActionsMenuOpen } = useActionsMenuOpenState(
|
||||
taskId,
|
||||
onActionsMenuOpenChange,
|
||||
);
|
||||
const { menu, boardRow, isArchiving, isDeleting } = usePreviewActionsMenu(
|
||||
task,
|
||||
taskId,
|
||||
taskTitle,
|
||||
workspaceId,
|
||||
onClose,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="task-preview-panel"
|
||||
@@ -143,6 +262,10 @@ export function TaskPreviewPanel({
|
||||
movingToStepId={movingToStepId}
|
||||
onMoveStep={onMoveStep}
|
||||
onDisclosureOpenChange={onDisclosureOpenChange}
|
||||
menuEntries={menu.entries}
|
||||
triggerRef={menu.triggerRef}
|
||||
actionsMenuOpen={actionsMenuOpen}
|
||||
setActionsMenuOpen={setActionsMenuOpen}
|
||||
/>
|
||||
|
||||
{moveError !== null && <TaskMoveErrorBanner error={moveError} />}
|
||||
@@ -155,7 +278,7 @@ export function TaskPreviewPanel({
|
||||
sessionId={sessionId}
|
||||
isArchived={isArchived}
|
||||
ensureSession={ensureSession}
|
||||
workspaceId={activeWorkspaceId ?? null}
|
||||
workspaceId={workspaceId}
|
||||
onSessionChange={onSessionChange}
|
||||
/>
|
||||
) : (
|
||||
@@ -164,6 +287,15 @@ export function TaskPreviewPanel({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<TaskActionsMenuDialogs
|
||||
taskId={taskId}
|
||||
taskTitle={taskTitle}
|
||||
workspaceId={workspaceId}
|
||||
boardRow={boardRow}
|
||||
isArchiving={isArchiving}
|
||||
isDeleting={isDeleting}
|
||||
menu={menu}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { StateProvider } from "@/components/state-provider";
|
||||
import { ToastProvider } from "@/components/toast-provider";
|
||||
import { useTaskActionsMenu, type TaskActionsMenuBoardRow } from "@/hooks/use-task-actions-menu";
|
||||
import { TaskActionsMenuDialogs } from "./task-actions-menu-dialogs";
|
||||
|
||||
const capturedEditDialogProps: Array<{ open: boolean; focusReturnRef?: { current: unknown } }> = [];
|
||||
type CapturedLinkDialogProps = { open: boolean; focusReturnRef?: { current: unknown } };
|
||||
const capturedLinkDialogProps: Record<string, CapturedLinkDialogProps[]> = {
|
||||
pr: [],
|
||||
issue: [],
|
||||
mr: [],
|
||||
external: [],
|
||||
};
|
||||
|
||||
vi.mock("@/components/task-create-dialog", () => ({
|
||||
TaskCreateDialog: (props: { open: boolean; focusReturnRef?: { current: unknown } }) => {
|
||||
capturedEditDialogProps.push({ open: props.open, focusReturnRef: props.focusReturnRef });
|
||||
return props.open ? <div data-testid="edit-dialog-open" /> : null;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/task/task-github-pr-dialog", () => ({
|
||||
TaskGitHubPRDialog: (props: CapturedLinkDialogProps) => {
|
||||
capturedLinkDialogProps.pr.push(props);
|
||||
return props.open ? <div data-testid="pr-dialog-open" /> : null;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/task/task-github-issue-dialog", () => ({
|
||||
TaskGitHubIssueDialog: (props: CapturedLinkDialogProps) => {
|
||||
capturedLinkDialogProps.issue.push(props);
|
||||
return props.open ? <div data-testid="issue-dialog-open" /> : null;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/gitlab/task-mr-link-dialog", () => ({
|
||||
TaskMRLinkDialog: (props: CapturedLinkDialogProps) => {
|
||||
capturedLinkDialogProps.mr.push(props);
|
||||
return props.open ? <div data-testid="mr-dialog-open" /> : null;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/task/task-external-link-dialog", () => ({
|
||||
TaskExternalLinkDialog: (props: CapturedLinkDialogProps) => {
|
||||
capturedLinkDialogProps.external.push(props);
|
||||
return props.open ? <div data-testid="external-dialog-open" /> : null;
|
||||
},
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
capturedEditDialogProps.length = 0;
|
||||
for (const key of Object.keys(capturedLinkDialogProps)) capturedLinkDialogProps[key].length = 0;
|
||||
});
|
||||
|
||||
const TASK_ID = "task-1";
|
||||
|
||||
const BOARD_ROW: TaskActionsMenuBoardRow = {
|
||||
id: TASK_ID,
|
||||
title: "Fix the sidebar",
|
||||
workflowStepId: "step-1",
|
||||
priority: "high",
|
||||
};
|
||||
|
||||
function Harness({ boardRow }: { boardRow: TaskActionsMenuBoardRow | null }) {
|
||||
const menu = useTaskActionsMenu({
|
||||
taskId: TASK_ID,
|
||||
taskTitle: "Fix the sidebar",
|
||||
workspaceId: "ws-1",
|
||||
isArchived: false,
|
||||
boardRow,
|
||||
onArchive: () => undefined,
|
||||
onDelete: () => undefined,
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<button data-testid="open-edit" onClick={() => menu.setShowEditDialog(true)}>
|
||||
Edit
|
||||
</button>
|
||||
<button data-testid="open-pr" onClick={() => menu.setShowPRDialog(true)}>
|
||||
Link PR
|
||||
</button>
|
||||
<button data-testid="open-issue" onClick={() => menu.setShowIssueDialog(true)}>
|
||||
Link issue
|
||||
</button>
|
||||
<button data-testid="open-mr" onClick={() => menu.setShowMRDialog(true)}>
|
||||
Link MR
|
||||
</button>
|
||||
<button data-testid="open-external" onClick={() => menu.setExternalLinkProvider("jira")}>
|
||||
Link Jira
|
||||
</button>
|
||||
<TaskActionsMenuDialogs
|
||||
taskId={TASK_ID}
|
||||
taskTitle="Fix the sidebar"
|
||||
workspaceId="ws-1"
|
||||
boardRow={boardRow}
|
||||
menu={menu}
|
||||
/>
|
||||
<output data-testid="menu-entry-keys">
|
||||
{menu.entries.map((entry) => entry.key).join(",")}
|
||||
</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderHarness(boardRow: TaskActionsMenuBoardRow | null) {
|
||||
return render(
|
||||
<ToastProvider>
|
||||
<StateProvider>
|
||||
<Harness boardRow={boardRow} />
|
||||
</StateProvider>
|
||||
</ToastProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function rerenderHarness(
|
||||
rerender: (ui: React.ReactNode) => void,
|
||||
boardRow: TaskActionsMenuBoardRow | null,
|
||||
) {
|
||||
rerender(
|
||||
<ToastProvider>
|
||||
<StateProvider>
|
||||
<Harness boardRow={boardRow} />
|
||||
</StateProvider>
|
||||
</ToastProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("TaskActionsMenuDialogs — Edit dialog stays mounted through a board-row loss (AC-TASKS-TASK-ACTIONS-MENU-001.12)", () => {
|
||||
it("opens the Edit dialog for a resolved board row", () => {
|
||||
renderHarness(BOARD_ROW);
|
||||
fireEvent.click(screen.getByTestId("open-edit"));
|
||||
|
||||
expect(screen.getByTestId("edit-dialog-open")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders no Edit dialog at all when the board row has never resolved", () => {
|
||||
renderHarness(null);
|
||||
|
||||
expect(capturedEditDialogProps).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("closes the Edit dialog via its own open prop, not an abrupt unmount, when the board row disappears while it is open", () => {
|
||||
const { rerender } = renderHarness(BOARD_ROW);
|
||||
fireEvent.click(screen.getByTestId("open-edit"));
|
||||
expect(screen.getByTestId("edit-dialog-open")).toBeTruthy();
|
||||
capturedEditDialogProps.length = 0;
|
||||
|
||||
rerenderHarness(rerender, null);
|
||||
|
||||
// The dialog closed via `open` flipping to false in this same render,
|
||||
// not by the wrapper disappearing from the tree: a real unmount would
|
||||
// never give TaskCreateDialog the chance to run its own close-focus
|
||||
// transition.
|
||||
expect(screen.queryByTestId("edit-dialog-open")).toBeNull();
|
||||
expect(capturedEditDialogProps).toHaveLength(1);
|
||||
expect(capturedEditDialogProps[0]?.open).toBe(false);
|
||||
});
|
||||
|
||||
it("passes a focusReturnRef pointing at the trigger to the Edit dialog", () => {
|
||||
renderHarness(BOARD_ROW);
|
||||
fireEvent.click(screen.getByTestId("open-edit"));
|
||||
|
||||
expect(capturedEditDialogProps.at(-1)?.focusReturnRef).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskActionsMenuDialogs — card action parity", () => {
|
||||
it("includes Priority for a resolved task", () => {
|
||||
renderHarness(BOARD_ROW);
|
||||
|
||||
expect(screen.getByTestId("menu-entry-keys").textContent?.split(",")).toContain("priority");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskActionsMenuDialogs — Link dialogs get a focusReturnRef (AC-TASKS-TASK-ACTIONS-MENU-001.12)", () => {
|
||||
it("passes a focusReturnRef pointing at the trigger to the GitHub PR dialog", () => {
|
||||
renderHarness(BOARD_ROW);
|
||||
fireEvent.click(screen.getByTestId("open-pr"));
|
||||
|
||||
expect(screen.getByTestId("pr-dialog-open")).toBeTruthy();
|
||||
expect(capturedLinkDialogProps.pr.at(-1)?.focusReturnRef).toBeTruthy();
|
||||
});
|
||||
|
||||
it("passes a focusReturnRef pointing at the trigger to the GitHub Issue dialog", () => {
|
||||
renderHarness(BOARD_ROW);
|
||||
fireEvent.click(screen.getByTestId("open-issue"));
|
||||
|
||||
expect(screen.getByTestId("issue-dialog-open")).toBeTruthy();
|
||||
expect(capturedLinkDialogProps.issue.at(-1)?.focusReturnRef).toBeTruthy();
|
||||
});
|
||||
|
||||
it("passes a focusReturnRef pointing at the trigger to the GitLab MR dialog", () => {
|
||||
renderHarness(BOARD_ROW);
|
||||
fireEvent.click(screen.getByTestId("open-mr"));
|
||||
|
||||
expect(screen.getByTestId("mr-dialog-open")).toBeTruthy();
|
||||
expect(capturedLinkDialogProps.mr.at(-1)?.focusReturnRef).toBeTruthy();
|
||||
});
|
||||
|
||||
it("passes a focusReturnRef pointing at the trigger to the external link dialog", () => {
|
||||
renderHarness(BOARD_ROW);
|
||||
fireEvent.click(screen.getByTestId("open-external"));
|
||||
|
||||
expect(screen.getByTestId("external-dialog-open")).toBeTruthy();
|
||||
expect(capturedLinkDialogProps.external.at(-1)?.focusReturnRef).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, type RefObject } from "react";
|
||||
import { useActiveWorkspaceRepositories } from "@/components/kanban-card-repositories";
|
||||
import { TaskArchiveConfirmation } from "@/components/task/task-archive-confirmation";
|
||||
import { TaskCreateDialog } from "@/components/task-create-dialog";
|
||||
import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog";
|
||||
import { TaskDetachConfirmationSurface } from "@/components/task/task-detach-confirm-dialog";
|
||||
import { TaskExternalLinkDialog } 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 type { Task } from "@/components/kanban-card";
|
||||
import type { useTaskActionsMenu, TaskActionsMenuBoardRow } from "@/hooks/use-task-actions-menu";
|
||||
import { hydrateEditedTask } from "@/hooks/domains/kanban/use-kanban-actions";
|
||||
import { useAppStoreApi } from "@/components/state-provider";
|
||||
|
||||
type TaskActionsMenuState = ReturnType<typeof useTaskActionsMenu>;
|
||||
|
||||
function buildLinkDialogTask(
|
||||
taskId: string,
|
||||
taskTitle: string,
|
||||
boardRow: TaskActionsMenuBoardRow | null,
|
||||
): Task {
|
||||
return {
|
||||
id: taskId,
|
||||
title: taskTitle,
|
||||
workflowStepId: boardRow?.workflowStepId ?? "",
|
||||
repositories: boardRow?.repositories,
|
||||
};
|
||||
}
|
||||
|
||||
type TaskActionsMenuDialogsProps = {
|
||||
taskId: string | null;
|
||||
taskTitle: string;
|
||||
workspaceId: string | null;
|
||||
boardRow: TaskActionsMenuBoardRow | null;
|
||||
isArchiving?: boolean;
|
||||
isDeleting?: boolean;
|
||||
menu: TaskActionsMenuState;
|
||||
/** The subject's own executor type, independent of `boardRow`: falls back
|
||||
* to this for the Archive/Delete confirmations' executor-specific cleanup
|
||||
* copy when the board row is unresolvable (e.g. an archived task). */
|
||||
subjectExecutorType?: string | null;
|
||||
};
|
||||
|
||||
type TaskActionsMenuEditDialogProps = {
|
||||
taskId: string;
|
||||
taskTitle: string;
|
||||
workspaceId: string | null;
|
||||
boardRow: TaskActionsMenuBoardRow;
|
||||
/** Whether the dialog should be visually open. Distinct from
|
||||
* `menu.showEditDialog`: this also folds in board-row availability, so a
|
||||
* board row lost mid-edit closes the dialog through its own close
|
||||
* transition (and focus-return) rather than an abrupt unmount. */
|
||||
open: boolean;
|
||||
focusReturnRef: RefObject<HTMLElement | null>;
|
||||
menu: TaskActionsMenuState;
|
||||
};
|
||||
|
||||
function TaskActionsMenuEditDialog({
|
||||
taskId,
|
||||
taskTitle,
|
||||
workspaceId,
|
||||
boardRow,
|
||||
open,
|
||||
focusReturnRef,
|
||||
menu,
|
||||
}: TaskActionsMenuEditDialogProps) {
|
||||
const store = useAppStoreApi();
|
||||
const editSteps = menu.stepsByWorkflowId[menu.currentWorkflowId ?? ""] ?? [];
|
||||
|
||||
return (
|
||||
<TaskCreateDialog
|
||||
open={open}
|
||||
onOpenChange={menu.setShowEditDialog}
|
||||
focusReturnRef={focusReturnRef}
|
||||
mode="edit"
|
||||
workspaceId={workspaceId}
|
||||
workflowId={menu.currentWorkflowId}
|
||||
lockedFields={{ workflow: true }}
|
||||
defaultStepId={boardRow.workflowStepId ?? null}
|
||||
steps={editSteps}
|
||||
editingTask={{
|
||||
id: taskId,
|
||||
title: taskTitle,
|
||||
description: boardRow.description,
|
||||
workflowStepId: boardRow.workflowStepId ?? "",
|
||||
state: boardRow.state,
|
||||
repositoryId: boardRow.repositoryId,
|
||||
repositories: boardRow.repositories,
|
||||
}}
|
||||
initialValues={{
|
||||
title: taskTitle,
|
||||
description: boardRow.description,
|
||||
state: boardRow.state,
|
||||
repositoryId: boardRow.repositoryId,
|
||||
repositories: boardRow.repositories,
|
||||
}}
|
||||
onSuccess={(task) => hydrateEditedTask(store, task, store.getState().kanban)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks the most recent non-null board row seen for the current subject.
|
||||
* Lets the Edit dialog stay mounted (and so run its own close-focus
|
||||
* transition) when the row disappears mid-edit, instead of losing its data
|
||||
* to an abrupt unmount the instant `boardRow` goes null.
|
||||
*/
|
||||
function useLastResolvedBoardRow(
|
||||
taskId: string | null,
|
||||
boardRow: TaskActionsMenuBoardRow | null,
|
||||
): TaskActionsMenuBoardRow | null {
|
||||
const ref = useRef<{ taskId: string | null; boardRow: TaskActionsMenuBoardRow | null }>({
|
||||
taskId,
|
||||
boardRow,
|
||||
});
|
||||
if (ref.current.taskId !== taskId || boardRow != null) {
|
||||
ref.current = { taskId, boardRow };
|
||||
}
|
||||
return ref.current.boardRow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts every dialog a task actions menu entry can open. Hosted per surface
|
||||
* so a dialog outlives the menu that opened it.
|
||||
*/
|
||||
export function TaskActionsMenuDialogs({
|
||||
taskId,
|
||||
taskTitle,
|
||||
workspaceId,
|
||||
boardRow,
|
||||
isArchiving,
|
||||
isDeleting,
|
||||
menu,
|
||||
subjectExecutorType,
|
||||
}: TaskActionsMenuDialogsProps) {
|
||||
const repositories = useActiveWorkspaceRepositories();
|
||||
const editBoardRow = useLastResolvedBoardRow(taskId, boardRow);
|
||||
if (!taskId) return null;
|
||||
const linkDialogTask = buildLinkDialogTask(taskId, taskTitle, boardRow);
|
||||
|
||||
return (
|
||||
<>
|
||||
{editBoardRow && (
|
||||
<TaskActionsMenuEditDialog
|
||||
taskId={taskId}
|
||||
taskTitle={taskTitle}
|
||||
workspaceId={workspaceId}
|
||||
boardRow={editBoardRow}
|
||||
open={boardRow != null && menu.showEditDialog}
|
||||
focusReturnRef={menu.triggerRef}
|
||||
menu={menu}
|
||||
/>
|
||||
)}
|
||||
<TaskArchiveConfirmation
|
||||
open={menu.showArchiveConfirm}
|
||||
anchorRef={menu.triggerRef}
|
||||
focusReturnRef={menu.triggerRef}
|
||||
restoreFocusOnConfirm
|
||||
taskTitle={taskTitle}
|
||||
taskId={taskId}
|
||||
executorType={boardRow?.primaryExecutorType ?? subjectExecutorType}
|
||||
isArchiving={isArchiving}
|
||||
onOpenChange={menu.setShowArchiveConfirm}
|
||||
onConfirm={(values) => menu.onConfirmArchive(values)}
|
||||
/>
|
||||
<TaskDeleteConfirmDialog
|
||||
open={menu.showDeleteConfirm}
|
||||
onOpenChange={menu.setShowDeleteConfirm}
|
||||
taskTitle={taskTitle}
|
||||
taskId={taskId}
|
||||
executorType={boardRow?.primaryExecutorType ?? subjectExecutorType}
|
||||
isDeleting={isDeleting}
|
||||
onConfirm={(opts) => menu.onConfirmDelete(opts)}
|
||||
focusReturnRef={menu.triggerRef}
|
||||
/>
|
||||
<TaskDetachConfirmationSurface
|
||||
open={menu.showDetachConfirm}
|
||||
anchorRef={menu.triggerRef}
|
||||
focusReturnRef={menu.triggerRef}
|
||||
restoreFocusOnConfirm
|
||||
taskTitle={taskTitle}
|
||||
sharesParentWorkspace={boardRow?.workspaceMode === "inherit_parent"}
|
||||
onOpenChange={menu.setShowDetachConfirm}
|
||||
onConfirm={menu.handleDetachConfirm}
|
||||
/>
|
||||
<TaskGitHubPRDialog
|
||||
workspaceId={workspaceId}
|
||||
open={menu.showPRDialog}
|
||||
onOpenChange={menu.setShowPRDialog}
|
||||
task={linkDialogTask}
|
||||
repositories={repositories}
|
||||
focusReturnRef={menu.triggerRef}
|
||||
/>
|
||||
<TaskGitHubIssueDialog
|
||||
open={menu.showIssueDialog}
|
||||
onOpenChange={menu.setShowIssueDialog}
|
||||
task={linkDialogTask}
|
||||
repositories={repositories}
|
||||
focusReturnRef={menu.triggerRef}
|
||||
/>
|
||||
{workspaceId && (
|
||||
<TaskMRLinkDialog
|
||||
open={menu.showMRDialog}
|
||||
onOpenChange={menu.setShowMRDialog}
|
||||
taskId={taskId}
|
||||
workspaceId={workspaceId}
|
||||
taskRepositories={boardRow?.repositories ?? []}
|
||||
repositories={repositories}
|
||||
focusReturnRef={menu.triggerRef}
|
||||
/>
|
||||
)}
|
||||
{menu.externalLinkProvider && workspaceId && (
|
||||
<TaskExternalLinkDialog
|
||||
open={true}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) menu.setExternalLinkProvider(null);
|
||||
}}
|
||||
provider={menu.externalLinkProvider}
|
||||
task={linkDialogTask}
|
||||
workspaceId={workspaceId}
|
||||
focusReturnRef={menu.triggerRef}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import type { RefObject } from "react";
|
||||
import { IconDots } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@kandev/ui/dropdown-menu";
|
||||
import {
|
||||
KanbanCardDropdownMenuItems,
|
||||
type KanbanCardMenuEntry,
|
||||
} from "@/components/kanban-card-menu-items";
|
||||
|
||||
type TaskActionsMenuTriggerProps = {
|
||||
entries: KanbanCardMenuEntry[];
|
||||
testId: string;
|
||||
triggerRef: RefObject<HTMLButtonElement | null>;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
/** The "More options" dots trigger shared by the preview and detail surfaces. */
|
||||
export function TaskActionsMenuTrigger({
|
||||
entries,
|
||||
testId,
|
||||
triggerRef,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: TaskActionsMenuTriggerProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
data-testid={testId}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-muted rounded-sm p-1 -m-1 transition-colors cursor-pointer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
aria-label={t("kanban:moreOptions")}
|
||||
>
|
||||
<IconDots className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-56"
|
||||
onEscapeKeyDown={(event) => {
|
||||
// Consume this Escape ourselves instead of letting Radix's default
|
||||
// dismiss handle it: closing the menu via `onOpenChange` still
|
||||
// triggers Radix's synchronous focus-restoration flush, but calling
|
||||
// `preventDefault()` here (during document capture, before this
|
||||
// same event reaches any bubble-phase `window` listener a host
|
||||
// surface owns) lets that listener check `event.defaultPrevented`
|
||||
// and know this keypress was already handled by the menu, rather
|
||||
// than racing a state update that may not have re-rendered yet
|
||||
// (AC-TASKS-TASK-ACTIONS-MENU-001.11).
|
||||
event.preventDefault();
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<KanbanCardDropdownMenuItems entries={entries} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -32,7 +32,12 @@ import { useTranslation } from "react-i18next";
|
||||
type TaskArchiveConfirmDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Element to return keyboard focus to on close, confirmed or cancelled
|
||||
* (AC-TASKS-TASK-ACTIONS-MENU-001.12). Omitted callers keep Radix's
|
||||
* default restore-to-previously-focused-element behavior. */
|
||||
focusReturnRef?: RefObject<HTMLElement | null>;
|
||||
/** Restore focus after confirmation when the task surface remains mounted. */
|
||||
restoreFocusOnConfirm?: boolean;
|
||||
taskTitle?: string;
|
||||
isBulkOperation?: boolean;
|
||||
count?: number;
|
||||
@@ -52,6 +57,10 @@ type TaskArchiveConfirmDialogProps = {
|
||||
|
||||
type ArchiveOpenMode = "pending" | "confirm" | "bypass";
|
||||
|
||||
function shouldRestoreFocus(confirmed: boolean, restoreFocusOnConfirm?: boolean): boolean {
|
||||
return !confirmed || restoreFocusOnConfirm === true;
|
||||
}
|
||||
|
||||
function useArchiveConfirmationMode(
|
||||
open: boolean,
|
||||
confirmTaskArchive: boolean,
|
||||
@@ -108,6 +117,7 @@ export function TaskArchiveConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
focusReturnRef,
|
||||
restoreFocusOnConfirm,
|
||||
taskTitle,
|
||||
isBulkOperation,
|
||||
count,
|
||||
@@ -137,7 +147,7 @@ export function TaskArchiveConfirmDialog({
|
||||
const [cascade, setCascade] = useState(false);
|
||||
const confirmedRef = useRef(false);
|
||||
const restoreFocus = () => {
|
||||
if (confirmedRef.current) return;
|
||||
if (!shouldRestoreFocus(confirmedRef.current, restoreFocusOnConfirm)) return;
|
||||
const focusReturnTarget = focusReturnRef?.current;
|
||||
if (focusReturnTarget?.isConnected) focusReturnTarget.focus();
|
||||
};
|
||||
|
||||
@@ -309,3 +309,52 @@ describe("TaskArchiveConfirmation cleanup copy", () => {
|
||||
expect(screen.getByTestId(CLEANUP_NOTES_TEST_ID).tagName).toBe("SPAN");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskArchiveConfirmation focus return", () => {
|
||||
it("returns focus to the trigger when a confirmed archive fails", async () => {
|
||||
pointerState.isFinePointer = false;
|
||||
getSubtaskCountMock.mockResolvedValue({ count: 0 });
|
||||
const onConfirm = vi.fn(() => {
|
||||
const failure = Promise.reject(new Error("archive failed"));
|
||||
void failure.catch(() => undefined);
|
||||
return failure;
|
||||
});
|
||||
|
||||
function FocusHarness() {
|
||||
const [open, setOpen] = useState(true);
|
||||
const anchorRef = useRef<HTMLButtonElement>(null);
|
||||
return (
|
||||
<>
|
||||
<button ref={anchorRef} type="button" data-testid="archive-focus-anchor">
|
||||
Archive source
|
||||
</button>
|
||||
<TaskArchiveConfirmation
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
anchorRef={anchorRef}
|
||||
focusReturnRef={anchorRef}
|
||||
restoreFocusOnConfirm
|
||||
taskId="task-1"
|
||||
taskTitle="Task One"
|
||||
executorType="worktree"
|
||||
forceDialog
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
render(
|
||||
<StateProvider>
|
||||
<FocusHarness />
|
||||
</StateProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByTestId(CONFIRM_TEST_ID));
|
||||
|
||||
await waitFor(() => expect(onConfirm).toHaveBeenCalledOnce());
|
||||
await waitFor(() =>
|
||||
expect(document.activeElement).toBe(screen.getByTestId("archive-focus-anchor")),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ export type TaskArchiveConfirmationProps = {
|
||||
anchorRef: RefObject<HTMLElement | null>;
|
||||
focusReturnRef?: RefObject<HTMLElement | null>;
|
||||
focusBoundaryRef?: RefObject<HTMLElement | null>;
|
||||
restoreFocusOnConfirm?: boolean;
|
||||
onConfirm: (values: ArchiveConfirmValues) => void | Promise<void>;
|
||||
confirmTestId?: string;
|
||||
/** Render a simple confirmation inside an existing action surface. */
|
||||
@@ -110,6 +111,7 @@ function ArchiveConfirmPopover({
|
||||
anchorRef,
|
||||
focusReturnRef,
|
||||
focusBoundaryRef,
|
||||
restoreFocusOnConfirm = false,
|
||||
taskTitle,
|
||||
executorType,
|
||||
isInFlight,
|
||||
@@ -122,6 +124,7 @@ function ArchiveConfirmPopover({
|
||||
anchorRef: RefObject<HTMLElement | null>;
|
||||
focusReturnRef?: RefObject<HTMLElement | null>;
|
||||
focusBoundaryRef?: RefObject<HTMLElement | null>;
|
||||
restoreFocusOnConfirm?: boolean;
|
||||
taskTitle?: string;
|
||||
executorType?: string | null;
|
||||
isInFlight: boolean;
|
||||
@@ -140,6 +143,7 @@ function ArchiveConfirmPopover({
|
||||
anchorRef={anchorRef}
|
||||
focusReturnRef={focusReturnRef}
|
||||
focusBoundaryRef={focusBoundaryRef ?? anchorRef}
|
||||
restoreFocusOnConfirm={restoreFocusOnConfirm}
|
||||
title={t("task:archiveTaskTitle")}
|
||||
description={
|
||||
<ArchiveDescription taskTitle={taskTitle} cleanup={cleanup} taskIsInFlight={isInFlight} />
|
||||
@@ -208,6 +212,7 @@ type ArchiveDialogProps = Pick<
|
||||
| "executorTypes"
|
||||
| "onConfirm"
|
||||
| "confirmTestId"
|
||||
| "restoreFocusOnConfirm"
|
||||
> & {
|
||||
subtaskClassification: SubtaskCountResult;
|
||||
};
|
||||
@@ -297,6 +302,7 @@ function ArchiveConfirmationContent({
|
||||
anchorRef={anchorRef}
|
||||
focusReturnRef={focusReturnRef}
|
||||
focusBoundaryRef={focusBoundaryRef}
|
||||
restoreFocusOnConfirm={dialogProps.restoreFocusOnConfirm}
|
||||
taskTitle={dialogProps.taskTitle}
|
||||
executorType={dialogProps.executorType}
|
||||
isInFlight={taskIsInFlight}
|
||||
@@ -323,6 +329,7 @@ export function TaskArchiveConfirmation({
|
||||
anchorRef,
|
||||
focusReturnRef,
|
||||
focusBoundaryRef,
|
||||
restoreFocusOnConfirm,
|
||||
onConfirm,
|
||||
confirmTestId = DEFAULT_CONFIRM_TEST_ID,
|
||||
inline = false,
|
||||
@@ -344,6 +351,7 @@ export function TaskArchiveConfirmation({
|
||||
taskIsInFlight={taskIsInFlight}
|
||||
anchorRef={anchorRef}
|
||||
focusReturnRef={focusReturnRef}
|
||||
restoreFocusOnConfirm={restoreFocusOnConfirm}
|
||||
focusBoundaryRef={focusBoundaryRef}
|
||||
inline={inline}
|
||||
onOpenChange={onOpenChange}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, type RefObject } from "react";
|
||||
import { IconLoader } from "@tabler/icons-react";
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -50,6 +50,10 @@ type TaskDeleteConfirmDialogProps = {
|
||||
requireDiscardConsent?: boolean;
|
||||
onConfirm: (opts: { cascade: boolean; discardWorktreeChanges: boolean }) => void;
|
||||
confirmTestId?: string;
|
||||
/** Element to return keyboard focus to on close, confirmed or cancelled
|
||||
* (AC-TASKS-TASK-ACTIONS-MENU-001.12). Omitted callers keep Radix's
|
||||
* default restore-to-previously-focused-element behavior. */
|
||||
focusReturnRef?: RefObject<HTMLElement | null>;
|
||||
};
|
||||
|
||||
type DiscardWorktreeChangesOptionProps = {
|
||||
@@ -254,6 +258,7 @@ export function TaskDeleteConfirmDialog({
|
||||
requireDiscardConsent = false,
|
||||
onConfirm,
|
||||
confirmTestId,
|
||||
focusReturnRef,
|
||||
}: TaskDeleteConfirmDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const safeCount = count ?? 0;
|
||||
@@ -284,7 +289,17 @@ export function TaskDeleteConfirmDialog({
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={handleOpenChange}>
|
||||
<AlertDialogContent size="lg" className={TASK_CONFIRM_CLASS} onClick={stopDialogPropagation}>
|
||||
<AlertDialogContent
|
||||
size="lg"
|
||||
className={TASK_CONFIRM_CLASS}
|
||||
onClick={stopDialogPropagation}
|
||||
onCloseAutoFocus={(event) => {
|
||||
const target = focusReturnRef?.current;
|
||||
if (!target || !document.contains(target)) return;
|
||||
event.preventDefault();
|
||||
target.focus();
|
||||
}}
|
||||
>
|
||||
<AlertDialogHeader className={TASK_CONFIRM_HEADER_CLASS}>
|
||||
<AlertDialogTitle className="text-base font-semibold">{title}</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
|
||||
@@ -21,6 +21,8 @@ function PopoverHarness({ onConfirm = vi.fn() }: { onConfirm?: () => void | Prom
|
||||
<TaskDetachConfirmPopover
|
||||
open={open}
|
||||
anchorRef={anchorRef}
|
||||
focusReturnRef={anchorRef}
|
||||
restoreFocusOnConfirm
|
||||
taskTitle="Child task"
|
||||
sharesParentWorkspace
|
||||
onOpenChange={setOpen}
|
||||
@@ -69,6 +71,16 @@ describe("task detach confirmation adapters", () => {
|
||||
expect(screen.queryByTestId("detach-task-inline-confirmation")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns focus to the menu trigger after confirmation", async () => {
|
||||
const onConfirm = vi.fn();
|
||||
render(<PopoverHarness onConfirm={onConfirm} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("detach-task-confirm"));
|
||||
|
||||
await waitFor(() => expect(onConfirm).toHaveBeenCalledOnce());
|
||||
await waitFor(() => expect(document.activeElement).toBe(screen.getByTestId("detach-anchor")));
|
||||
});
|
||||
|
||||
it("keeps task-switcher detachment on its existing modal branch", async () => {
|
||||
const longTaskTitle = `Child task ${"x".repeat(180)}`;
|
||||
render(
|
||||
|
||||
@@ -74,6 +74,7 @@ export type TaskDetachConfirmPopoverProps = TaskDetachConfirmationCopyProps & {
|
||||
open: boolean;
|
||||
anchorRef: RefObject<HTMLElement | null>;
|
||||
focusReturnRef?: RefObject<HTMLElement | null>;
|
||||
restoreFocusOnConfirm?: boolean;
|
||||
focusBoundaryRef?: RefObject<HTMLElement | null>;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCancel?: () => void;
|
||||
@@ -84,6 +85,7 @@ export function TaskDetachConfirmPopover({
|
||||
open,
|
||||
anchorRef,
|
||||
focusReturnRef,
|
||||
restoreFocusOnConfirm = false,
|
||||
focusBoundaryRef,
|
||||
taskTitle,
|
||||
sharesParentWorkspace,
|
||||
@@ -97,6 +99,7 @@ export function TaskDetachConfirmPopover({
|
||||
open={open}
|
||||
anchorRef={anchorRef}
|
||||
focusReturnRef={focusReturnRef}
|
||||
restoreFocusOnConfirm={restoreFocusOnConfirm}
|
||||
focusBoundaryRef={focusBoundaryRef}
|
||||
title={t(DETACH_TASK_FROM_PARENT_KEY)}
|
||||
description={
|
||||
@@ -157,6 +160,7 @@ export type TaskDetachConfirmationSurfaceProps = TaskDetachConfirmationCopyProps
|
||||
open: boolean;
|
||||
anchorRef: RefObject<HTMLElement | null>;
|
||||
focusReturnRef?: RefObject<HTMLElement | null>;
|
||||
restoreFocusOnConfirm?: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
};
|
||||
@@ -165,6 +169,7 @@ export function TaskDetachConfirmationSurface({
|
||||
open,
|
||||
anchorRef,
|
||||
focusReturnRef,
|
||||
restoreFocusOnConfirm = false,
|
||||
taskTitle,
|
||||
sharesParentWorkspace,
|
||||
onOpenChange,
|
||||
@@ -177,6 +182,7 @@ export function TaskDetachConfirmationSurface({
|
||||
open={open}
|
||||
anchorRef={anchorRef}
|
||||
focusReturnRef={focusReturnRef}
|
||||
restoreFocusOnConfirm={restoreFocusOnConfirm}
|
||||
focusBoundaryRef={anchorRef}
|
||||
taskTitle={taskTitle}
|
||||
sharesParentWorkspace={sharesParentWorkspace}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, type RefObject } from "react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -23,6 +23,7 @@ import { JIRA_KEY_RE } from "@/components/jira/jira-ticket-common";
|
||||
import { LINEAR_KEY_RE } from "@/components/linear/linear-issue-common";
|
||||
import { extractSentryShortId } from "@/components/sentry/sentry-issue-common";
|
||||
import { useSentryInstances } from "@/hooks/domains/sentry/use-sentry-availability";
|
||||
import { createFocusReturnHandler } from "@/lib/dialog-focus-return";
|
||||
import { findTaskInSnapshots } from "@/lib/kanban/find-task";
|
||||
import type { SentryIssue } from "@/lib/types/sentry";
|
||||
import { buildLinkedIssueTitle } from "./task-external-link-utils";
|
||||
@@ -41,6 +42,9 @@ type TaskExternalLinkDialogProps = {
|
||||
provider: ExternalLinkProvider;
|
||||
task: TaskExternalLinkTarget;
|
||||
workspaceId: string;
|
||||
/** Element to return keyboard focus to on close (AC-TASKS-TASK-ACTIONS-MENU-001.12).
|
||||
* Omitted callers keep Radix's default restore-to-previously-focused-element behavior. */
|
||||
focusReturnRef?: RefObject<HTMLElement | null>;
|
||||
};
|
||||
|
||||
/** Copy fields hold CATALOG KEYS, not English: `PROVIDERS` is a module-scope
|
||||
@@ -252,6 +256,7 @@ export function TaskExternalLinkDialog({
|
||||
provider,
|
||||
task,
|
||||
workspaceId,
|
||||
focusReturnRef,
|
||||
}: TaskExternalLinkDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const config = PROVIDERS[provider];
|
||||
@@ -260,7 +265,10 @@ export function TaskExternalLinkDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg">
|
||||
<DialogContent
|
||||
className="w-[calc(100vw-2rem)] sm:max-w-lg"
|
||||
onCloseAutoFocus={createFocusReturnHandler(focusReturnRef)}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t(config.titleKey)}</DialogTitle>
|
||||
<DialogDescription>{t(config.descriptionKey)}</DialogDescription>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState, type RefObject } from "react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -14,6 +14,7 @@ import { Input } from "@kandev/ui/input";
|
||||
import { Label } from "@kandev/ui/label";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import { linkTaskIssue, unlinkTaskIssue } from "@/lib/api/domains/github-api";
|
||||
import { createFocusReturnHandler } from "@/lib/dialog-focus-return";
|
||||
import type { Repository } from "@/lib/types/http";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -40,6 +41,9 @@ type TaskGitHubIssueDialogProps = {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
task: TaskIssue;
|
||||
repositories: Repository[];
|
||||
/** Element to return keyboard focus to on close (AC-TASKS-TASK-ACTIONS-MENU-001.12).
|
||||
* Omitted callers keep Radix's default restore-to-previously-focused-element behavior. */
|
||||
focusReturnRef?: RefObject<HTMLElement | null>;
|
||||
};
|
||||
|
||||
type DialogFieldsProps = {
|
||||
@@ -163,6 +167,7 @@ export function TaskGitHubIssueDialog({
|
||||
onOpenChange,
|
||||
task,
|
||||
repositories,
|
||||
focusReturnRef,
|
||||
}: TaskGitHubIssueDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
@@ -219,7 +224,10 @@ export function TaskGitHubIssueDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg">
|
||||
<DialogContent
|
||||
className="w-[calc(100vw-2rem)] sm:max-w-lg"
|
||||
onCloseAutoFocus={createFocusReturnHandler(focusReturnRef)}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{currentLabel ? t("task:changeGithubIssue") : t("task:linkGithubIssue")}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, type RefObject } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "@kandev/ui/dialog";
|
||||
import { TaskChangeRequestLinkForm } from "@/components/integrations/task-change-request-link-form";
|
||||
import { createTaskPR } from "@/lib/api/domains/github-api";
|
||||
import { createFocusReturnHandler } from "@/lib/dialog-focus-return";
|
||||
import type { Repository } from "@/lib/types/http";
|
||||
import {
|
||||
githubReposForTask,
|
||||
@@ -28,6 +29,9 @@ type TaskGitHubPRDialogProps = {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
task: TaskPullRequestLinkTarget;
|
||||
repositories: Repository[];
|
||||
/** Element to return keyboard focus to on close (AC-TASKS-TASK-ACTIONS-MENU-001.12).
|
||||
* Omitted callers keep Radix's default restore-to-previously-focused-element behavior. */
|
||||
focusReturnRef?: RefObject<HTMLElement | null>;
|
||||
};
|
||||
|
||||
export function TaskGitHubPRDialog({
|
||||
@@ -36,6 +40,7 @@ export function TaskGitHubPRDialog({
|
||||
onOpenChange,
|
||||
task,
|
||||
repositories,
|
||||
focusReturnRef,
|
||||
}: TaskGitHubPRDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const githubRepos = useMemo(() => githubReposForTask(task, repositories), [task, repositories]);
|
||||
@@ -59,7 +64,10 @@ export function TaskGitHubPRDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg">
|
||||
<DialogContent
|
||||
className="w-[calc(100vw-2rem)] sm:max-w-lg"
|
||||
onCloseAutoFocus={createFocusReturnHandler(focusReturnRef)}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("task:linkGithubPullRequest")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { StateProvider } from "@/components/state-provider";
|
||||
import type { Task } from "@/lib/types/http";
|
||||
import { useTaskActionsMenuBoardRow } from "./task-page-content-helpers";
|
||||
|
||||
const TASK_ID = "task-1";
|
||||
|
||||
function makeTask(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: TASK_ID,
|
||||
title: "Fix the sidebar",
|
||||
workspace_id: "ws-1",
|
||||
workflow_id: "wf-1",
|
||||
workflow_step_id: "step-1",
|
||||
priority: "medium",
|
||||
repositories: [],
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
...overrides,
|
||||
} as Task;
|
||||
}
|
||||
|
||||
describe("useTaskActionsMenuBoardRow (AC-TASKS-TASK-ACTIONS-MENU-002.5)", () => {
|
||||
it("resolves the board row from a workflow snapshot, not the page's own task record", () => {
|
||||
function wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<StateProvider
|
||||
initialState={
|
||||
{
|
||||
kanbanMulti: {
|
||||
snapshots: {
|
||||
"wf-1": {
|
||||
workflowId: "wf-1",
|
||||
workflowName: "Workflow 1",
|
||||
steps: [],
|
||||
tasks: [
|
||||
{
|
||||
id: TASK_ID,
|
||||
workflowId: "wf-1",
|
||||
workflowStepId: "step-1",
|
||||
title: "Fix the sidebar (live)",
|
||||
position: 0,
|
||||
parentTaskId: "task-parent",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
},
|
||||
} as never
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</StateProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useTaskActionsMenuBoardRow(makeTask()), { wrapper });
|
||||
|
||||
expect(result.current).toEqual(
|
||||
expect.objectContaining({
|
||||
id: TASK_ID,
|
||||
title: "Fix the sidebar (live)",
|
||||
workflowStepId: "step-1",
|
||||
parentTaskId: "task-parent",
|
||||
priority: "medium",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null (the identifier-only tier) for a task with no board row anywhere on the board", () => {
|
||||
function wrapper({ children }: { children: React.ReactNode }) {
|
||||
return <StateProvider>{children}</StateProvider>;
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useTaskActionsMenuBoardRow(makeTask()), { wrapper });
|
||||
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when there is no subject task at all", () => {
|
||||
function wrapper({ children }: { children: React.ReactNode }) {
|
||||
return <StateProvider>{children}</StateProvider>;
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useTaskActionsMenuBoardRow(null), { wrapper });
|
||||
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
taskId as toTaskId,
|
||||
workflowId as toWorkflowId,
|
||||
@@ -8,6 +9,9 @@ import {
|
||||
import type { KanbanState } from "@/lib/state/slices";
|
||||
import { issueFieldsFromMetadata } from "@/lib/metadata-utils";
|
||||
import { repositorySlug } from "@/lib/repository-slug";
|
||||
import type { TaskActionsMenuBoardRow } from "@/hooks/use-task-actions-menu";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { findTaskInSnapshots } from "@/lib/kanban/find-task";
|
||||
|
||||
const EMPTY_REPOSITORIES: Repository[] = [];
|
||||
|
||||
@@ -235,6 +239,7 @@ export function resolveTaskIds(task: Task | null) {
|
||||
workspaceId: taskValues.workspace_id ?? null,
|
||||
projectId: taskValues.project_id ?? null,
|
||||
workflowStepId: taskValues.workflow_step_id ?? null,
|
||||
primaryExecutorType: taskValues.primary_executor_type ?? null,
|
||||
baseBranch: primaryRepository?.base_branch,
|
||||
pullRequestTarget: primaryRepository?.branch_policy_pull_request_target || undefined,
|
||||
isArchived: !!taskValues.archived_at,
|
||||
@@ -262,6 +267,41 @@ function buildPullRequestTargetsByRepository(
|
||||
return targets;
|
||||
}
|
||||
|
||||
/**
|
||||
* The detail top bar's actions-menu subject: a live board-row lookup against
|
||||
* the workflow snapshots with the flat task list as fallback (the same lookup
|
||||
* `useSidebarTaskEdit` performs), NOT the detail page's own task record. The
|
||||
* page's own record stays loaded on tasks the board has pruned (e.g. after a
|
||||
* peer archives/deletes it, or for a task that was never on any board, such
|
||||
* as an Office-managed task), so using it directly would make the
|
||||
* "unresolved-row" identifier-only tier (AC-TASKS-TASK-ACTIONS-MENU-002.5)
|
||||
* unreachable. Returns null when the subject genuinely has no board row.
|
||||
*/
|
||||
export function useTaskActionsMenuBoardRow(task: Task | null): TaskActionsMenuBoardRow | null {
|
||||
const taskId = task?.id ?? null;
|
||||
const taskPriority = task?.priority;
|
||||
const kanbanTasks = useAppStore((state) => state.kanban.tasks);
|
||||
const snapshots = useAppStore((state) => state.kanbanMulti.snapshots);
|
||||
return useMemo(() => {
|
||||
if (!taskId) return null;
|
||||
const boardTask = findTaskInSnapshots(taskId, snapshots, kanbanTasks);
|
||||
if (!boardTask) return null;
|
||||
return {
|
||||
id: boardTask.id,
|
||||
title: boardTask.title,
|
||||
description: boardTask.description,
|
||||
workflowStepId: boardTask.workflowStepId,
|
||||
state: boardTask.state,
|
||||
priority: boardTask.priority ?? taskPriority,
|
||||
repositoryId: boardTask.repositoryId,
|
||||
repositories: boardTask.repositories,
|
||||
parentTaskId: boardTask.parentTaskId,
|
||||
primaryExecutorType: boardTask.primaryExecutorType,
|
||||
workspaceMode: boardTask.workspaceMode,
|
||||
};
|
||||
}, [kanbanTasks, snapshots, taskId, taskPriority]);
|
||||
}
|
||||
|
||||
export function resolveTaskPullRequestProps(
|
||||
task: Pick<Task, "title" | "repositories"> | null,
|
||||
repositories: Repository[] = [],
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
buildDebugEntries,
|
||||
buildArchivedValue,
|
||||
resolveTaskProps,
|
||||
useTaskActionsMenuBoardRow,
|
||||
selectWorkspaceRepositories,
|
||||
} from "@/components/task/task-page-content-helpers";
|
||||
import type { useSessionResumption } from "@/hooks/domains/session/use-session-resumption";
|
||||
@@ -104,6 +105,7 @@ function resolveCurrentStepId(
|
||||
|
||||
function buildTaskTopBarProps(params: {
|
||||
taskProps: ReturnType<typeof resolveTaskProps>;
|
||||
actionsMenuBoardRow: ReturnType<typeof useTaskActionsMenuBoardRow>;
|
||||
workflowSteps: ReturnType<typeof useWorkflowStepsMapped>;
|
||||
showDebugOverlay: boolean;
|
||||
onToggleDebugOverlay: () => void;
|
||||
@@ -134,6 +136,13 @@ function buildTaskTopBarProps(params: {
|
||||
remoteExecutorType: params.remote.remoteExecutorType,
|
||||
officeTaskHref: params.officeTaskHref,
|
||||
onTaskUnarchived: params.onTaskUnarchived,
|
||||
actionsMenuBoardRow: params.actionsMenuBoardRow,
|
||||
// The subject's own last-known values, independent of `actionsMenuBoardRow`:
|
||||
// the board excludes archived (and can lag/miss cross-workflow) tasks, so
|
||||
// these stay available for the actions menu's plugin context and
|
||||
// executor-aware confirmation copy even when the board row is unresolvable.
|
||||
subjectWorkflowStepId: taskProps.workflowStepId,
|
||||
subjectPrimaryExecutorType: taskProps.primaryExecutorType,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -243,6 +252,7 @@ function useTaskPageDerivedProps({
|
||||
selectWorkspaceRepositories(state.repositories.itemsByWorkspaceId, task?.workspace_id),
|
||||
);
|
||||
const taskProps = resolveTaskProps(task, repository, workspaceRepositories);
|
||||
const actionsMenuBoardRow = useTaskActionsMenuBoardRow(task);
|
||||
const remote = resolveRemoteExecutor(resumption.sessionStatus as RemoteExecutorStatus | null);
|
||||
const embeddedVscode = useEmbeddedVscodeSupport(effectiveSessionId, resumption.sessionStatus);
|
||||
const activeSessionMetadata = useAppStore((state) =>
|
||||
@@ -261,6 +271,7 @@ function useTaskPageDerivedProps({
|
||||
});
|
||||
const topBarProps = buildTaskTopBarProps({
|
||||
taskProps,
|
||||
actionsMenuBoardRow,
|
||||
workflowSteps,
|
||||
showDebugOverlay,
|
||||
onToggleDebugOverlay,
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { StoreApi } from "zustand";
|
||||
import { StateProvider, useAppStoreApi } from "@/components/state-provider";
|
||||
import { ToastProvider } from "@/components/toast-provider";
|
||||
import { pluginRegistry } from "@/lib/plugins/registry";
|
||||
import type { AppState } from "@/lib/state/store";
|
||||
import { TaskTopBarActionsMenu } from "./task-top-bar-actions-menu";
|
||||
import type { TaskActionsMenuBoardRow } from "@/hooks/use-task-actions-menu";
|
||||
|
||||
type WsHandler = (message: { payload: Record<string, unknown> }) => void;
|
||||
const wsHandlers = new Map<string, Set<WsHandler>>();
|
||||
|
||||
function emitWs(type: string, payload: Record<string, unknown>) {
|
||||
const handlers = wsHandlers.get(type);
|
||||
if (!handlers) return;
|
||||
for (const handler of handlers) handler({ payload });
|
||||
}
|
||||
|
||||
const mockClient = {
|
||||
subscribe: vi.fn(() => vi.fn()),
|
||||
on: vi.fn((type: string, handler: WsHandler) => {
|
||||
const handlers = wsHandlers.get(type) ?? new Set();
|
||||
handlers.add(handler);
|
||||
wsHandlers.set(type, handlers);
|
||||
return () => {
|
||||
handlers.delete(handler);
|
||||
};
|
||||
}),
|
||||
};
|
||||
vi.mock("@/lib/ws/connection", () => ({
|
||||
getWebSocketClient: () => mockClient,
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
mockClient.subscribe.mockClear();
|
||||
mockClient.on.mockClear();
|
||||
wsHandlers.clear();
|
||||
});
|
||||
|
||||
const TASK_ID = "task-1";
|
||||
const OLD_WORKFLOW_ID = "wf-old";
|
||||
const NEW_WORKFLOW_ID = "wf-new";
|
||||
const TRIGGER_TEST_ID = "task-topbar-actions-menu";
|
||||
const MOVE_TO_TEST_ID = "task-context-move-to";
|
||||
|
||||
function initialStateWithTaskInActiveKanban(): Partial<AppState> {
|
||||
return {
|
||||
kanban: {
|
||||
workflowId: OLD_WORKFLOW_ID,
|
||||
tasks: [
|
||||
{
|
||||
id: TASK_ID,
|
||||
workflowId: OLD_WORKFLOW_ID,
|
||||
workflowStepId: "step-a",
|
||||
title: "Task 1",
|
||||
position: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
|
||||
function initialStateWithMoveTargets(): Partial<AppState> {
|
||||
return {
|
||||
kanban: {
|
||||
workflowId: OLD_WORKFLOW_ID,
|
||||
tasks: [
|
||||
{
|
||||
id: TASK_ID,
|
||||
workflowId: OLD_WORKFLOW_ID,
|
||||
workflowStepId: "step-a",
|
||||
title: "Task 1",
|
||||
position: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
kanbanMulti: {
|
||||
snapshots: {
|
||||
[OLD_WORKFLOW_ID]: {
|
||||
workflowId: OLD_WORKFLOW_ID,
|
||||
workflowName: "Workflow",
|
||||
steps: [
|
||||
{ id: "step-a", title: "Step A", position: 0 },
|
||||
{ id: "step-b", title: "Step B", position: 1 },
|
||||
],
|
||||
tasks: [
|
||||
{
|
||||
id: TASK_ID,
|
||||
workflowId: OLD_WORKFLOW_ID,
|
||||
workflowStepId: "step-a",
|
||||
title: "Task 1",
|
||||
position: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
workflows: {
|
||||
items: [{ id: OLD_WORKFLOW_ID, name: "Workflow", workspaceId: "ws-1", hidden: false }],
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
|
||||
const RESOLVED_BOARD_ROW: TaskActionsMenuBoardRow = {
|
||||
id: TASK_ID,
|
||||
title: "Task 1",
|
||||
workflowStepId: "step-a",
|
||||
};
|
||||
|
||||
let capturedStore: StoreApi<AppState> | null = null;
|
||||
|
||||
function StoreCapture() {
|
||||
capturedStore = useAppStoreApi();
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderMenu(initialState: Partial<AppState>) {
|
||||
capturedStore = null;
|
||||
render(
|
||||
<ToastProvider>
|
||||
<StateProvider initialState={initialState as never}>
|
||||
<StoreCapture />
|
||||
<TaskTopBarActionsMenu
|
||||
taskId={TASK_ID}
|
||||
taskTitle="Task 1"
|
||||
boardRow={null}
|
||||
workspaceId="ws-1"
|
||||
/>
|
||||
</StateProvider>
|
||||
</ToastProvider>,
|
||||
);
|
||||
return () => capturedStore!;
|
||||
}
|
||||
|
||||
function renderMenuWithBoardRow(
|
||||
initialState: Partial<AppState>,
|
||||
boardRow: TaskActionsMenuBoardRow | null,
|
||||
) {
|
||||
capturedStore = null;
|
||||
const { rerender } = render(
|
||||
<ToastProvider>
|
||||
<StateProvider initialState={initialState as never}>
|
||||
<StoreCapture />
|
||||
<TaskTopBarActionsMenu
|
||||
taskId={TASK_ID}
|
||||
taskTitle="Task 1"
|
||||
boardRow={boardRow}
|
||||
workspaceId="ws-1"
|
||||
/>
|
||||
</StateProvider>
|
||||
</ToastProvider>,
|
||||
);
|
||||
return {
|
||||
getStore: () => capturedStore!,
|
||||
setBoardRow: (next: TaskActionsMenuBoardRow | null) =>
|
||||
rerender(
|
||||
<ToastProvider>
|
||||
<StateProvider initialState={initialState as never}>
|
||||
<StoreCapture />
|
||||
<TaskTopBarActionsMenu
|
||||
taskId={TASK_ID}
|
||||
taskTitle="Task 1"
|
||||
boardRow={next}
|
||||
workspaceId="ws-1"
|
||||
/>
|
||||
</StateProvider>
|
||||
</ToastProvider>,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function openMenu(trigger: HTMLElement) {
|
||||
act(() => {
|
||||
trigger.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true }));
|
||||
trigger.click();
|
||||
});
|
||||
}
|
||||
|
||||
function expectMenuOpen(trigger: HTMLElement, open: boolean) {
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe(String(open));
|
||||
}
|
||||
|
||||
describe("TaskTopBarActionsMenu — WS subscription lifetime", () => {
|
||||
it("subscribes to the subject task's own WS updates for the lifetime of the trigger", () => {
|
||||
const { unmount } = render(
|
||||
<ToastProvider>
|
||||
<StateProvider initialState={initialStateWithTaskInActiveKanban() as never}>
|
||||
<TaskTopBarActionsMenu
|
||||
taskId={TASK_ID}
|
||||
taskTitle="Task 1"
|
||||
boardRow={null}
|
||||
workspaceId="ws-1"
|
||||
/>
|
||||
</StateProvider>
|
||||
</ToastProvider>,
|
||||
);
|
||||
|
||||
expect(mockClient.subscribe).toHaveBeenCalledWith(TASK_ID);
|
||||
const unsubscribe = mockClient.subscribe.mock.results[0]?.value as ReturnType<typeof vi.fn>;
|
||||
|
||||
unmount();
|
||||
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps the trigger mounted when a same-message workflow move lands the task in a workflow with no prior snapshot", () => {
|
||||
const getStore = renderMenu(initialStateWithTaskInActiveKanban());
|
||||
expect(screen.getByTestId(TRIGGER_TEST_ID)).toBeTruthy();
|
||||
|
||||
// A single task.updated for a cross-workflow move removes the task from
|
||||
// the old workflow's collections and adds it to the new one's snapshot
|
||||
// in one state transition (mirroring applyTaskUpdatedCache), so the
|
||||
// subject is never simultaneously absent from both.
|
||||
act(() => {
|
||||
getStore().setState((state) => ({
|
||||
kanban: { ...state.kanban, tasks: [] },
|
||||
kanbanMulti: {
|
||||
...state.kanbanMulti,
|
||||
snapshots: {
|
||||
...state.kanbanMulti.snapshots,
|
||||
[NEW_WORKFLOW_ID]: {
|
||||
workflowId: NEW_WORKFLOW_ID,
|
||||
workflowName: "New workflow",
|
||||
steps: [],
|
||||
tasks: [
|
||||
{
|
||||
id: TASK_ID,
|
||||
workflowId: NEW_WORKFLOW_ID,
|
||||
workflowStepId: "step-b",
|
||||
title: "Task 1",
|
||||
position: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
expect(screen.getByTestId(TRIGGER_TEST_ID)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskTopBarActionsMenu — live demotion on board-row loss (AC-TASKS-TASK-ACTIONS-MENU-002.6/004.1c)", () => {
|
||||
it("demotes Move to out of the menu in place, keeping the top-level menu open, when the board row is lost while the menu is open", () => {
|
||||
const { setBoardRow } = renderMenuWithBoardRow(
|
||||
initialStateWithMoveTargets(),
|
||||
RESOLVED_BOARD_ROW,
|
||||
);
|
||||
const trigger = screen.getByTestId(TRIGGER_TEST_ID);
|
||||
|
||||
openMenu(trigger);
|
||||
expectMenuOpen(trigger, true);
|
||||
expect(screen.getByTestId(MOVE_TO_TEST_ID)).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
setBoardRow(null);
|
||||
});
|
||||
|
||||
expectMenuOpen(trigger, true);
|
||||
expect(screen.queryByTestId(MOVE_TO_TEST_ID)).toBeNull();
|
||||
});
|
||||
|
||||
it("re-promotes Move to back into the menu in place when the board row resolves again while the menu stays open", () => {
|
||||
const { setBoardRow } = renderMenuWithBoardRow(initialStateWithMoveTargets(), null);
|
||||
const trigger = screen.getByTestId(TRIGGER_TEST_ID);
|
||||
|
||||
openMenu(trigger);
|
||||
expectMenuOpen(trigger, true);
|
||||
expect(screen.queryByTestId(MOVE_TO_TEST_ID)).toBeNull();
|
||||
|
||||
act(() => {
|
||||
setBoardRow(RESOLVED_BOARD_ROW);
|
||||
});
|
||||
|
||||
expectMenuOpen(trigger, true);
|
||||
expect(screen.getByTestId(MOVE_TO_TEST_ID)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskTopBarActionsMenu — closes on genuine removal (AC-TASKS-TASK-ACTIONS-MENU-004.5)", () => {
|
||||
it("closes the trigger's open menu when task.deleted arrives for this task", () => {
|
||||
renderMenu(initialStateWithTaskInActiveKanban());
|
||||
const trigger = screen.getByTestId(TRIGGER_TEST_ID);
|
||||
|
||||
openMenu(trigger);
|
||||
expectMenuOpen(trigger, true);
|
||||
|
||||
act(() => {
|
||||
emitWs("task.deleted", { task_id: TASK_ID });
|
||||
});
|
||||
|
||||
expectMenuOpen(trigger, false);
|
||||
});
|
||||
|
||||
it("closes the trigger's open menu when task.updated arrives with archived_at set for this task", () => {
|
||||
renderMenu(initialStateWithTaskInActiveKanban());
|
||||
const trigger = screen.getByTestId(TRIGGER_TEST_ID);
|
||||
|
||||
openMenu(trigger);
|
||||
expectMenuOpen(trigger, true);
|
||||
|
||||
act(() => {
|
||||
emitWs("task.updated", { task_id: TASK_ID, archived_at: "2026-09-03T00:00:00Z" });
|
||||
});
|
||||
|
||||
expectMenuOpen(trigger, false);
|
||||
});
|
||||
|
||||
it("ignores task.deleted for a different task id", () => {
|
||||
renderMenu(initialStateWithTaskInActiveKanban());
|
||||
const trigger = screen.getByTestId(TRIGGER_TEST_ID);
|
||||
|
||||
openMenu(trigger);
|
||||
expectMenuOpen(trigger, true);
|
||||
|
||||
act(() => {
|
||||
emitWs("task.deleted", { task_id: "some-other-task" });
|
||||
});
|
||||
|
||||
expectMenuOpen(trigger, true);
|
||||
});
|
||||
|
||||
it("ignores task.updated for this task when archived_at is absent (an ordinary field update)", () => {
|
||||
renderMenu(initialStateWithTaskInActiveKanban());
|
||||
const trigger = screen.getByTestId(TRIGGER_TEST_ID);
|
||||
|
||||
openMenu(trigger);
|
||||
expectMenuOpen(trigger, true);
|
||||
|
||||
act(() => {
|
||||
emitWs("task.updated", { task_id: TASK_ID, title: "Renamed" });
|
||||
});
|
||||
|
||||
expectMenuOpen(trigger, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskTopBarActionsMenu — board-row loss alone does not close the menu (AC-TASKS-TASK-ACTIONS-MENU-002.6/004.1c)", () => {
|
||||
it("stays open and only demotes entries when the board row disappears from the store without a task.deleted/archived lifecycle event", () => {
|
||||
const { getStore, setBoardRow } = renderMenuWithBoardRow(
|
||||
initialStateWithMoveTargets(),
|
||||
RESOLVED_BOARD_ROW,
|
||||
);
|
||||
const trigger = screen.getByTestId(TRIGGER_TEST_ID);
|
||||
|
||||
openMenu(trigger);
|
||||
expectMenuOpen(trigger, true);
|
||||
expect(screen.getByTestId(MOVE_TO_TEST_ID)).toBeTruthy();
|
||||
|
||||
// Mirrors what a real board-row loss looks like: the task's row vanishes
|
||||
// from the store's collections (e.g. `useTaskActionsMenuBoardRow` can no
|
||||
// longer resolve it) and the parent recomputes `boardRow` as null -- but
|
||||
// no task.deleted/archived_at lifecycle event ever arrives, because the
|
||||
// task itself is still alive. This is the exact state combination the
|
||||
// old `existsInBoard`-based implementation could not tell apart from a
|
||||
// genuine removal.
|
||||
act(() => {
|
||||
getStore().setState((state) => ({
|
||||
kanban: { ...state.kanban, tasks: [] },
|
||||
kanbanMulti: {
|
||||
...state.kanbanMulti,
|
||||
snapshots: {
|
||||
...state.kanbanMulti.snapshots,
|
||||
[OLD_WORKFLOW_ID]: {
|
||||
...state.kanbanMulti.snapshots[OLD_WORKFLOW_ID],
|
||||
tasks: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
setBoardRow(null);
|
||||
});
|
||||
|
||||
expectMenuOpen(trigger, true);
|
||||
expect(screen.queryByTestId(MOVE_TO_TEST_ID)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskTopBarActionsMenu — plugin context stays independent of the board row (AC-TASKS-TASK-ACTIONS-MENU-002.4b)", () => {
|
||||
const PLUGIN_ID = "test-plugin-subject-workflow-step";
|
||||
|
||||
afterEach(() => pluginRegistry.unregisterPlugin(PLUGIN_ID));
|
||||
|
||||
it("passes the subject's own workflowStepId to a plugin primary action for an archived subject with no board row", () => {
|
||||
const visible = vi.fn(() => true);
|
||||
pluginRegistry.forPlugin(PLUGIN_ID).registerTaskMenuAction({
|
||||
id: "primary-action",
|
||||
group: "primary",
|
||||
label: "Plugin primary action",
|
||||
visible,
|
||||
run: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<ToastProvider>
|
||||
<StateProvider initialState={{} as never}>
|
||||
<TaskTopBarActionsMenu
|
||||
taskId={TASK_ID}
|
||||
taskTitle="Task 1"
|
||||
boardRow={null}
|
||||
workspaceId="ws-1"
|
||||
isArchived
|
||||
subjectWorkflowStepId="step-last-known"
|
||||
/>
|
||||
</StateProvider>
|
||||
</ToastProvider>,
|
||||
);
|
||||
|
||||
expect(visible).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ workflowStepId: "step-last-known" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the board row's workflowStepId when no independent subject value is supplied", () => {
|
||||
const visible = vi.fn(() => true);
|
||||
pluginRegistry.forPlugin(PLUGIN_ID).registerTaskMenuAction({
|
||||
id: "primary-action",
|
||||
group: "primary",
|
||||
label: "Plugin primary action",
|
||||
visible,
|
||||
run: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<ToastProvider>
|
||||
<StateProvider initialState={{} as never}>
|
||||
<TaskTopBarActionsMenu
|
||||
taskId={TASK_ID}
|
||||
taskTitle="Task 1"
|
||||
boardRow={RESOLVED_BOARD_ROW}
|
||||
workspaceId="ws-1"
|
||||
/>
|
||||
</StateProvider>
|
||||
</ToastProvider>,
|
||||
);
|
||||
|
||||
expect(visible).toHaveBeenCalledWith(expect.objectContaining({ workflowStepId: "step-a" }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskTopBarActionsMenu — Delete confirmation keeps executor-specific cleanup copy without a board row", () => {
|
||||
it("shows the worktree-specific cleanup effect for an archived subject using the subject's own executor type", async () => {
|
||||
render(
|
||||
<ToastProvider>
|
||||
<StateProvider initialState={{} as never}>
|
||||
<TaskTopBarActionsMenu
|
||||
taskId={TASK_ID}
|
||||
taskTitle="Task 1"
|
||||
boardRow={null}
|
||||
workspaceId="ws-1"
|
||||
isArchived
|
||||
subjectPrimaryExecutorType="worktree"
|
||||
/>
|
||||
</StateProvider>
|
||||
</ToastProvider>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByTestId(TRIGGER_TEST_ID);
|
||||
openMenu(trigger);
|
||||
(await screen.findByRole("menuitem", { name: "Delete" })).click();
|
||||
|
||||
const dialog = await screen.findByRole("alertdialog");
|
||||
expect(dialog.textContent).toContain("The task's git worktree and its branch will be deleted.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskTopBarActionsMenu — Escape closes and returns focus (AC-TASKS-TASK-ACTIONS-MENU-001.9)", () => {
|
||||
it("closes the menu and returns focus to the trigger on Escape", async () => {
|
||||
renderMenu(initialStateWithTaskInActiveKanban());
|
||||
const trigger = screen.getByTestId(TRIGGER_TEST_ID);
|
||||
|
||||
openMenu(trigger);
|
||||
expectMenuOpen(trigger, true);
|
||||
|
||||
const menu = await screen.findByRole("menu");
|
||||
act(() => {
|
||||
menu.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
});
|
||||
|
||||
expectMenuOpen(trigger, false);
|
||||
await waitFor(() => expect(document.activeElement).toBe(trigger));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
"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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import { cleanup, render, screen, within } from "@testing-library/react";
|
||||
import { useEffect } from "react";
|
||||
import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { StateProvider } from "@/components/state-provider";
|
||||
import { StateProvider, useAppStoreApi } from "@/components/state-provider";
|
||||
import { ToastProvider } from "@/components/toast-provider";
|
||||
import { defaultState } from "@/lib/state/default-state";
|
||||
import { TaskTopBar } from "./task-top-bar";
|
||||
import type { TaskActionsMenuBoardRow } from "@/hooks/use-task-actions-menu";
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
@@ -165,7 +168,7 @@ describe("TaskTopBar repository crumb", () => {
|
||||
|
||||
it("names the task's repository so an open task says which project it belongs to", () => {
|
||||
renderTopBar(
|
||||
<TaskTopBar taskId="task-1" taskTitle="Fix the sidebar" repositoryLabel="kdlbs/kandev" />,
|
||||
<TaskTopBar taskId="task-1" taskTitle={TASK_TITLE} repositoryLabel="kdlbs/kandev" />,
|
||||
);
|
||||
|
||||
const crumb = repositoryCrumb("kdlbs/kandev");
|
||||
@@ -177,7 +180,7 @@ describe("TaskTopBar repository crumb", () => {
|
||||
});
|
||||
|
||||
it("renders no repository crumb for a task with no repository", () => {
|
||||
renderTopBar(<TaskTopBar taskId="task-1" taskTitle="Fix the sidebar" />);
|
||||
renderTopBar(<TaskTopBar taskId="task-1" taskTitle={TASK_TITLE} />);
|
||||
|
||||
// Any static crumb, not just a slug-shaped one: a repository with no
|
||||
// provider falls back to a bare name like "scratchpad", which a
|
||||
@@ -202,3 +205,155 @@ function renderTopBar(ui: React.ReactNode) {
|
||||
</StateProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const TASK_TITLE = "Fix the sidebar";
|
||||
const TRIGGER_TEST_ID = "task-topbar-actions-menu";
|
||||
|
||||
const NORMAL_BOARD_ROW: TaskActionsMenuBoardRow = {
|
||||
id: "task-1",
|
||||
title: TASK_TITLE,
|
||||
workflowStepId: "step-1",
|
||||
};
|
||||
|
||||
function getTrigger() {
|
||||
return screen.getByTestId(TRIGGER_TEST_ID);
|
||||
}
|
||||
|
||||
function openMenu() {
|
||||
const trigger = getTrigger();
|
||||
fireEvent.pointerDown(trigger, { button: 0, pointerId: 1 });
|
||||
fireEvent.click(trigger);
|
||||
return trigger;
|
||||
}
|
||||
|
||||
function expectMenuOpen(open: boolean) {
|
||||
expect(getTrigger().getAttribute("aria-expanded")).toBe(String(open));
|
||||
}
|
||||
|
||||
describe("TaskTopBar actions menu trigger", () => {
|
||||
it("renders no trigger when the top bar has no subject task", () => {
|
||||
renderTopBar(<TaskTopBar taskId={null} actionsMenuBoardRow={null} />);
|
||||
|
||||
expect(screen.queryByTestId(TRIGGER_TEST_ID)).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the trigger last in the right control group, with the More options accessible name", () => {
|
||||
renderTopBar(
|
||||
<TaskTopBar taskId="task-1" taskTitle={TASK_TITLE} actionsMenuBoardRow={NORMAL_BOARD_ROW} />,
|
||||
);
|
||||
|
||||
const trigger = getTrigger();
|
||||
expect(trigger.getAttribute("aria-label")).toBe("More options");
|
||||
expect(trigger.getAttribute("aria-haspopup")).toBe("menu");
|
||||
expectMenuOpen(false);
|
||||
|
||||
// Positioned after every other control already rendered in the group
|
||||
// (AC-TASKS-TASK-ACTIONS-MENU-001.2): every earlier control precedes it
|
||||
// in document order.
|
||||
const controls = screen.getAllByRole("button");
|
||||
expect(controls.at(-1)).toBe(trigger);
|
||||
});
|
||||
|
||||
it("opens a menu presenting Edit, Archive, and Delete for a normal (non-archived, resolvable) subject", () => {
|
||||
renderTopBar(
|
||||
<TaskTopBar taskId="task-1" taskTitle={TASK_TITLE} actionsMenuBoardRow={NORMAL_BOARD_ROW} />,
|
||||
);
|
||||
|
||||
openMenu();
|
||||
|
||||
expect(screen.getByRole("menuitem", { name: "Edit" })).toBeTruthy();
|
||||
expect(screen.getByRole("menuitem", { name: "Archive" })).toBeTruthy();
|
||||
expect(screen.getByRole("menuitem", { name: "Delete" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the trigger and identifier-only tier (Archive/Delete only) when the board row can't be resolved (AC-TASKS-TASK-ACTIONS-MENU-002.5)", () => {
|
||||
renderTopBar(<TaskTopBar taskId="task-1" taskTitle={TASK_TITLE} actionsMenuBoardRow={null} />);
|
||||
|
||||
expect(screen.queryByTestId(TRIGGER_TEST_ID)).toBeTruthy();
|
||||
openMenu();
|
||||
|
||||
expect(screen.getByRole("menuitem", { name: "Archive" })).toBeTruthy();
|
||||
expect(screen.getByRole("menuitem", { name: "Delete" })).toBeTruthy();
|
||||
expect(screen.queryByRole("menuitem", { name: "Edit" })).toBeNull();
|
||||
expect(screen.queryByRole("menuitem", { name: "Move to" })).toBeNull();
|
||||
});
|
||||
|
||||
it("presents only Delete (no Archive, no Edit) for an archived subject", () => {
|
||||
renderTopBar(
|
||||
<TaskTopBar
|
||||
taskId="task-1"
|
||||
taskTitle={TASK_TITLE}
|
||||
isArchived
|
||||
actionsMenuBoardRow={NORMAL_BOARD_ROW}
|
||||
/>,
|
||||
);
|
||||
|
||||
openMenu();
|
||||
|
||||
expect(screen.getByRole("menuitem", { name: "Delete" })).toBeTruthy();
|
||||
expect(screen.queryByRole("menuitem", { name: "Archive" })).toBeNull();
|
||||
expect(screen.queryByRole("menuitem", { name: "Edit" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
function StoreProbe({ onReady }: { onReady: (store: ReturnType<typeof useAppStoreApi>) => void }) {
|
||||
const store = useAppStoreApi();
|
||||
useEffect(() => onReady(store), [store, onReady]);
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("TaskTopBar actions menu — subject removed from the board (AC-TASKS-TASK-ACTIONS-MENU-004.5)", () => {
|
||||
// The subject leaving `kanban.tasks` alone is board-row loss, not genuine
|
||||
// removal (AC-TASKS-TASK-ACTIONS-MENU-002.6/004.1c): it must demote the
|
||||
// menu in place, never close it. `actionsMenuBoardRow` is a prop this
|
||||
// harness holds static, so only a real "gone" signal (task.deleted, or
|
||||
// task.updated with archived_at) may close the menu here, and this test has
|
||||
// no WS client wired up to send one.
|
||||
it("keeps an open menu open when the subject merely leaves the board's task collections", () => {
|
||||
let store: ReturnType<typeof useAppStoreApi> | null = null;
|
||||
renderTopBar(
|
||||
<StateProvider
|
||||
initialState={{
|
||||
kanban: {
|
||||
...defaultState.kanban,
|
||||
tasks: [
|
||||
{
|
||||
id: "task-1",
|
||||
workflowId: "workflow-1",
|
||||
workflowStepId: "step-1",
|
||||
title: TASK_TITLE,
|
||||
position: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
}}
|
||||
>
|
||||
<StoreProbe
|
||||
onReady={(api) => {
|
||||
store = api;
|
||||
}}
|
||||
/>
|
||||
<TaskTopBar taskId="task-1" taskTitle={TASK_TITLE} actionsMenuBoardRow={NORMAL_BOARD_ROW} />
|
||||
</StateProvider>,
|
||||
);
|
||||
|
||||
openMenu();
|
||||
expectMenuOpen(true);
|
||||
|
||||
act(() => {
|
||||
store!.setState((state) => ({ kanban: { ...state.kanban, tasks: [] } }));
|
||||
});
|
||||
|
||||
expectMenuOpen(true);
|
||||
});
|
||||
|
||||
it("does not close the menu for a task that has simply not loaded into the board yet", () => {
|
||||
renderTopBar(
|
||||
<TaskTopBar taskId="task-1" taskTitle={TASK_TITLE} actionsMenuBoardRow={NORMAL_BOARD_ROW} />,
|
||||
);
|
||||
|
||||
openMenu();
|
||||
|
||||
expectMenuOpen(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,10 +23,12 @@ import { TaskUnarchiveButton } from "@/components/task/task-unarchive-button";
|
||||
import { TaskAssigneeControl } from "@/components/task/task-assignee-control";
|
||||
import { WorkflowStepper, type WorkflowStepperStep } from "@/components/task/workflow-stepper";
|
||||
import { TaskTopBarPluginActions } from "@/components/task/task-top-bar-plugin-actions";
|
||||
import { TaskTopBarActionsMenu } from "@/components/task/task-top-bar-actions-menu";
|
||||
import { TopbarMetrics } from "@/components/system-metrics/topbar-metrics";
|
||||
import { RegisteredChangeRequestStatus } from "@/components/integrations/registered-change-request-status";
|
||||
import { isDebugUI } from "@/lib/config";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TaskActionsMenuBoardRow } from "@/hooks/use-task-actions-menu";
|
||||
|
||||
type TaskTopBarProps = {
|
||||
taskId?: string | null;
|
||||
@@ -50,6 +52,11 @@ type TaskTopBarProps = {
|
||||
onTaskUnarchived?: (taskId: string) => void;
|
||||
onMoveStart?: () => void;
|
||||
onMoveError?: (error: unknown) => void;
|
||||
actionsMenuBoardRow?: TaskActionsMenuBoardRow | null;
|
||||
/** The subject task's own values, independent of `actionsMenuBoardRow` (see
|
||||
* `TaskTopBarActionsMenuProps` for why the board row cannot be relied on). */
|
||||
subjectWorkflowStepId?: string | null;
|
||||
subjectPrimaryExecutorType?: string | null;
|
||||
};
|
||||
|
||||
const TaskTopBar = memo(function TaskTopBar({
|
||||
@@ -73,6 +80,9 @@ const TaskTopBar = memo(function TaskTopBar({
|
||||
onTaskUnarchived,
|
||||
onMoveStart,
|
||||
onMoveError,
|
||||
actionsMenuBoardRow,
|
||||
subjectWorkflowStepId,
|
||||
subjectPrimaryExecutorType,
|
||||
}: TaskTopBarProps) {
|
||||
const { t } = useTranslation();
|
||||
// Projects only exist for office-owned tasks, so kanban-mode tasks render no
|
||||
@@ -128,6 +138,9 @@ const TaskTopBar = memo(function TaskTopBar({
|
||||
issueNumber={issueNumber}
|
||||
officeTaskHref={officeTaskHref}
|
||||
onTaskUnarchived={onTaskUnarchived}
|
||||
actionsMenuBoardRow={actionsMenuBoardRow}
|
||||
subjectWorkflowStepId={subjectWorkflowStepId}
|
||||
subjectPrimaryExecutorType={subjectPrimaryExecutorType}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -366,6 +379,9 @@ function TopBarRight({
|
||||
issueNumber,
|
||||
officeTaskHref,
|
||||
onTaskUnarchived,
|
||||
actionsMenuBoardRow,
|
||||
subjectWorkflowStepId,
|
||||
subjectPrimaryExecutorType,
|
||||
}: {
|
||||
taskId?: string | null;
|
||||
activeSessionId?: string | null;
|
||||
@@ -379,6 +395,9 @@ function TopBarRight({
|
||||
issueNumber?: number;
|
||||
officeTaskHref?: string | null;
|
||||
onTaskUnarchived?: (taskId: string) => void;
|
||||
actionsMenuBoardRow?: TaskActionsMenuBoardRow | null;
|
||||
subjectWorkflowStepId?: string | null;
|
||||
subjectPrimaryExecutorType?: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
@@ -431,6 +450,15 @@ function TopBarRight({
|
||||
isArchived={isArchived}
|
||||
embeddedVscodeSupported={embeddedVscodeSupported}
|
||||
/>
|
||||
<TaskTopBarActionsMenu
|
||||
taskId={taskId ?? null}
|
||||
taskTitle={taskTitle ?? ""}
|
||||
boardRow={actionsMenuBoardRow ?? null}
|
||||
workspaceId={workspaceId ?? null}
|
||||
isArchived={isArchived}
|
||||
subjectWorkflowStepId={subjectWorkflowStepId}
|
||||
subjectPrimaryExecutorType={subjectPrimaryExecutorType}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
// Covers REQ-TASKS-TASK-ACTIONS-MENU-001/002/003 on the desktop task detail
|
||||
// top bar: the trigger this requirement adds, its parity entries, and the
|
||||
// detail-scoped Archive outcome (archive-and-switch away from the archived
|
||||
// task, matching every other task-scoped archive entry point).
|
||||
test.describe("Task detail top bar actions menu", () => {
|
||||
test("opens a menu with Edit, Archive, and Delete, and confirming Archive switches away from the task", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const task = await apiClient.createTask(seedData.workspaceId, "Detail Menu Archive Task", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
|
||||
const card = kanban.taskCardByTitle("Detail Menu Archive Task");
|
||||
await expect(card).toBeVisible({ timeout: 10_000 });
|
||||
await card.click();
|
||||
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
|
||||
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
|
||||
const topBar = testPage.getByTestId("task-topbar");
|
||||
const trigger = topBar.getByTestId("task-topbar-actions-menu");
|
||||
await expect(trigger).toHaveAttribute("aria-label", "More options");
|
||||
await trigger.click();
|
||||
|
||||
await expect(testPage.getByRole("menuitem", { name: "Edit" })).toBeVisible();
|
||||
await expect(testPage.getByRole("menuitem", { name: "Delete" })).toBeVisible();
|
||||
const archiveItem = testPage.getByRole("menuitem", { name: "Archive" });
|
||||
await expect(archiveItem).toBeVisible();
|
||||
await archiveItem.click();
|
||||
|
||||
const confirmation = testPage.getByTestId("task-archive-confirm-popover");
|
||||
await expect(confirmation).toBeVisible();
|
||||
await expect(confirmation).toContainText("Detail Menu Archive Task");
|
||||
await confirmation.getByTestId("archive-task-confirm").click();
|
||||
|
||||
// Archive-and-switch (AC-TASKS-TASK-ACTIONS-MENU-003.4): the detail route
|
||||
// no longer points at the archived task.
|
||||
await expect
|
||||
.poll(() => testPage.url(), {
|
||||
timeout: 15_000,
|
||||
message: "Waiting for switch-away navigation",
|
||||
})
|
||||
.not.toMatch(new RegExp(`/t/${task.id}(?:$|[/?])`));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
|
||||
// Covers REQ-TASKS-TASK-ACTIONS-MENU-001/002/003 on the desktop task preview
|
||||
// panel: the trigger this requirement adds, its parity entries, and the
|
||||
// preview-scoped Archive and Delete outcomes (remove from board, close the
|
||||
// panel, no navigation to the task detail route).
|
||||
test.describe("Task preview panel actions menu", () => {
|
||||
test("opens a menu with Edit, Archive, and Delete, and confirming Archive closes the preview without navigating", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
await apiClient.saveUserSettings({ enable_preview_on_click: true });
|
||||
await apiClient.createTask(seedData.workspaceId, "Preview Menu Archive Task", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
|
||||
const card = kanban.taskCardByTitle("Preview Menu Archive Task");
|
||||
await expect(card).toBeVisible({ timeout: 10_000 });
|
||||
await card.click();
|
||||
await expect(testPage).toHaveURL(/taskId=/, { timeout: 10_000 });
|
||||
|
||||
const previewPanel = testPage.getByTestId("task-preview-panel");
|
||||
await expect(previewPanel).toBeVisible();
|
||||
const startUrl = testPage.url();
|
||||
|
||||
const trigger = previewPanel.getByTestId("task-preview-actions-menu");
|
||||
await expect(trigger).toHaveAttribute("aria-label", "More options");
|
||||
await trigger.click();
|
||||
|
||||
const archiveItem = testPage.getByRole("menuitem", { name: "Archive" });
|
||||
await expect(archiveItem).toBeVisible();
|
||||
await archiveItem.click();
|
||||
|
||||
const confirmation = testPage.getByTestId("task-archive-confirm-popover");
|
||||
await expect(confirmation).toBeVisible();
|
||||
await expect(confirmation).toContainText("Preview Menu Archive Task");
|
||||
await confirmation.getByTestId("archive-task-confirm").click();
|
||||
|
||||
// AC-TASKS-TASK-ACTIONS-MENU-003.3: remove from the board, close the
|
||||
// preview panel, no navigation to the task detail route.
|
||||
await expect(kanban.taskCardByTitle("Preview Menu Archive Task")).not.toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(previewPanel).not.toBeVisible();
|
||||
expect(testPage.url()).not.toBe(startUrl);
|
||||
expect(testPage.url()).not.toMatch(/\/t\//);
|
||||
});
|
||||
|
||||
test("opens a menu with Edit, Archive, and Delete, and confirming Delete closes the preview without navigating", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
await apiClient.saveUserSettings({ enable_preview_on_click: true });
|
||||
await apiClient.createTask(seedData.workspaceId, "Preview Menu Delete Task", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
|
||||
const card = kanban.taskCardByTitle("Preview Menu Delete Task");
|
||||
await expect(card).toBeVisible({ timeout: 10_000 });
|
||||
await card.click();
|
||||
await expect(testPage).toHaveURL(/taskId=/, { timeout: 10_000 });
|
||||
|
||||
const previewPanel = testPage.getByTestId("task-preview-panel");
|
||||
await expect(previewPanel).toBeVisible();
|
||||
const startUrl = testPage.url();
|
||||
|
||||
const trigger = previewPanel.getByTestId("task-preview-actions-menu");
|
||||
await expect(trigger).toHaveAttribute("aria-label", "More options");
|
||||
await trigger.click();
|
||||
|
||||
await expect(testPage.getByRole("menuitem", { name: "Edit" })).toBeVisible();
|
||||
await expect(testPage.getByRole("menuitem", { name: "Archive" })).toBeVisible();
|
||||
const deleteItem = testPage.getByRole("menuitem", { name: "Delete" });
|
||||
await expect(deleteItem).toBeVisible();
|
||||
await deleteItem.click();
|
||||
|
||||
const dialog = testPage.getByRole("alertdialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog).toContainText("Preview Menu Delete Task");
|
||||
await dialog.getByTestId("delete-discard-worktree-checkbox").click();
|
||||
await dialog.getByRole("button", { name: "Delete" }).click();
|
||||
|
||||
await expect(kanban.taskCardByTitle("Preview Menu Delete Task")).not.toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(previewPanel).not.toBeVisible();
|
||||
// No navigation to the task detail route (AC-TASKS-TASK-ACTIONS-MENU-003.3).
|
||||
expect(testPage.url()).not.toBe(startUrl);
|
||||
expect(testPage.url()).not.toMatch(/\/t\//);
|
||||
});
|
||||
|
||||
test("Escape closes only the open menu, keeping the preview open, and returns focus to the trigger; a second Escape closes the preview", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
await apiClient.saveUserSettings({ enable_preview_on_click: true });
|
||||
await apiClient.createTask(seedData.workspaceId, "Preview Menu Escape Task", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
|
||||
const card = kanban.taskCardByTitle("Preview Menu Escape Task");
|
||||
await expect(card).toBeVisible({ timeout: 10_000 });
|
||||
await card.click();
|
||||
await expect(testPage).toHaveURL(/taskId=/, { timeout: 10_000 });
|
||||
|
||||
const previewPanel = testPage.getByTestId("task-preview-panel");
|
||||
await expect(previewPanel).toBeVisible();
|
||||
|
||||
const trigger = previewPanel.getByTestId("task-preview-actions-menu");
|
||||
await trigger.click();
|
||||
await expect(testPage.getByRole("menuitem", { name: "Edit" })).toBeVisible();
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true");
|
||||
|
||||
// First Escape (AC-TASKS-TASK-ACTIONS-MENU-001.9/001.11): closes only the
|
||||
// menu, leaves the preview open on the same subject, and returns focus to
|
||||
// the trigger that opened it.
|
||||
await testPage.keyboard.press("Escape");
|
||||
await expect(testPage.getByRole("menuitem", { name: "Edit" })).not.toBeVisible();
|
||||
await expect(previewPanel).toBeVisible();
|
||||
await expect(trigger).toBeFocused();
|
||||
|
||||
// Second Escape, with no menu open, closes the preview panel as it does
|
||||
// today.
|
||||
await testPage.keyboard.press("Escape");
|
||||
await expect(previewPanel).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -72,7 +72,7 @@ function hydrateCreatedTask(
|
||||
}
|
||||
|
||||
/** Handle editing an existing task - only update dialog-editable fields. */
|
||||
function hydrateEditedTask(
|
||||
export function hydrateEditedTask(
|
||||
store: ReturnType<typeof useAppStoreApi>,
|
||||
task: BackendTask,
|
||||
currentKanban: KanbanState,
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { StateProvider } from "@/components/state-provider";
|
||||
import { useTaskActionsMenuMoveTargets } from "./use-task-actions-menu-move-targets";
|
||||
|
||||
describe("useTaskActionsMenuMoveTargets — hidden step fallback (AC-TASKS-TASK-ACTIONS-MENU-002.3b)", () => {
|
||||
const WORKFLOW_ID = "wf-1";
|
||||
const TASK_ID = "task-1";
|
||||
const HIDDEN_STEP_ID = "step-hidden";
|
||||
|
||||
function wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<StateProvider
|
||||
initialState={
|
||||
{
|
||||
kanbanMulti: {
|
||||
snapshots: {
|
||||
[WORKFLOW_ID]: {
|
||||
workflowId: WORKFLOW_ID,
|
||||
workflowName: "Workflow 1",
|
||||
steps: [
|
||||
{ id: "step-a", title: "Todo", color: "blue", position: 0 },
|
||||
{ id: HIDDEN_STEP_ID, title: "Hidden step", color: "gray", position: 1 },
|
||||
{ id: "step-b", title: "Done", color: "green", position: 2 },
|
||||
],
|
||||
tasks: [
|
||||
{
|
||||
id: TASK_ID,
|
||||
workflowId: WORKFLOW_ID,
|
||||
workflowStepId: "step-a",
|
||||
title: "Task 1",
|
||||
position: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
},
|
||||
userSettings: {
|
||||
hiddenWorkflowStepIds: { [WORKFLOW_ID]: [HIDDEN_STEP_ID] },
|
||||
},
|
||||
} as never
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</StateProvider>
|
||||
);
|
||||
}
|
||||
|
||||
it("excludes hidden steps from the current workflow when the caller passes no explicit steps", () => {
|
||||
const { result } = renderHook(() => useTaskActionsMenuMoveTargets(TASK_ID), { wrapper });
|
||||
|
||||
expect(result.current.currentWorkflowId).toBe(WORKFLOW_ID);
|
||||
const stepIds = result.current.stepsByWorkflowId[WORKFLOW_ID].map((step) => step.id);
|
||||
expect(stepIds).toEqual(["step-a", "step-b"]);
|
||||
});
|
||||
|
||||
it("uses the caller's explicit steps as-is, bypassing the hidden-step fallback", () => {
|
||||
const explicitSteps = [{ id: HIDDEN_STEP_ID, title: "Hidden step", color: "gray" }];
|
||||
const { result } = renderHook(() => useTaskActionsMenuMoveTargets(TASK_ID, explicitSteps), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
const stepIds = result.current.stepsByWorkflowId[WORKFLOW_ID].map((step) => step.id);
|
||||
expect(stepIds).toEqual([HIDDEN_STEP_ID]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useTaskActionsMenuMoveTargets — flat-list fallback for a WS-arrived task (AC-TASKS-TASK-ACTIONS-MENU-002.1/002.2)", () => {
|
||||
const WORKFLOW_ID = "wf-1";
|
||||
const TASK_ID = "task-1";
|
||||
|
||||
function wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<StateProvider
|
||||
initialState={
|
||||
{
|
||||
kanban: {
|
||||
tasks: [
|
||||
{
|
||||
id: TASK_ID,
|
||||
workflowId: WORKFLOW_ID,
|
||||
workflowStepId: "step-a",
|
||||
title: "Task 1",
|
||||
position: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
kanbanMulti: {
|
||||
// The workflow's own snapshot has already loaded (steps present),
|
||||
// but this task has not yet been merged into `snapshot.tasks` -
|
||||
// the exact WS-arrived-before-snapshot-catch-up race.
|
||||
snapshots: {
|
||||
[WORKFLOW_ID]: {
|
||||
workflowId: WORKFLOW_ID,
|
||||
workflowName: "Workflow 1",
|
||||
steps: [
|
||||
{ id: "step-a", title: "Todo", color: "blue", position: 0 },
|
||||
{ id: "step-b", title: "Done", color: "green", position: 1 },
|
||||
],
|
||||
tasks: [],
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
},
|
||||
} as never
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</StateProvider>
|
||||
);
|
||||
}
|
||||
|
||||
it("resolves currentWorkflowId from the flat kanban.tasks list when no snapshot lists the task yet", () => {
|
||||
const { result } = renderHook(() => useTaskActionsMenuMoveTargets(TASK_ID), { wrapper });
|
||||
|
||||
expect(result.current.currentWorkflowId).toBe(WORKFLOW_ID);
|
||||
const stepIds = result.current.stepsByWorkflowId[WORKFLOW_ID].map((step) => step.id);
|
||||
expect(stepIds).toEqual(["step-a", "step-b"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import type { WorkflowStep } from "@/components/kanban-card";
|
||||
import type { KanbanCardMoveTargets } from "@/components/kanban-card-menu-items";
|
||||
import type { TaskMoveStep, TaskMoveWorkflow } from "@/components/task/task-move-context-menu";
|
||||
import { sortWorkflowStepsByPosition } from "@/lib/kanban/workflow-step-order";
|
||||
|
||||
/**
|
||||
* Move-target resolution for callers with no board-column context (the
|
||||
* preview panel, the detail top bar). Layers two fallbacks the card's hot
|
||||
* render path (`useKanbanCardMoveTargets`) deliberately omits (system
|
||||
* design: the card hook keeps a fixed, minimal subscription set so
|
||||
* unrelated writes to these slices never re-render every visible card):
|
||||
* - resolves `currentWorkflowId` from the flat `kanban.tasks` list when a
|
||||
* WS-arrived task hasn't yet been merged into its workflow's snapshot
|
||||
* (the same race `findTaskInSnapshots` falls back for).
|
||||
* - filters the current workflow's steps by the user's hidden-step
|
||||
* preference when the caller has no `steps` list of its own to pass
|
||||
* (AC-TASKS-TASK-ACTIONS-MENU-002.3b: Move to must match the card).
|
||||
*/
|
||||
export function useTaskActionsMenuMoveTargets(
|
||||
taskId: string,
|
||||
steps?: WorkflowStep[],
|
||||
): KanbanCardMoveTargets {
|
||||
const workflows = useAppStore((state) => state.workflows.items);
|
||||
const snapshots = useAppStore((state) => state.kanbanMulti.snapshots);
|
||||
const kanbanTasks = useAppStore((state) => state.kanban.tasks);
|
||||
const hiddenWorkflowStepIds = useAppStore((state) => state.userSettings.hiddenWorkflowStepIds);
|
||||
|
||||
const currentWorkflowId = useMemo(() => {
|
||||
for (const [workflowId, snapshot] of Object.entries(snapshots)) {
|
||||
if (snapshot.tasks.some((task) => task.id === taskId)) return workflowId;
|
||||
}
|
||||
return kanbanTasks.find((task) => task.id === taskId)?.workflowId ?? null;
|
||||
}, [snapshots, kanbanTasks, taskId]);
|
||||
|
||||
const workflowItems = useMemo<TaskMoveWorkflow[]>(() => {
|
||||
const current = workflows.find((workflow) => workflow.id === currentWorkflowId);
|
||||
return workflows
|
||||
.filter((workflow) => workflow.workspaceId === current?.workspaceId && !workflow.hidden)
|
||||
.map((workflow) => ({ id: workflow.id, name: workflow.name, hidden: workflow.hidden }));
|
||||
}, [workflows, currentWorkflowId]);
|
||||
|
||||
const fallbackCurrentWorkflowSteps = useMemo<WorkflowStep[] | undefined>(() => {
|
||||
if (!currentWorkflowId || steps) return undefined;
|
||||
const snapshotSteps = snapshots[currentWorkflowId]?.steps;
|
||||
if (!snapshotSteps) return undefined;
|
||||
const hiddenIds = hiddenWorkflowStepIds[currentWorkflowId];
|
||||
const hiddenSet = hiddenIds && hiddenIds.length > 0 ? new Set(hiddenIds) : null;
|
||||
const sorted = sortWorkflowStepsByPosition(snapshotSteps);
|
||||
return hiddenSet ? sorted.filter((step) => !hiddenSet.has(step.id)) : sorted;
|
||||
}, [currentWorkflowId, steps, snapshots, hiddenWorkflowStepIds]);
|
||||
|
||||
const stepsByWorkflowId = useMemo<Record<string, TaskMoveStep[]>>(() => {
|
||||
const result: Record<string, TaskMoveStep[]> = {};
|
||||
for (const [workflowId, snapshot] of Object.entries(snapshots)) {
|
||||
result[workflowId] = sortWorkflowStepsByPosition(snapshot.steps).map((step) => ({
|
||||
id: step.id,
|
||||
title: step.title,
|
||||
color: step.color,
|
||||
events: step.events,
|
||||
}));
|
||||
}
|
||||
const effectiveCurrentWorkflowSteps = steps ?? fallbackCurrentWorkflowSteps;
|
||||
if (currentWorkflowId && effectiveCurrentWorkflowSteps) {
|
||||
result[currentWorkflowId] = effectiveCurrentWorkflowSteps.map((step) => ({
|
||||
id: step.id,
|
||||
title: step.title,
|
||||
color: step.color,
|
||||
events: step.events,
|
||||
}));
|
||||
}
|
||||
return result;
|
||||
}, [snapshots, currentWorkflowId, steps, fallbackCurrentWorkflowSteps]);
|
||||
|
||||
return { currentWorkflowId, workflowItems, stepsByWorkflowId };
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
"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;
|
||||
onSendToWorkflow: (workflowId: string, stepId: string) => 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,
|
||||
onSendToWorkflow: args.onSendToWorkflow,
|
||||
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]);
|
||||
|
||||
return { closeDialogs, requestDetachConfirmation, requestArchiveConfirmation };
|
||||
}
|
||||
|
||||
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 } =
|
||||
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 onSendToWorkflow = useCallback(
|
||||
(workflowId: string, stepId: string) => {
|
||||
if (!taskId) return;
|
||||
void moveTasks([taskId], workflowId, stepId, "workflow").catch(() => {
|
||||
// useTaskWorkflowMove already shows the failure toast.
|
||||
});
|
||||
},
|
||||
[moveTasks, 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,
|
||||
onSendToWorkflow,
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
|
||||
const NETWORK_ERROR_MESSAGE = "network error";
|
||||
|
||||
const archiveTaskMock = vi.fn();
|
||||
const deleteTaskMock = vi.fn();
|
||||
const removeTaskFromBoardMock = vi.fn();
|
||||
@@ -40,7 +42,11 @@ vi.mock("@/components/toast-provider", () => ({
|
||||
useToast: () => ({ toast: toastMock }),
|
||||
}));
|
||||
|
||||
import { useArchiveAndSwitchTask, useTaskActions } from "./use-task-actions";
|
||||
import {
|
||||
useArchiveAndSwitchTask,
|
||||
useDeleteAndSwitchTask,
|
||||
useTaskActions,
|
||||
} from "./use-task-actions";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
@@ -90,7 +96,7 @@ describe("useArchiveAndSwitchTask", () => {
|
||||
});
|
||||
|
||||
it("restores active task when archive API rejects after switching", async () => {
|
||||
const error = new Error("network error");
|
||||
const error = new Error(NETWORK_ERROR_MESSAGE);
|
||||
archiveTaskMock.mockRejectedValueOnce(error);
|
||||
removeTaskFromBoardMock.mockImplementationOnce(async () => {
|
||||
storeState.tasks.activeTaskId = "task-B";
|
||||
@@ -98,7 +104,7 @@ describe("useArchiveAndSwitchTask", () => {
|
||||
});
|
||||
const { result } = renderHook(() => useArchiveAndSwitchTask());
|
||||
|
||||
await expect(result.current("task-A")).rejects.toThrow("network error");
|
||||
await expect(result.current("task-A")).rejects.toThrow(NETWORK_ERROR_MESSAGE);
|
||||
|
||||
expect(removeTaskFromBoardMock).toHaveBeenCalledTimes(1);
|
||||
expect(removeTaskFromBoardMock).toHaveBeenCalledWith("task-A", {
|
||||
@@ -151,6 +157,49 @@ describe("useArchiveAndSwitchTask", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("useDeleteAndSwitchTask", () => {
|
||||
it("removes and switches away from the active task before delete API resolves", async () => {
|
||||
const deletion = deferred<void>();
|
||||
deleteTaskMock.mockReturnValueOnce(deletion.promise);
|
||||
const { result } = renderHook(() => useDeleteAndSwitchTask());
|
||||
|
||||
let deleteAndSwitchPromise!: Promise<void>;
|
||||
act(() => {
|
||||
deleteAndSwitchPromise = result.current("task-A");
|
||||
});
|
||||
|
||||
expect(removeTaskFromBoardMock).toHaveBeenCalledWith("task-A", {
|
||||
wasActiveTaskId: "task-A",
|
||||
wasActiveSessionId: "sess-A",
|
||||
switchOnly: true,
|
||||
});
|
||||
|
||||
deletion.resolve();
|
||||
await deleteAndSwitchPromise;
|
||||
expect(deleteTaskMock).toHaveBeenCalledWith("task-A", undefined);
|
||||
expect(removeTaskFromBoardMock).toHaveBeenLastCalledWith("task-A", {
|
||||
wasActiveTaskId: "task-A",
|
||||
wasActiveSessionId: "sess-A",
|
||||
});
|
||||
});
|
||||
|
||||
it("restores active task when delete API rejects after switching", async () => {
|
||||
const error = new Error(NETWORK_ERROR_MESSAGE);
|
||||
deleteTaskMock.mockRejectedValueOnce(error);
|
||||
removeTaskFromBoardMock.mockImplementationOnce(async () => {
|
||||
storeState.tasks.activeTaskId = "task-B";
|
||||
return { switchedTaskId: "task-B" };
|
||||
});
|
||||
const { result } = renderHook(() => useDeleteAndSwitchTask());
|
||||
|
||||
await expect(result.current("task-A")).rejects.toThrow(NETWORK_ERROR_MESSAGE);
|
||||
|
||||
expect(setActiveSessionMock).toHaveBeenCalledWith("task-A", "sess-A");
|
||||
expect(replaceTaskUrlMock).toHaveBeenCalledWith("task-A");
|
||||
expect(deleteTaskMock).toHaveBeenCalledWith("task-A", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useTaskActions", () => {
|
||||
it("shows localized retry guidance for a dirty-worktree delete conflict", async () => {
|
||||
const { ApiError } = await import("@/lib/api/client");
|
||||
|
||||
@@ -53,22 +53,26 @@ export function useTaskActions() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Archives a task and switches to the next available task.
|
||||
* Shared between the PR merged banner and the sidebar archive action.
|
||||
* Runs a one-shot task action (archive or delete) and switches to the next
|
||||
* available task, restoring the previous active task if the action rejects
|
||||
* after an optimistic switch. Shared shape behind `useArchiveAndSwitchTask`
|
||||
* and `useDeleteAndSwitchTask`.
|
||||
*/
|
||||
export function useArchiveAndSwitchTask(opts?: { useLayoutSwitch?: boolean }) {
|
||||
function useSwitchAfterTaskAction(
|
||||
runAction: (taskId: string, opts?: { cascade?: boolean }) => Promise<unknown>,
|
||||
opts?: { useLayoutSwitch?: boolean },
|
||||
) {
|
||||
const store = useAppStoreApi();
|
||||
const { archiveTaskById } = useTaskActions();
|
||||
const { removeTaskFromBoard } = useTaskRemoval({
|
||||
store,
|
||||
useLayoutSwitch: opts?.useLayoutSwitch,
|
||||
});
|
||||
|
||||
return useCallback(
|
||||
async (taskId: string, opts?: TaskActionOptions) => {
|
||||
async (taskId: string, actionOpts?: { cascade?: boolean }) => {
|
||||
const { activeTaskId: wasActiveTaskId, activeSessionId: wasActiveSessionId } =
|
||||
store.getState().tasks;
|
||||
const removalOptions = opts?.cascade ? { excludeTaskTree: true } : {};
|
||||
const removalOptions = actionOpts?.cascade ? { excludeTaskTree: true } : {};
|
||||
|
||||
const initialSwitch = await removeTaskFromBoard(taskId, {
|
||||
wasActiveTaskId,
|
||||
@@ -78,7 +82,7 @@ export function useArchiveAndSwitchTask(opts?: { useLayoutSwitch?: boolean }) {
|
||||
});
|
||||
|
||||
try {
|
||||
await archiveTaskById(taskId, opts);
|
||||
await runAction(taskId, actionOpts);
|
||||
await removeTaskFromBoard(taskId, {
|
||||
wasActiveTaskId,
|
||||
wasActiveSessionId,
|
||||
@@ -103,6 +107,24 @@ export function useArchiveAndSwitchTask(opts?: { useLayoutSwitch?: boolean }) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[archiveTaskById, removeTaskFromBoard, store],
|
||||
[runAction, removeTaskFromBoard, store],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Archives a task and switches to the next available task.
|
||||
* Shared between the PR merged banner and the sidebar archive action.
|
||||
*/
|
||||
export function useArchiveAndSwitchTask(opts?: { useLayoutSwitch?: boolean }) {
|
||||
const { archiveTaskById } = useTaskActions();
|
||||
return useSwitchAfterTaskAction(archiveTaskById, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a task and switches to the next available task, mirroring
|
||||
* `useArchiveAndSwitchTask`'s outcome for the task detail surface.
|
||||
*/
|
||||
export function useDeleteAndSwitchTask(opts?: { useLayoutSwitch?: boolean }) {
|
||||
const { deleteTaskById } = useTaskActions();
|
||||
return useSwitchAfterTaskAction(deleteTaskById, opts);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import type { ExternalLinkProvider } from "@/components/task/task-external-link-dialog";
|
||||
|
||||
/** Every confirm/link-dialog open flag a task actions menu and its dialogs share.
|
||||
* `closeAll` resets every flag; callers use it whenever the subject a dialog
|
||||
* was opened for is no longer the current one (AC-TASKS-TASK-ACTIONS-MENU-004.5a's
|
||||
* no-retarget rule applies to these dialogs, not just the trigger itself). */
|
||||
export function useTaskMenuDialogState() {
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [showArchiveConfirm, setShowArchiveConfirm] = useState(false);
|
||||
const [showDetachConfirm, setShowDetachConfirm] = useState(false);
|
||||
const [showPRDialog, setShowPRDialog] = useState(false);
|
||||
const [showIssueDialog, setShowIssueDialog] = useState(false);
|
||||
const [showMRDialog, setShowMRDialog] = useState(false);
|
||||
const [externalLinkProvider, setExternalLinkProvider] = useState<ExternalLinkProvider | null>(
|
||||
null,
|
||||
);
|
||||
const closeAll = useCallback(() => {
|
||||
setShowDeleteConfirm(false);
|
||||
setShowArchiveConfirm(false);
|
||||
setShowDetachConfirm(false);
|
||||
setShowPRDialog(false);
|
||||
setShowIssueDialog(false);
|
||||
setShowMRDialog(false);
|
||||
setExternalLinkProvider(null);
|
||||
}, []);
|
||||
return {
|
||||
showDeleteConfirm,
|
||||
setShowDeleteConfirm,
|
||||
showArchiveConfirm,
|
||||
setShowArchiveConfirm,
|
||||
showDetachConfirm,
|
||||
setShowDetachConfirm,
|
||||
showPRDialog,
|
||||
setShowPRDialog,
|
||||
showIssueDialog,
|
||||
setShowIssueDialog,
|
||||
showMRDialog,
|
||||
setShowMRDialog,
|
||||
externalLinkProvider,
|
||||
setExternalLinkProvider,
|
||||
closeAll,
|
||||
};
|
||||
}
|
||||
|
||||
/** Edit-dialog open flag for a single-subject task actions menu (preview/detail). */
|
||||
export function useTaskMenuEditDialogState() {
|
||||
const [showEditDialog, setShowEditDialog] = useState(false);
|
||||
const closeAll = useCallback(() => setShowEditDialog(false), []);
|
||||
return { showEditDialog, setShowEditDialog, closeAll };
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { RefObject } from "react";
|
||||
|
||||
/**
|
||||
* Builds a Radix `onCloseAutoFocus` handler that returns keyboard focus to
|
||||
* `focusReturnRef` on close. Radix's own default restores focus to whatever
|
||||
* element had it when the dialog opened, which has nothing valid to return to
|
||||
* when the dialog was opened from a menu item that has already unmounted
|
||||
* (e.g. the task actions menu closes on entry activation before its dialog
|
||||
* opens).
|
||||
*/
|
||||
export function createFocusReturnHandler(focusReturnRef?: RefObject<HTMLElement | null>) {
|
||||
return (event: Event) => {
|
||||
const target = focusReturnRef?.current;
|
||||
if (!target || !document.contains(target)) return;
|
||||
event.preventDefault();
|
||||
target.focus();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { pluginRegistry } from "@/lib/plugins/registry";
|
||||
import { buildTaskActionsMenuEntries } from "./task-actions-menu-entries";
|
||||
import type { KanbanCardMenuEntry } from "@/components/kanban-card-menu-items";
|
||||
|
||||
const PLUGIN_ID = "test-task-actions-menu-plugin";
|
||||
const DELETE_SEPARATOR = "delete-separator";
|
||||
|
||||
function itemKeys(entries: KanbanCardMenuEntry[]): string[] {
|
||||
return entries.map((entry) => entry.key);
|
||||
}
|
||||
|
||||
const baseArgs = {
|
||||
currentWorkflowId: "workflow-1",
|
||||
currentStepId: "step-1",
|
||||
workflows: [
|
||||
{ id: "workflow-1", name: "Workflow 1" },
|
||||
{ id: "workflow-2", name: "Workflow 2" },
|
||||
],
|
||||
stepsByWorkflowId: {
|
||||
"workflow-1": [
|
||||
{ id: "step-1", title: "Step 1" },
|
||||
{ id: "step-2", title: "Step 2" },
|
||||
],
|
||||
"workflow-2": [{ id: "step-3", title: "Step 3" }],
|
||||
},
|
||||
onEdit: vi.fn(),
|
||||
onArchive: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onDetach: vi.fn(),
|
||||
parentTaskId: "parent-1",
|
||||
currentPriority: "high",
|
||||
onSelectPriority: vi.fn(),
|
||||
onMoveToStep: vi.fn(),
|
||||
onSendToWorkflow: vi.fn(),
|
||||
// A real card always wires at least one link handler; omitting it here
|
||||
// would silently drop "link" from the entries array and let the order
|
||||
// assertion below pass without ever exercising its actual position.
|
||||
onLinkPullRequest: vi.fn(),
|
||||
};
|
||||
|
||||
describe("buildTaskActionsMenuEntries — normal tier", () => {
|
||||
afterEach(() => pluginRegistry.unregisterPlugin(PLUGIN_ID));
|
||||
|
||||
it("always uses the flat Edit item, even when a plugin edit-group action is registered", () => {
|
||||
pluginRegistry.forPlugin(PLUGIN_ID).registerTaskMenuAction({
|
||||
id: "edit-action",
|
||||
group: "edit",
|
||||
label: "Plugin edit action",
|
||||
run: vi.fn(),
|
||||
});
|
||||
|
||||
const entries = buildTaskActionsMenuEntries("normal", baseArgs);
|
||||
const edit = entries.find((entry) => entry.key === "edit");
|
||||
|
||||
expect(edit?.kind).toBe("item");
|
||||
});
|
||||
|
||||
it("presents the full card order for a resolved, unarchived subject", () => {
|
||||
const entries = buildTaskActionsMenuEntries("normal", baseArgs);
|
||||
expect(itemKeys(entries)).toEqual([
|
||||
"edit",
|
||||
"priority",
|
||||
"move-to",
|
||||
"send-to-workflow",
|
||||
"link",
|
||||
"archive",
|
||||
"detach",
|
||||
DELETE_SEPARATOR,
|
||||
"delete",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTaskActionsMenuEntries — in-flight disabling (AC-TASKS-TASK-ACTIONS-MENU-004.1)", () => {
|
||||
function itemByKey(entries: KanbanCardMenuEntry[], key: string) {
|
||||
return entries.find((entry) => entry.key === key);
|
||||
}
|
||||
|
||||
it("disables Archive, Detach, and Delete while an archive request from this menu is in flight", () => {
|
||||
const entries = buildTaskActionsMenuEntries("normal", { ...baseArgs, isArchiving: true });
|
||||
|
||||
expect(itemByKey(entries, "archive")).toMatchObject({ disabled: true });
|
||||
expect(itemByKey(entries, "detach")).toMatchObject({ disabled: true });
|
||||
expect(itemByKey(entries, "delete")).toMatchObject({ disabled: true });
|
||||
});
|
||||
|
||||
it("disables Archive, Detach, and Delete while a delete request from this menu is in flight", () => {
|
||||
const entries = buildTaskActionsMenuEntries("normal", { ...baseArgs, isDeleting: true });
|
||||
|
||||
expect(itemByKey(entries, "archive")).toMatchObject({ disabled: true });
|
||||
expect(itemByKey(entries, "detach")).toMatchObject({ disabled: true });
|
||||
expect(itemByKey(entries, "delete")).toMatchObject({ disabled: true });
|
||||
});
|
||||
|
||||
it("disables Archive, Detach, and Delete while a detach request from this menu is in flight", () => {
|
||||
const entries = buildTaskActionsMenuEntries("normal", { ...baseArgs, isDetaching: true });
|
||||
|
||||
expect(itemByKey(entries, "archive")).toMatchObject({ disabled: true });
|
||||
expect(itemByKey(entries, "detach")).toMatchObject({ disabled: true });
|
||||
expect(itemByKey(entries, "delete")).toMatchObject({ disabled: true });
|
||||
});
|
||||
|
||||
it("leaves every entry enabled when nothing from this menu is in flight", () => {
|
||||
const entries = buildTaskActionsMenuEntries("normal", baseArgs);
|
||||
|
||||
expect(itemByKey(entries, "archive")).toMatchObject({ disabled: false });
|
||||
expect(itemByKey(entries, "detach")).toMatchObject({ disabled: false });
|
||||
expect(itemByKey(entries, "delete")).toMatchObject({ disabled: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTaskActionsMenuEntries — archived tier", () => {
|
||||
afterEach(() => pluginRegistry.unregisterPlugin(PLUGIN_ID));
|
||||
|
||||
it("presents Delete alone with no leading separator when no plugin action is admitted", () => {
|
||||
const entries = buildTaskActionsMenuEntries("archived", baseArgs);
|
||||
expect(itemKeys(entries)).toEqual(["delete"]);
|
||||
});
|
||||
|
||||
it("presents admitted plugin primary actions, a separator, then Delete", () => {
|
||||
pluginRegistry.forPlugin(PLUGIN_ID).registerTaskMenuAction({
|
||||
id: "primary-action",
|
||||
group: "primary",
|
||||
label: "Plugin primary action",
|
||||
run: vi.fn(),
|
||||
});
|
||||
|
||||
const entries = buildTaskActionsMenuEntries("archived", baseArgs);
|
||||
expect(itemKeys(entries)).toEqual([
|
||||
`plugin-primary-${PLUGIN_ID}-primary-action`,
|
||||
DELETE_SEPARATOR,
|
||||
"delete",
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits Edit, Move to, Send to workflow, Link, Detach from parent, and Archive", () => {
|
||||
const entries = buildTaskActionsMenuEntries("archived", baseArgs);
|
||||
const keys = itemKeys(entries);
|
||||
for (const omitted of ["edit", "move-to", "send-to-workflow", "link", "archive", "detach"]) {
|
||||
expect(keys).not.toContain(omitted);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTaskActionsMenuEntries — unresolved board row tier", () => {
|
||||
afterEach(() => pluginRegistry.unregisterPlugin(PLUGIN_ID));
|
||||
|
||||
it("presents only Archive and Delete when no plugin action is admitted", () => {
|
||||
const entries = buildTaskActionsMenuEntries("unresolved-row", baseArgs);
|
||||
expect(itemKeys(entries)).toEqual(["archive", DELETE_SEPARATOR, "delete"]);
|
||||
});
|
||||
|
||||
it("orders admitted plugin primary actions before Archive", () => {
|
||||
pluginRegistry.forPlugin(PLUGIN_ID).registerTaskMenuAction({
|
||||
id: "primary-action",
|
||||
group: "primary",
|
||||
label: "Plugin primary action",
|
||||
run: vi.fn(),
|
||||
});
|
||||
|
||||
const entries = buildTaskActionsMenuEntries("unresolved-row", baseArgs);
|
||||
expect(itemKeys(entries)).toEqual([
|
||||
`plugin-primary-${PLUGIN_ID}-primary-action`,
|
||||
"archive",
|
||||
DELETE_SEPARATOR,
|
||||
"delete",
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits Edit, Move to, Send to workflow, Link, and Detach from parent even with a parentTaskId", () => {
|
||||
const entries = buildTaskActionsMenuEntries("unresolved-row", baseArgs);
|
||||
const keys = itemKeys(entries);
|
||||
for (const omitted of ["edit", "move-to", "send-to-workflow", "link", "detach"]) {
|
||||
expect(keys).not.toContain(omitted);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
buildArchiveEntry,
|
||||
buildDeleteEntry,
|
||||
buildKanbanCardMenuEntries,
|
||||
resolvePluginMenuContext,
|
||||
type KanbanCardMenuEntry,
|
||||
} from "@/components/kanban-card-menu-items";
|
||||
import { buildPrimaryPluginEntries } from "@/components/plugins/task-menu-actions";
|
||||
import type { BuildKanbanCardMenuEntriesArgs } from "@/components/kanban-card-menu-items";
|
||||
|
||||
/**
|
||||
* `normal`: the subject is not archived and its board row is resolved, so
|
||||
* the full card entry set applies. `archived`: the board excludes archived
|
||||
* tasks, so this is a detail-surface-only branch the card has none to
|
||||
* inherit from. `unresolved-row`: the subject's board row cannot be found,
|
||||
* so only identifier-only entries are available.
|
||||
*/
|
||||
export type TaskActionsMenuTier = "normal" | "archived" | "unresolved-row";
|
||||
|
||||
export function buildTaskActionsMenuEntries(
|
||||
tier: TaskActionsMenuTier,
|
||||
args: BuildKanbanCardMenuEntriesArgs,
|
||||
): KanbanCardMenuEntry[] {
|
||||
if (tier === "archived") return buildArchivedTaskActionsMenuEntries(args);
|
||||
if (tier === "unresolved-row") return buildUnresolvedRowTaskActionsMenuEntries(args);
|
||||
return buildKanbanCardMenuEntries({ ...args, forceFlatEdit: true });
|
||||
}
|
||||
|
||||
function buildArchivedTaskActionsMenuEntries({
|
||||
disabled,
|
||||
isDeleting,
|
||||
onDelete,
|
||||
pluginMenuContext,
|
||||
}: BuildKanbanCardMenuEntriesArgs): KanbanCardMenuEntry[] {
|
||||
const isProcessing = Boolean(disabled || isDeleting);
|
||||
const pluginEntries = buildPrimaryPluginEntries({
|
||||
disabled: isProcessing,
|
||||
context: resolvePluginMenuContext(pluginMenuContext),
|
||||
});
|
||||
const entries: KanbanCardMenuEntry[] = [...pluginEntries];
|
||||
if (pluginEntries.length > 0) {
|
||||
entries.push({ kind: "separator", key: "delete-separator" });
|
||||
}
|
||||
entries.push(buildDeleteEntry({ isDeleting, isProcessing, onDelete }));
|
||||
return entries;
|
||||
}
|
||||
|
||||
function buildUnresolvedRowTaskActionsMenuEntries({
|
||||
disabled,
|
||||
isDeleting,
|
||||
isArchiving,
|
||||
onArchive,
|
||||
onDelete,
|
||||
pluginMenuContext,
|
||||
}: BuildKanbanCardMenuEntriesArgs): KanbanCardMenuEntry[] {
|
||||
const isProcessing = Boolean(disabled || isDeleting || isArchiving);
|
||||
return [
|
||||
...buildPrimaryPluginEntries({
|
||||
disabled: isProcessing,
|
||||
context: resolvePluginMenuContext(pluginMenuContext),
|
||||
}),
|
||||
buildArchiveEntry({ isArchiving, isProcessing, onArchive }),
|
||||
{ kind: "separator", key: "delete-separator" },
|
||||
buildDeleteEntry({ isDeleting, isProcessing, onDelete }),
|
||||
];
|
||||
}
|
||||
@@ -85,6 +85,9 @@ signals, and task-scoped scheduling contracts.
|
||||
- [Runtime Task-State Publication Order](requirements/runtime-state-publication-order.md)
|
||||
- [Session Delete Preserves Task Workspaces](requirements/session-delete-resource-cleanup.md)
|
||||
- [Sidebar Task Editing](requirements/sidebar-task-edit.md)
|
||||
- [Task Actions Menu on Preview and Detail Surfaces](requirements/task-actions-menu.md)
|
||||
- [Task Actions Menu In-Flight and Concurrency](requirements/task-actions-menu-concurrency.md)
|
||||
- [Task Actions Menu Action Outcomes](requirements/task-actions-menu-outcomes.md)
|
||||
- [Subtasks as Workflow Checklist](requirements/subtask-checklist.md)
|
||||
- [Subtask Completion Trigger](requirements/subtask-completion-trigger.md)
|
||||
- [Subtask detachment](requirements/subtask-detachment.md)
|
||||
@@ -150,6 +153,7 @@ signals, and task-scoped scheduling contracts.
|
||||
- [Saved Prompt Delivery](system-design/saved-prompt-delivery.md)
|
||||
- [Passthrough Initial Prompt Turn Boundary](system-design/passthrough-initial-prompt-turn-boundary.md)
|
||||
- [Prompt attachments](system-design/prompt-attachments.md)
|
||||
- [Task Actions Menu on Preview and Detail Surfaces](system-design/task-actions-menu.md)
|
||||
- [Task Archive Confirmation](system-design/archive-confirmation.md)
|
||||
- [Task plan content size limit](system-design/plan-content-size-limit.md)
|
||||
- [Task plan write consistency](system-design/plan-write-consistency.md)
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
---
|
||||
status: draft
|
||||
system: tasks
|
||||
created: 2026-09-03
|
||||
owners:
|
||||
- Kandev
|
||||
---
|
||||
|
||||
# Task Actions Menu In-Flight and Concurrency Requirements
|
||||
|
||||
## Overview
|
||||
|
||||
The actions menu added to the task preview and task detail surfaces by
|
||||
[Task Actions Menu on Preview and Detail Surfaces](task-actions-menu.md) creates
|
||||
two new entry points to task actions that already exist. This file owns what
|
||||
those entry points do while a request is in flight, when two surfaces act on one
|
||||
task, and when a request fails. Terminology, entry content, and post-action
|
||||
navigation are defined in the parent requirement and are not restated here.
|
||||
|
||||
## Requirements
|
||||
|
||||
### REQ-TASKS-TASK-ACTIONS-MENU-004: In-flight, concurrent, and failing actions
|
||||
|
||||
**Intent:** The new entry points cannot produce a duplicate destructive request,
|
||||
a stuck menu, or a silent failure.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-004.1:** While an archive, delete, or detach
|
||||
request started from a surface's actions menu is in flight, the system shall
|
||||
present that surface's actions-menu entries as disabled and shall not start a
|
||||
second request from that menu. Because
|
||||
AC-TASKS-TASK-ACTIONS-MENU-004.2 closes the menu on a terminal activation,
|
||||
and archive, delete and detach are terminal, this state is observed by
|
||||
reopening the menu while the request is still in flight.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-004.1a:** Move to, Send to workflow, and Link are
|
||||
deliberately outside AC-TASKS-TASK-ACTIONS-MENU-004.1, matching the card,
|
||||
whose menu carries no pending state for them either. Selecting a Link entry
|
||||
opens a dialog and starts no task-state request, so the dialog owns its own
|
||||
submit state. For Move to and Send to workflow the system shall permit a
|
||||
second target to be selected before the first request settles, shall issue
|
||||
each as an independent request, and shall let the last one the server applies
|
||||
determine the task's step, on
|
||||
AC-TASKS-TASK-ACTIONS-MENU-004.3a's terms. The system shall not introduce a
|
||||
menu-level in-flight lock for these entries.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-004.1b:** The in-flight disabled state of
|
||||
AC-TASKS-TASK-ACTIONS-MENU-004.1 is scoped to the surface that started the
|
||||
request. A surface's menu shall disable its entries only for a request that
|
||||
surface itself started, and shall not disable them because a Kanban card or
|
||||
the other surface has a request in flight for the same task. The system shall
|
||||
not introduce shared, task-keyed pending state to close that gap. A card
|
||||
mid-archive and a preview panel showing the same task may therefore present
|
||||
different enabled states for the duration of that request. This is deliberate,
|
||||
it is the named exception in AC-TASKS-TASK-ACTIONS-MENU-002.1, and it matches
|
||||
the card, whose own pending flags are board-scoped or hook-local and are
|
||||
already not shared with any other surface.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-004.1c:** When the subject task's board row stops
|
||||
being resolvable while a Move to or Send to workflow submenu is open, the
|
||||
system shall demote those entries per AC-TASKS-TASK-ACTIONS-MENU-002.6, which
|
||||
removes the submenu's parent entry and so closes the open submenu while
|
||||
leaving the top-level menu open. A move request already in flight shall be
|
||||
left to complete or fail on its own terms; the system shall not cancel it, and
|
||||
shall keep the surface open on the subject task per
|
||||
AC-TASKS-TASK-ACTIONS-MENU-003.6. Losing the board row is not losing the task:
|
||||
AC-TASKS-TASK-ACTIONS-MENU-004.5 governs the latter.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-004.2:** When the user activates a *terminal*
|
||||
entry, meaning one that opens a dialog, dispatches a request, or invokes a
|
||||
plugin action, the system
|
||||
shall close the menu, so a single activation produces at most one confirmation
|
||||
and at most one request. Opening a submenu is not a terminal activation: when
|
||||
the user opens the Move to or Send to workflow submenu required by
|
||||
AC-TASKS-TASK-ACTIONS-MENU-002.2, the system shall leave both that submenu and
|
||||
the top-level menu open until a target inside it is chosen, and choosing that
|
||||
target is itself a terminal activation and closes both.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-004.3:** When the same task is acted on
|
||||
concurrently from two surfaces by a *one-shot* action, meaning Archive or
|
||||
Delete, the system shall send each confirmed request independently; the first
|
||||
request to be applied determines task state, and the later request shall
|
||||
surface the existing failure feedback for its action rather than a new error
|
||||
surface. Two actions are deliberately outside this criterion because neither
|
||||
is one-shot: Move to and Send to workflow are repeatable and are governed by
|
||||
AC-TASKS-TASK-ACTIONS-MENU-004.3a, and Detach from parent is idempotent and is
|
||||
governed by AC-TASKS-TASK-ACTIONS-MENU-004.3b.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-004.3a:** When the same task is moved
|
||||
concurrently, whether from two surfaces or twice from one menu, the system
|
||||
shall send each move independently and shall not treat the later move as a
|
||||
conflict. A move is repeatable and carries no precondition on the step it
|
||||
moves from, so the last move the server applies determines the task's step,
|
||||
and the system shall raise no failure feedback for an earlier move merely
|
||||
being superseded. Failure feedback for a move is raised only when the server
|
||||
rejects that move on its own terms
|
||||
(AC-TASKS-TASK-ACTIONS-MENU-004.4).
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-004.3b:** Detach from parent is idempotent rather
|
||||
than one-shot, and the system shall not treat a concurrent or repeated detach
|
||||
of one task as a conflict. A task that is already parentless shall remain
|
||||
parentless and shall produce no failure feedback. Failure feedback for a
|
||||
detach is raised only when the detach operation fails, per
|
||||
AC-TASKS-TASK-ACTIONS-MENU-004.4.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-004.4:** When an archive, delete, move, detach,
|
||||
or link request started from either surface fails, the system shall surface
|
||||
the existing failure feedback for that action, shall leave the task in its
|
||||
last confirmed state, and shall keep the surface open on that task.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-004.4a:** AC-TASKS-TASK-ACTIONS-MENU-004.5 takes
|
||||
precedence over AC-TASKS-TASK-ACTIONS-MENU-004.4 whenever both apply. When a
|
||||
request fails because another actor archived or deleted the subject task,
|
||||
the surface shall apply its existing missing-task behavior instead of staying
|
||||
open on a task that is gone. The system shall still surface the existing
|
||||
failure feedback for the request. For every other failure, the surface shall
|
||||
stay open on the task and show that feedback.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-004.5:** When a task disappears from beneath a
|
||||
surface for a reason other than the user's own action, such as another client
|
||||
archiving it, the system shall apply that surface's existing missing-task
|
||||
behavior and shall not leave an actions menu open over a subject that no
|
||||
longer exists.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-004.5a:** An actions menu shall never act on a
|
||||
task other than the one it was opened on. There are three ways a menu can stop
|
||||
matching its subject, and this criterion owns the third. The board row
|
||||
becoming unresolvable is governed by AC-TASKS-TASK-ACTIONS-MENU-002.6 and
|
||||
AC-TASKS-TASK-ACTIONS-MENU-004.1c, which demote entries but keep the menu
|
||||
open. The subject being removed is governed by
|
||||
AC-TASKS-TASK-ACTIONS-MENU-004.5, which closes it. The subject being *replaced
|
||||
by a different task* is governed here: when the preview surface's subject task
|
||||
changes identity while its actions menu is open, which is reachable because
|
||||
the preview panel re-renders with a different task rather than unmounting, the
|
||||
system shall close that menu and shall not re-target it at the new subject.
|
||||
Reopening the trigger then opens a menu on the new subject, per
|
||||
AC-TASKS-TASK-ACTIONS-MENU-002.6. For this criterion the subject's identity is
|
||||
the subject task's *identifier*, compared across renders, and not the identity
|
||||
of the object the surface holds that task in. The system shall not treat a new
|
||||
task object bearing the same identifier as a change of subject: the preview
|
||||
rebuilds that object whenever the board's task collections change, so a
|
||||
field-only update to the subject, or an unrelated task's update anywhere on
|
||||
the board, shall leave an open menu open, which is what
|
||||
AC-TASKS-TASK-ACTIONS-MENU-002.6 requires of an in-place entry update. Only a
|
||||
change of the identifier itself qualifies. A subject replaced by *no* subject
|
||||
is not this case: the preview losing its subject entirely is governed by
|
||||
AC-TASKS-TASK-ACTIONS-MENU-004.5 when the task went away and by
|
||||
AC-TASKS-TASK-ACTIONS-MENU-001.6 for the trigger itself. The detail surface needs no equivalent rule
|
||||
because it remounts on navigation, which destroys any open menu; the criterion
|
||||
is stated for both surfaces so that a later change to that behavior does not
|
||||
silently create the gap.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-004.6:** Repeating an action that already
|
||||
succeeded, for example archiving a task another client has already archived
|
||||
(whether by confirming it, or by issuing it directly where
|
||||
AC-TASKS-TASK-ACTIONS-MENU-003.1a shows no confirmation), shall not corrupt
|
||||
client state: the system shall reconcile to the server's task state and
|
||||
surface the failure feedback for the rejected request. Where the server
|
||||
accepts the repeat instead of rejecting it, as a repeated move does under
|
||||
AC-TASKS-TASK-ACTIONS-MENU-004.3a and a repeated detach does under
|
||||
AC-TASKS-TASK-ACTIONS-MENU-004.3b, there is no rejection and the system shall
|
||||
surface no failure feedback.
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
status: draft
|
||||
system: tasks
|
||||
created: 2026-09-03
|
||||
owners:
|
||||
- Kandev
|
||||
---
|
||||
|
||||
# Task Actions Menu Action Outcomes Requirements
|
||||
|
||||
## Overview
|
||||
|
||||
The actions menu added to the task preview and task detail surfaces by
|
||||
[Task Actions Menu on Preview and Detail Surfaces](task-actions-menu.md) reaches
|
||||
task actions that already exist. This file owns what each confirmed action does
|
||||
to task state and to navigation, so that an action taken from one of these two
|
||||
surfaces has the same effect as the same action taken from a Kanban card, and
|
||||
leaves the user somewhere coherent rather than on a surface whose subject no
|
||||
longer exists. Terminology and entry content are defined in the parent
|
||||
requirement and are not restated here; in-flight, concurrent, and failing
|
||||
actions are owned by
|
||||
[Task Actions Menu In-Flight and Concurrency](task-actions-menu-concurrency.md).
|
||||
|
||||
## Requirements
|
||||
|
||||
### REQ-TASKS-TASK-ACTIONS-MENU-003: Action outcomes and post-action navigation
|
||||
|
||||
**Intent:** An action taken from one of these surfaces has the same effect on
|
||||
task state as the same action taken from a card, and leaves the user somewhere
|
||||
coherent rather than on a surface whose subject no longer exists.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-003.1:** When the user selects Archive or Delete
|
||||
from either surface and a confirmation is shown for that action, the system
|
||||
shall open the same confirmation the card actions menu opens, carrying the
|
||||
subject task's title and the same cascade choice, and shall make no state
|
||||
change until the user confirms. Whether a confirmation is shown at all is not
|
||||
decided by this requirement; see AC-TASKS-TASK-ACTIONS-MENU-003.1a.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-003.1a:** Whether Archive shows a confirmation is
|
||||
owned by [Archive confirmation](archive-confirmation.md), not by this
|
||||
requirement. When the archive-confirmation preference is disabled,
|
||||
`AC-TASKS-ARCHIVE-CONFIRMATION-001.2` archives the requested task alone,
|
||||
without a dialog and without cascading, from any task surface. These two
|
||||
surfaces are task surfaces, so the system shall take that same path here and
|
||||
shall not show a confirmation the card would not show: this is parity under
|
||||
AC-TASKS-TASK-ACTIONS-MENU-002.1, not an exception to it. On that path the
|
||||
archive request is issued immediately with no cascade, and no confirm gesture
|
||||
exists, so every criterion worded around confirming an archive
|
||||
(AC-TASKS-TASK-ACTIONS-MENU-003.3, AC-TASKS-TASK-ACTIONS-MENU-003.4,
|
||||
AC-TASKS-TASK-ACTIONS-MENU-004.3 and AC-TASKS-TASK-ACTIONS-MENU-004.6) shall
|
||||
be read as taking effect when the request is issued. Where the preference has
|
||||
no stored value the system shall confirm, which is the existing default and
|
||||
not a new one. Delete is unaffected:
|
||||
`AC-TASKS-ARCHIVE-CONFIRMATION-001.7` leaves delete confirmations unchanged,
|
||||
so Delete always confirms.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-003.2:** When the user cancels or dismisses a
|
||||
confirmation opened from either surface, the system shall leave the task
|
||||
unchanged, shall leave the surface open on the same subject task, and shall
|
||||
make no request.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-003.3:** When the user confirms Archive or
|
||||
Delete from the preview surface and the request succeeds, the system shall
|
||||
remove the task from the board and close the preview panel, and shall not
|
||||
navigate to the task detail route.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-003.4:** When the user confirms Archive from the
|
||||
detail surface and the request succeeds, the system shall use the same
|
||||
archive-and-switch outcome the existing task-scoped archive entry points use:
|
||||
the next eligible task opens, or the task overview opens when no eligible
|
||||
task remains.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-003.5:** When the user confirms Delete from the
|
||||
detail surface and the request succeeds, the system shall remove the task and
|
||||
apply the same switch-to-next-task outcome as
|
||||
AC-TASKS-TASK-ACTIONS-MENU-003.4.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-003.6:** When the user selects a Move to or Send
|
||||
to workflow target from either surface, the system shall move the subject
|
||||
task alone to that step, shall keep the surface open on that task, and shall
|
||||
not navigate.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-003.7:** When a move made from the detail
|
||||
surface succeeds, the system shall show the new step as current in the top
|
||||
bar's workflow stepper.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-003.8:** When the user selects Edit from either
|
||||
surface, the system shall open the existing task edit dialog initialized from
|
||||
the subject task, and shall not navigate or change the active or previewed
|
||||
task; saving shall use the existing task update contract and cancelling shall
|
||||
leave the task unchanged.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-003.9:** When the user selects a Link entry from
|
||||
either surface, the system shall open the same link dialog the card actions
|
||||
menu opens for that provider, scoped to the subject task.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-003.10:** When the user confirms Detach from
|
||||
parent from either surface, the system shall detach the subject task from its
|
||||
parent and shall leave the surface open on that task.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-003.11:** The system shall introduce no API
|
||||
endpoint, request payload, persisted field, permission, or feature flag for
|
||||
any action reached from these surfaces; every action shall use the contract
|
||||
it already uses from the card actions menu.
|
||||
@@ -0,0 +1,302 @@
|
||||
---
|
||||
status: draft
|
||||
system: tasks
|
||||
created: 2026-09-03
|
||||
owners:
|
||||
- Kandev
|
||||
---
|
||||
|
||||
# Task Actions Menu on Preview and Detail Surfaces Requirements
|
||||
|
||||
## Overview
|
||||
|
||||
Every task action Kandev offers (edit, move to step, send to workflow, link an
|
||||
external item, run a plugin task action, detach from parent, archive, delete) is
|
||||
reachable from a Kanban card, through its visible "more options" button or its
|
||||
context menu. Two other surfaces show a single task and offer none of them: the
|
||||
**desktop task preview panel**, whose header renders only Maximize and Close,
|
||||
and the **desktop task detail top bar**, which renders metrics, plugin actions,
|
||||
PR/MR/issue status, unarchive, and tools, but no task actions.
|
||||
|
||||
From either surface, archiving the task means returning to the board to find its
|
||||
card, right-clicking a task switcher sidebar row that only appears on hover, or
|
||||
knowing the command palette shortcut. This requirement makes the same actions
|
||||
reachable from both surfaces through the affordance the card already uses.
|
||||
|
||||
The task system owns this contract, not the UI system. Per
|
||||
[structure and ownership](../../guide/structure-and-ownership.md), user
|
||||
visibility does not make a capability UI-owned: the contract is *which task
|
||||
actions are reachable, from which task surface, and what each does to task state
|
||||
and navigation*, which has no meaning without task state. The UI system keeps
|
||||
the menu, dropdown, and popover primitives this composes.
|
||||
|
||||
## Terminology
|
||||
|
||||
- **Actions menu:** The menu of task actions this requirement places on a
|
||||
surface, opened from a visible trigger.
|
||||
- **Card actions menu:** The existing menu opened from a Kanban card's visible
|
||||
"more options" button. It is this capability's reference for content and
|
||||
ordering.
|
||||
- **Preview surface:** The desktop task preview panel rendered beside the
|
||||
Kanban board. It has no mobile equivalent.
|
||||
- **Detail surface:** The desktop task detail top bar rendered above an open
|
||||
task. It has a separate mobile top bar, which is out of scope.
|
||||
- **Subject task:** The single task an actions menu acts on. On the preview
|
||||
surface it is the previewed task; on the detail surface it is the open task.
|
||||
- **Control group:** A cluster of controls rendered together at one end of a
|
||||
surface's header or top bar. On the preview surface it is the single group
|
||||
holding Maximize and Close; on the detail surface it is the top bar's
|
||||
right-hand group, whose last member today is the tools cluster.
|
||||
- **Board row:** The task's entry in the board's task collections. Entries such
|
||||
as Edit, Detach, Move to, Send to workflow, and Link need fields that only
|
||||
the board row carries.
|
||||
|
||||
## Prior art
|
||||
|
||||
**Wiki leg (receipt).** Searched the `@henry`-pinned vault
|
||||
`OBSIDIAN_VAULT_PATH=/Users/henry/Documents/henry/wiki`, collection
|
||||
`QMD_WIKI_COLLECTION="wiki"`. No `qmd` MCP server and no `obsidian-wiki` CLI, so
|
||||
both the semantic and GraphRAG passes were skipped; the grep fallback then hit
|
||||
`Operation not permitted` on every content read while `ls -la` on the same paths
|
||||
succeeded. **This leg returned nothing useful.**
|
||||
|
||||
**Cross-product leg (receipt).** Intended query: `saas-kb` `search_fsm_docs`,
|
||||
`category: "ai_sdlc"`. Server and tool not connected to this session.
|
||||
**This leg returned nothing useful.**
|
||||
|
||||
**In-product prior art (read directly; the basis for the decisions below).**
|
||||
|
||||
- The card actions menu is already built by a pure, surface-independent builder
|
||||
emitting an entry list that a dropdown or a context-menu renderer draws.
|
||||
Bringing it to a new surface is wiring, not a new menu.
|
||||
- [Sidebar Task Editing](sidebar-task-edit.md) is the same shape of change,
|
||||
owned by this system, and set the precedent that a second surface reuses the
|
||||
first's dialog and update contract rather than inventing its own.
|
||||
- The task switcher sidebar already exposes task actions from a visible dots
|
||||
button, but only on hover, and only per sidebar row. Its archive flow is the
|
||||
same shared confirmation the PR banners and command palette use.
|
||||
- The command palette already offers Archive from the detail surface, but it is
|
||||
keyboard-driven and not a visible affordance, so it does not satisfy this.
|
||||
|
||||
**What this capability does differently:** it standardises on the *card's*
|
||||
entry set and ordering rather than the sidebar's divergent one, because two
|
||||
menus that already disagree should not become four.
|
||||
|
||||
## Requirements
|
||||
|
||||
### REQ-TASKS-TASK-ACTIONS-MENU-001: Actions menu trigger on the preview and detail surfaces
|
||||
|
||||
**Intent:** A user looking at a single task can reach that task's actions from
|
||||
the surface they are already on, without returning to the board, hovering to
|
||||
reveal a control, or knowing a keyboard shortcut.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-001.1:** When the preview surface has a subject
|
||||
task, the system shall render an actions-menu trigger in the preview header's
|
||||
control group, positioned before the Maximize control, with the accessible
|
||||
name `More options`.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-001.2:** When the detail surface has a subject
|
||||
task, the system shall render an actions-menu trigger in the top bar's right
|
||||
control group, positioned after every other control in that group, with the
|
||||
accessible name `More options`.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-001.3:** The system shall render each trigger at
|
||||
full opacity whenever its surface is rendered, without requiring pointer
|
||||
hover or keyboard focus to reveal it.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-001.4:** The system shall expose each trigger
|
||||
with `aria-haspopup="menu"` and an `aria-expanded` value that is `false` while
|
||||
its menu is closed and `true` while its menu is open.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-001.5:** The system shall give the preview
|
||||
trigger the stable test id `task-preview-actions-menu` and the detail trigger
|
||||
the stable test id `task-topbar-actions-menu`, so that a test can address one
|
||||
surface while the board's card triggers carry the same accessible name.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-001.6:** When the preview surface has no subject
|
||||
task, the system shall render no actions-menu trigger, and shall leave the
|
||||
Close control unchanged.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-001.7:** When the detail surface has no subject
|
||||
task identifier, the system shall render no actions-menu trigger.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-001.8:** When the user activates a trigger, the
|
||||
system shall open that surface's actions menu right-aligned to the trigger,
|
||||
and shall not navigate, shall not change the active or previewed task, shall
|
||||
not close the preview panel, and shall not activate any other control on the
|
||||
surface.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-001.9:** When an actions menu is open and the
|
||||
user presses Escape, the system shall close the menu and return keyboard
|
||||
focus to the trigger that opened it.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-001.10:** The system shall introduce no new
|
||||
localization key for either trigger, reusing the existing `More options`
|
||||
string, which is already present in every shipped catalog.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-001.11:** When an actions menu is open on the
|
||||
preview surface and the user presses Escape, the system shall close only the
|
||||
menu and shall leave the preview panel open on the same subject task; a
|
||||
second Escape, with no menu open, shall then close the preview panel as it
|
||||
does today.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-001.12:** When a dialog opened from an actions
|
||||
menu closes, whether confirmed, cancelled, or dismissed, and its surface is
|
||||
still mounted on the same subject task, the system shall return keyboard
|
||||
focus to the trigger that opened the menu.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-001.13:** The system shall place each trigger in
|
||||
the document tab order at its rendered position within its control group, so
|
||||
a keyboard user reaches it by tabbing through that group and opens its menu
|
||||
with Enter or Space.
|
||||
|
||||
### REQ-TASKS-TASK-ACTIONS-MENU-002: Menu content parity with the card actions menu
|
||||
|
||||
**Intent:** A user learns one task menu. The same task offers the same actions,
|
||||
in the same order, with the same labels, wherever the menu is opened.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-002.1:** When an actions menu is open on a
|
||||
subject task that is not archived, the system shall present the same entries,
|
||||
in the same order, with the same labels, the same submenu nesting, and the
|
||||
same enabled or disabled state as the card actions menu presents for that
|
||||
same task at that same moment, except where
|
||||
AC-TASKS-TASK-ACTIONS-MENU-002.4 through
|
||||
AC-TASKS-TASK-ACTIONS-MENU-002.9 state otherwise, except for plugin
|
||||
`edit`-group actions (AC-TASKS-TASK-ACTIONS-MENU-002.2a), and except for the
|
||||
in-flight disabled state, which is per surface under
|
||||
AC-TASKS-TASK-ACTIONS-MENU-004.1b and so may differ from the card's for the
|
||||
duration of a request.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-002.2:** The order named in
|
||||
AC-TASKS-TASK-ACTIONS-MENU-002.1 is: Edit, Priority, Move to, Send to
|
||||
workflow, plugin primary actions, Link, Archive, Detach from parent, a
|
||||
separator, Delete. The system shall omit Priority when the card actions menu
|
||||
omits it. It shall omit any of Move to, Send to workflow, Link, and Detach
|
||||
from parent whose availability condition in the card actions menu is unmet,
|
||||
and shall not reorder the entries that remain.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-002.2a:** The Edit entry on these two surfaces
|
||||
shall always be the flat Edit item, never the card's submenu form: the system
|
||||
shall not present plugin task-menu actions registered with group `edit` on
|
||||
either surface. Group `edit` is a card-only plugin contract and this
|
||||
requirement does not widen it; group `primary` is unaffected and appears per
|
||||
AC-TASKS-TASK-ACTIONS-MENU-002.2.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-002.3:** Within the Move to and Send to workflow
|
||||
submenus the system shall order steps by ascending `position`, breaking a tie
|
||||
by ascending step `id`. The new surfaces shall use the same order as the card.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-002.3a:** Within the Send to workflow submenu the
|
||||
system shall order workflows in the order the workflow collection holds them,
|
||||
which is the order the card actions menu uses, and shall omit workflows marked
|
||||
hidden.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-002.3b:** The system shall exclude from a Move to
|
||||
target list every step the user has hidden for the subject task's current
|
||||
workflow, matching the card, whose current-workflow targets are already
|
||||
hidden-filtered. Within Send to workflow the system shall not filter another
|
||||
workflow's steps by hidden state, also matching the card. This asymmetry is
|
||||
inherited deliberately; see `## Out of scope`.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-002.3c:** The system shall never present the
|
||||
display-only orphan sentinel step (`__kandev_orphan__`, labelled "Needs
|
||||
Reassignment") as a move target on either surface.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-002.4:** When the subject task is archived, the
|
||||
system shall omit Edit, Move to, Send to workflow, Link, Detach from parent,
|
||||
and Archive from the actions menu, and shall present exactly, in this order:
|
||||
the admitted plugin primary actions, then a separator, then Delete. The card
|
||||
has no archived branch to inherit from, so the order is stated in full here;
|
||||
it is the card's own order with the omitted entries removed.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-002.4c:** When no plugin primary action is
|
||||
admitted for an archived subject, the system shall present Delete alone, with
|
||||
no leading separator, so the menu never opens on a separator.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-002.4a:** AC-TASKS-TASK-ACTIONS-MENU-002.4 takes
|
||||
precedence over AC-TASKS-TASK-ACTIONS-MENU-002.5 whenever both apply. An
|
||||
archived subject is normally board-row-unresolvable too, since the board
|
||||
excludes archived tasks; in that overlap the archived rule governs, so the
|
||||
system shall present Delete and not Archive.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-002.4b:** "The admitted plugin primary actions"
|
||||
means the ordinary `visible(context)` result for the `primary` group, in
|
||||
plugin registration order, evaluated against the unchanged plugin task-menu
|
||||
context. The system shall not add an archived field to that
|
||||
context, and shall not filter plugin actions by archived state: the context
|
||||
carries no archived signal, and a plugin that should hide itself for an
|
||||
archived task owns that.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-002.5:** When the subject task's board row is
|
||||
not resolvable, the system shall omit Edit, Move to, Send to workflow, Link,
|
||||
and Detach from parent, and shall present Archive and Delete, which need only
|
||||
the task identifier. This criterion yields to
|
||||
AC-TASKS-TASK-ACTIONS-MENU-002.4 when the subject is also archived.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-002.6:** The entry set shall be live, not
|
||||
snapshotted when the menu opens. When the subject task's board row becomes
|
||||
resolvable, or stops being resolvable, the system shall update that surface's
|
||||
entries in place, whether its menu is currently open or closed, without
|
||||
requiring a page reload and without closing an open menu. When the subject
|
||||
task itself is removed rather than its row merely becoming unresolvable,
|
||||
AC-TASKS-TASK-ACTIONS-MENU-004.5 governs and the menu closes; when the subject
|
||||
is replaced by a different task, AC-TASKS-TASK-ACTIONS-MENU-004.5a governs.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-002.7:** When the board holds a multi-task
|
||||
selection that includes the subject task, the system shall still act on the
|
||||
subject task alone, shall present the single-task labels rather than any
|
||||
count-bearing label, and shall leave the selection unchanged. Entry
|
||||
membership shall not depend on the selection either: the system shall still
|
||||
present Detach from parent for a subject that has a parent, even while a board
|
||||
multi-selection includes it. This removes the selection as a reason to omit
|
||||
Detach; it does not override AC-TASKS-TASK-ACTIONS-MENU-002.4 or
|
||||
AC-TASKS-TASK-ACTIONS-MENU-002.5, which still omit Detach for an archived
|
||||
subject and for an unresolved board row. The card suppresses Detach under a
|
||||
multi-selection because its own menu can act on that selection; these surfaces
|
||||
never can, so that cause is absent here.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-002.8:** When a plugin registers, is enabled, or
|
||||
is disabled while a surface is mounted, the system shall reflect that change
|
||||
in that surface's plugin entries immediately, including in a menu that is
|
||||
already open, and without closing it. Plugin entries are not a separate tier:
|
||||
they are live on the same terms as every other entry under
|
||||
AC-TASKS-TASK-ACTIONS-MENU-002.6, which is what the card already does, since
|
||||
it subscribes to the plugin registry and rebuilds its entries each render.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-002.9:** The system shall present no entry on
|
||||
these two surfaces that the card actions menu does not present for a single,
|
||||
unselected task, and shall present no bulk-selection variant of the menu.
|
||||
- **AC-TASKS-TASK-ACTIONS-MENU-002.10:** The system shall leave the card actions
|
||||
menu, the card context menu, and the task switcher sidebar menu unchanged in
|
||||
entry membership, labels, top-level order, and behavior, with one named
|
||||
exception. Adopting `sortWorkflowStepsByPosition` inside the shared
|
||||
move-target derivation also applies its ascending-`id` tiebreak to the steps
|
||||
the card lists under Send to workflow, which are today sorted by `position`
|
||||
alone. That changes rendered order only where two steps of a non-current
|
||||
workflow share a `position` value, a case whose order is arbitrary today. No
|
||||
other card-facing change is permitted; the card's Move to submenu is
|
||||
unaffected, already sorting through that helper.
|
||||
|
||||
### Action outcomes and post-action navigation
|
||||
|
||||
`REQ-TASKS-TASK-ACTIONS-MENU-003` covers what each confirmed action does to task
|
||||
state and navigation. It lives in
|
||||
[Task Actions Menu Action Outcomes](task-actions-menu-outcomes.md).
|
||||
|
||||
### In-flight, concurrent, and failing actions
|
||||
|
||||
`REQ-TASKS-TASK-ACTIONS-MENU-004` covers in-flight, concurrent, and failing
|
||||
actions. It lives in
|
||||
[Task Actions Menu In-Flight and Concurrency](task-actions-menu-concurrency.md).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **The mobile task detail top bar.** Different chrome budget, and mobile
|
||||
already routes task actions through the task switcher sheet's per-row menus.
|
||||
A follow-up wanting parity there should start from that sheet's menu.
|
||||
- **The mobile preview surface.** There is none: on mobile the board navigates
|
||||
straight to the task detail route and never renders the preview panel, so
|
||||
there is no surface to add a trigger to.
|
||||
- **Changing the card actions menu, the card context menu, or the task
|
||||
switcher sidebar menu.** Their entry sets, ordering, and labels are frozen by
|
||||
this requirement, not revised by it.
|
||||
- **Reconciling the card menu's entry set with the task switcher sidebar's
|
||||
divergent one.** The sidebar offers Pin, Rename, Create subtask, Duplicate,
|
||||
Color, and Nest, and omits several the card has. These surfaces standardise on
|
||||
the card's set; the divergence stays.
|
||||
- **Bulk or multi-task actions from these surfaces.** Both surfaces address a
|
||||
single task; the board's multi-select toolbar remains the bulk path.
|
||||
- **Unarchive.** The detail top bar already renders a dedicated Unarchive
|
||||
control for an archived task, and this requirement does not duplicate it into
|
||||
the menu.
|
||||
- **A right-click context menu on either surface.** This requirement adds the
|
||||
visible trigger only; the card keeps its context menu.
|
||||
- **Correcting the card's hidden-step asymmetry in move targets.** The card
|
||||
filters user-hidden steps out of Move to but not out of Send to workflow.
|
||||
`AC-TASKS-TASK-ACTIONS-MENU-002.3b` reproduces that asymmetry rather than
|
||||
fixing it, because fixing it would change card behavior that
|
||||
`AC-TASKS-TASK-ACTIONS-MENU-002.10` freezes. A follow-up should change both
|
||||
surfaces together and update the card's move-target tests.
|
||||
- **Presenting plugin `edit`-group actions on these surfaces.** That group is
|
||||
documented as card-only, and `AC-TASKS-TASK-ACTIONS-MENU-002.2a` keeps it that
|
||||
way. Widening it changes a published plugin contract and every registered
|
||||
plugin's expectations, so it is a plugin-system decision, not this one's.
|
||||
- **Any new backend endpoint, persistence model, permission, feature flag, or
|
||||
localization key.**
|
||||
@@ -0,0 +1,362 @@
|
||||
---
|
||||
status: draft
|
||||
system: tasks
|
||||
requirements:
|
||||
- REQ-TASKS-TASK-ACTIONS-MENU-001
|
||||
- REQ-TASKS-TASK-ACTIONS-MENU-002
|
||||
- REQ-TASKS-TASK-ACTIONS-MENU-003
|
||||
- REQ-TASKS-TASK-ACTIONS-MENU-004
|
||||
---
|
||||
|
||||
# Task Actions Menu on Preview and Detail Surfaces System Design
|
||||
|
||||
## Purpose and boundaries
|
||||
|
||||
This design places the existing task actions menu on the desktop preview panel
|
||||
and the desktop task detail top bar. It owns how a surface obtains the subject
|
||||
task, how the shared entry list is built for that surface, and what each
|
||||
confirmed action does to task state and navigation.
|
||||
|
||||
Contracts this design uses but does not own:
|
||||
|
||||
- The task update, archive, delete, move, detach, and link request contracts,
|
||||
owned by their existing task-system requirements.
|
||||
- The archive confirmation contract, owned by
|
||||
[Archive confirmation](../requirements/archive-confirmation.md).
|
||||
- The dropdown, context-menu, popover, and dialog primitives, owned by the UI
|
||||
system.
|
||||
- The plugin task-menu action contract, owned by the plugin system.
|
||||
|
||||
## Requirement mapping
|
||||
|
||||
| Requirement | Design section |
|
||||
| --- | --- |
|
||||
| `REQ-TASKS-TASK-ACTIONS-MENU-001` | [Components and responsibilities](#components-and-responsibilities) |
|
||||
| `REQ-TASKS-TASK-ACTIONS-MENU-002` | [Data and contracts](#data-and-contracts) |
|
||||
| `REQ-TASKS-TASK-ACTIONS-MENU-003` ([own file](../requirements/task-actions-menu-outcomes.md)) | [Control flow](#control-flow) |
|
||||
| `REQ-TASKS-TASK-ACTIONS-MENU-004` ([own file](../requirements/task-actions-menu-concurrency.md)) | [Failure and recovery](#failure-and-recovery) |
|
||||
|
||||
## Components and responsibilities
|
||||
|
||||
**Shared menu state (new).** The Kanban card today builds its entry list and
|
||||
its dialog state inside a card-scoped hook that also carries board concerns:
|
||||
board-supplied edit/delete/archive/move callbacks, the multi-selection, and the
|
||||
card's own step list. Parity in
|
||||
`AC-TASKS-TASK-ACTIONS-MENU-002.1` cannot be guaranteed by a second,
|
||||
independent implementation, so the surface-independent part of that hook is
|
||||
extracted into a shared hook that takes a subject task and a presentation, and
|
||||
returns the entry list plus the dialog state. The card hook keeps the
|
||||
board-only concerns (multi-selection move targets, board callbacks) and
|
||||
composes the shared one. The extracted hook carries no pending state: each
|
||||
caller supplies its own in-flight flags, which is what keeps the card's existing
|
||||
board-scoped flags working untouched and what makes the surface-local rule below
|
||||
fall out of the structure rather than needing enforcement.
|
||||
|
||||
This extraction sits on the board's hot render path, one instance per visible
|
||||
card. It must not change the card's rendered entries, their identity keys, or
|
||||
the number of store subscriptions per card. That is the constraint the
|
||||
implementation is held to, and the existing card menu tests are the evidence.
|
||||
|
||||
**Preview trigger (new).** Renders inside the preview header's control group,
|
||||
before Maximize. The preview already receives the full task object, so its
|
||||
subject task needs no store lookup.
|
||||
|
||||
**Detail trigger (new).** Renders inside the top bar's right control group,
|
||||
after the tools cluster. The top bar receives only identifiers, so it resolves
|
||||
its board row from the store (see below) and degrades per
|
||||
`AC-TASKS-TASK-ACTIONS-MENU-002.5` when the row is absent.
|
||||
|
||||
**Dialog host (new, one per surface).** Mounts the confirmation and link
|
||||
dialogs the entries open. Each surface hosts its own so a dialog outlives the
|
||||
menu that opened it and is not nested inside a portal that unmounts on select.
|
||||
|
||||
## Data and contracts
|
||||
|
||||
**Subject task resolution.**
|
||||
|
||||
- Preview surface: the task object the panel already receives. When it is
|
||||
`null` the trigger is not rendered.
|
||||
- Detail surface: the task identifier from the route, plus a board-row lookup
|
||||
against the workflow snapshots with the flat task list as fallback (the same
|
||||
lookup the sidebar and the shared archive-confirmation hook already perform
|
||||
from inside the detail view, so the snapshots are populated there).
|
||||
|
||||
**Entry availability tiers.** Two tiers, which is what makes
|
||||
`AC-TASKS-TASK-ACTIONS-MENU-002.5` implementable:
|
||||
|
||||
- *Identifier-only*: Archive, Delete. Available whenever a task identifier is
|
||||
known.
|
||||
- *Board-row*: Edit, Priority, Move to, Send to workflow, Link, Detach from
|
||||
parent. Each needs fields carried by the board row (priority, workflow
|
||||
membership, step, repositories, or parent task id), and is omitted while
|
||||
that row is unresolved.
|
||||
|
||||
The tiers describe availability, not precedence. An archived subject is filtered
|
||||
by the archived rule first, which is why Archive -- an identifier-only entry --
|
||||
is still absent for it.
|
||||
|
||||
The tiers are evaluated live. The entry list is derived from store state on each
|
||||
render rather than captured when the menu opens, so a board row that resolves
|
||||
while the menu is open promotes the board-row entries in place
|
||||
(`AC-TASKS-TASK-ACTIONS-MENU-002.6`). Nothing needs to close and reopen the menu
|
||||
to converge. Two things do close it: the subject task disappearing
|
||||
(`AC-TASKS-TASK-ACTIONS-MENU-004.5`) and the subject being replaced by a
|
||||
different task (`AC-TASKS-TASK-ACTIONS-MENU-004.5a`). Losing the board row is
|
||||
neither, and demotes entries in place.
|
||||
|
||||
**There is no memoized-at-open tier.** Plugin entries are live on exactly the
|
||||
same terms as every other entry (`AC-TASKS-TASK-ACTIONS-MENU-002.8`), including
|
||||
inside a menu that is currently open. This matches the card rather than relaxing
|
||||
against it: `usePluginRegistry()` is a `useSyncExternalStore` subscription that
|
||||
re-renders the card on any registry mutation, and the card's entry list is
|
||||
rebuilt unmemoized on each render, so an action a plugin registers after the
|
||||
card mounted already appears and one whose plugin was just disabled already
|
||||
stops appearing. A single liveness rule for every tier is also the cheaper
|
||||
contract to hold: a plugin-only exception would need its own snapshot point,
|
||||
its own invalidation, and its own tests, to buy nothing a user can see.
|
||||
|
||||
**Ordering.** The entry order is fixed by the shared builder, so parity is
|
||||
structural rather than asserted per surface.
|
||||
|
||||
For a normal resolved row, the top-level order is Edit, Priority, Move to, Send
|
||||
to workflow, plugin primary actions, Link, Archive, Detach from parent, a
|
||||
separator, and Delete. The Priority submenu uses the row's current priority and
|
||||
the same update hook as the card. If the card builder omits an entry because its
|
||||
callback is unavailable, the new surface omits that entry too.
|
||||
|
||||
Step ordering is ascending `position`, tie-broken by ascending step `id`. That
|
||||
rule is already implemented, once, by `sortWorkflowStepsByPosition`
|
||||
(`lib/kanban/auto-hide-empty-columns.ts`), and the card's Move to submenu
|
||||
already renders through it: the board derives its move targets with that helper
|
||||
and passes them down, and the shared move-target derivation then uses that
|
||||
supplied list verbatim for the current workflow. So the tiebreak is not new
|
||||
behavior being introduced here, and the card's Move to submenu does not change.
|
||||
|
||||
The one place the shared derivation is weaker is *other* workflows: it sorts
|
||||
their steps with `position` alone. Adopting `sortWorkflowStepsByPosition` there
|
||||
gives the new surfaces the same order the card's Move to already has, and
|
||||
changes the card's Send to workflow submenu only where two steps of a
|
||||
non-current workflow share a `position` -- an order that is arbitrary today.
|
||||
`AC-TASKS-TASK-ACTIONS-MENU-002.10` names that as its one permitted exception.
|
||||
|
||||
Two exclusions travel with the target list. User-hidden steps are filtered out
|
||||
for the current workflow and not for other workflows, which is what the card
|
||||
does today and what `AC-TASKS-TASK-ACTIONS-MENU-002.3b` reproduces rather than
|
||||
corrects. The orphan sentinel `__kandev_orphan__` ("Needs Reassignment") is a
|
||||
display-only node synthesized per view and never reaches a snapshot, so it is
|
||||
absent from these surfaces' targets by construction; the criterion states it so
|
||||
that a future view-specific target list cannot leak it in.
|
||||
|
||||
Workflow ordering is the workflow collection's own order, minus hidden
|
||||
workflows, unchanged.
|
||||
|
||||
**Plugin action groups.** Only the `primary` group crosses to these surfaces.
|
||||
Group `edit` actions, which the card nests under its Edit entry, are documented
|
||||
as card-only (`docs/plans/plugins/PLUGIN-API.md`), and this design does not
|
||||
widen that contract from the task system's side, so the Edit entry here is
|
||||
always the flat item and never the card's submenu form
|
||||
(`AC-TASKS-TASK-ACTIONS-MENU-002.2a`). Concretely, `buildEditMenuEntry` returns
|
||||
a flat item when no `edit`-group action is visible and a submenu when one is;
|
||||
these surfaces take the flat form unconditionally rather than calling that
|
||||
builder with the plugin context.
|
||||
|
||||
**Archived subject.** The archived case reaches only the detail surface: the
|
||||
board excludes archived tasks, so the preview panel cannot hold one. That same
|
||||
exclusion is why an archived subject is normally board-row-unresolvable too, so
|
||||
the archived rule and the unresolved-row rule overlap by default rather than by
|
||||
accident. `AC-TASKS-TASK-ACTIONS-MENU-002.4a` resolves the overlap in one
|
||||
direction: archived wins, and Archive is not offered on a task that is already
|
||||
archived.
|
||||
|
||||
The archived entry set is therefore the admitted plugin primary actions, a
|
||||
separator, and Delete -- stated in full because the card menu has no archived
|
||||
branch for it to inherit from (`buildKanbanCardMenuEntries` takes no archived
|
||||
input). "Admitted" is the ordinary `visible(context)` result in registration
|
||||
order; the plugin task-menu context carries no archived signal and this design
|
||||
does not add one, so archived-awareness stays the plugin's own concern.
|
||||
|
||||
The detail surface already receives `isArchived`, so no new input is needed to
|
||||
evaluate this branch.
|
||||
|
||||
**Accessible names and test ids.** Both triggers use the existing
|
||||
`More options` string. Because the preview renders beside a board whose cards
|
||||
carry the same accessible name, each trigger also carries a surface-specific
|
||||
test id (`task-preview-actions-menu`, `task-topbar-actions-menu`) so a test can
|
||||
address one surface unambiguously.
|
||||
|
||||
**Localization.** No new key. Every entry label and both trigger names already
|
||||
exist in `en`, `pt-pt`, `zh-cn`, `zh-tw`, `zh-hk`, and `pseudo`.
|
||||
|
||||
## Control flow
|
||||
|
||||
1. The user activates a surface's trigger. The surface opens its menu. No
|
||||
navigation, no active-task change, no preview close: the trigger stops
|
||||
pointer and click propagation so the surrounding header controls and the
|
||||
board's card click dispatch never see the event.
|
||||
2. Selecting an entry closes the menu and either opens a dialog hosted by that
|
||||
surface or dispatches a move.
|
||||
3. Archive and Delete open the shared confirmation; nothing is requested until
|
||||
the user confirms.
|
||||
3a. Except when the archive-confirmation preference is off. That preference is
|
||||
read *inside* the shared confirmation component, not by its callers: when it
|
||||
is disabled the component renders no dialog, immediately invokes its confirm
|
||||
callback with no cascade, and closes. A surface that mounts the same shared
|
||||
confirmation therefore inherits the preference-honouring path for free, which
|
||||
is why `AC-TASKS-TASK-ACTIONS-MENU-003.1a` is parity with the card rather
|
||||
than an exception to it: the card mounts that same component. Neither new
|
||||
surface reads the preference itself, and this design adds no second bypass.
|
||||
4. On confirmed Archive or Delete:
|
||||
- Preview surface: the removal prunes the task from the board collections.
|
||||
The preview's existing missing-subject guard then closes the panel. No
|
||||
new navigation code path is introduced.
|
||||
- Detail surface: the removal routes through the existing
|
||||
archive-and-switch and remove-from-board flows, which pick the next
|
||||
eligible task or fall back to the task overview.
|
||||
4a. Escape while a preview menu is open must close only the menu
|
||||
(`AC-TASKS-TASK-ACTIONS-MENU-001.11`). The preview panel's own Escape
|
||||
handler is currently a bare `window` keydown listener that does not consult
|
||||
`defaultPrevented`, so without a change it would close the panel on the same
|
||||
keypress that closes the menu. The panel's handler is the one that changes:
|
||||
it must ignore an Escape the open menu has already handled. The menu
|
||||
primitive's own Escape behavior is not modified.
|
||||
4b. The preview panel re-renders with a different subject task rather than
|
||||
unmounting, so an open menu can outlive the task it was opened on, and its
|
||||
terminal entries include Delete. Both triggers therefore use the default
|
||||
modal dropdown behavior the card already uses, so a single outside click
|
||||
dismisses the menu rather than also landing on another card; and the preview
|
||||
additionally closes its menu when the subject's identity changes
|
||||
(`AC-TASKS-TASK-ACTIONS-MENU-004.5a`). Modality alone is not relied on: it is
|
||||
a presentation default a later `modal={false}` could remove, whereas the
|
||||
identity check is behavioral. That check compares the subject's task *id*
|
||||
across renders, never the identity of the task object: `useSelectedTask`
|
||||
memoizes on the board's task collections and returns a freshly constructed
|
||||
object, so its result gets a new reference whenever any task on the board
|
||||
changes while the selected id stays put. Keying the close on the object would
|
||||
therefore close the menu on unrelated board churn and contradict
|
||||
`AC-TASKS-TASK-ACTIONS-MENU-002.6`, which requires an open menu to survive an
|
||||
in-place entry update. The detail route remounts on navigation and needs
|
||||
neither.
|
||||
5. On a move: the subject task alone is moved. Both surfaces stay on the task.
|
||||
The detail top bar's stepper re-reads the current step from state.
|
||||
5a. If the board row is lost while a Move to or Send to workflow submenu is
|
||||
open, the live demotion of `AC-TASKS-TASK-ACTIONS-MENU-002.6` removes the
|
||||
submenu's parent entry, so the submenu closes with it and the top-level menu
|
||||
stays open on the identifier-only entries. A move already in flight is not
|
||||
cancelled: it was dispatched against the server, which remains the arbiter,
|
||||
and it lands or fails on its own terms
|
||||
(`AC-TASKS-TASK-ACTIONS-MENU-004.1c`). This is a rare flash rather than a
|
||||
state machine -- the row is lost by a store update, and the alternative,
|
||||
pinning an open submenu to a target list the store no longer backs, would
|
||||
mean offering move targets that may no longer exist.
|
||||
6. Edit opens the existing edit dialog seeded from the subject task; save uses
|
||||
the existing task update contract.
|
||||
|
||||
## Failure and recovery
|
||||
|
||||
**In-flight state is surface-local, and that is a decision, not an oversight.**
|
||||
Each surface owns the pending flag for the requests it starts. There is no
|
||||
task-keyed pending registry, and this design does not add one
|
||||
(`AC-TASKS-TASK-ACTIONS-MENU-004.1b`).
|
||||
|
||||
The alternative -- lifting archive/delete/detach pending state into shared,
|
||||
task-keyed store state so every surface showing the task disables together --
|
||||
was rejected. It buys strict parity during a window that is hard to even
|
||||
observe (`AC-TASKS-TASK-ACTIONS-MENU-004.2` closes the menu on a terminal
|
||||
activation, and archive/delete/detach are terminal, so the
|
||||
disabled state is only reachable by reopening mid-flight), and it pays for that
|
||||
with a new piece of client state architecture on the board's hot render path.
|
||||
It would also be a divergence dressed as parity: the card has no such registry
|
||||
either. `isDeleting` and `isArchiving` arrive as props from board-level state
|
||||
(`deletingTaskId === task.id` in `virtualized-column-task-list.tsx` and
|
||||
`swimlane-graph2-content.tsx`), and `isDetaching` comes from `useDetachTask`,
|
||||
whose `detachingTaskId` is a `useState` inside the hook and therefore local to
|
||||
each caller. Two cards for different tasks already do not share it, and nothing
|
||||
outside the board can read it.
|
||||
|
||||
The consequence is explicit and testable: a card mid-archive and a preview panel
|
||||
open on that same task will show different enabled states until the request
|
||||
settles. `AC-TASKS-TASK-ACTIONS-MENU-002.1` names this as its one in-flight
|
||||
exception rather than leaving the parity rule to over-claim.
|
||||
|
||||
- In-flight archive, delete, and detach requests mark that surface's entries
|
||||
disabled, and the menu is already closed by the selection, so a second
|
||||
request cannot be started from the same menu. The disabled state is therefore
|
||||
observable by reopening the menu mid-flight, not by watching the menu that
|
||||
dispatched the request.
|
||||
- Move to, Send to workflow, and Link are outside that rule by decision, not by
|
||||
omission (`AC-TASKS-TASK-ACTIONS-MENU-004.1a`). Link starts no task-state
|
||||
request on select -- it opens a dialog, which owns its own submit state. Move
|
||||
carries no menu-level pending state on the card either, so adding one here
|
||||
would be a new divergence rather than parity. Two moves issued in quick
|
||||
succession are two independent requests and the server arbitrates, which is
|
||||
the same rule the detail surface's own workflow stepper already applies with
|
||||
its last-request-wins guard.
|
||||
- Two surfaces acting on one task issue independent requests. The server is the
|
||||
arbiter: the losing request fails and raises the existing per-action failure
|
||||
feedback. No new error surface, no client-side lock across surfaces.
|
||||
- Detach is the exception, because it is idempotent rather than one-shot
|
||||
(`AC-TASKS-TASK-ACTIONS-MENU-004.3b`). Two existing layers already guarantee
|
||||
that, and this design leans on both rather than adding a third: the client
|
||||
detach request layer keys in-flight requests by task id and hands a second
|
||||
caller the existing promise instead of issuing another request, and the detach
|
||||
endpoint treats a detach of an already-parentless task as success with no row
|
||||
update. A second detach of one task therefore produces no second request and
|
||||
no failure feedback. This is a REQUEST-level coalesce and does not contradict
|
||||
`AC-TASKS-TASK-ACTIONS-MENU-004.1b`, which governs the per-surface pending
|
||||
flag behind the disabled state, not the request layer.
|
||||
- When the losing request fails *because* the subject was archived or deleted by
|
||||
another actor, the subject is gone, so the surface's missing-task behavior
|
||||
wins over the keep-the-surface-open rule
|
||||
(`AC-TASKS-TASK-ACTIONS-MENU-004.4a`). The failure feedback is raised at
|
||||
application level, so it still reaches the user after the preview has closed
|
||||
or the detail route has switched away.
|
||||
- **That cause is detected from store state, not from the failed response, and
|
||||
that is the authoritative mechanism rather than an incidental race.** The
|
||||
surface never inspects the rejected request to decide whether the subject
|
||||
survived; it reacts to the subject leaving the store, which is the same signal
|
||||
`AC-TASKS-TASK-ACTIONS-MENU-004.5` already runs on. The response could not
|
||||
carry that decision anyway without a backend change this capability does not
|
||||
make: a delete of a removed task resolves through the handler's not-found
|
||||
branch, but the already-archived sentinel matches none of the handler's typed
|
||||
branches and is answered as an undifferentiated server error, so the archive
|
||||
path has no distinguishable losing-race status to read. Ordering between the
|
||||
rejection and the store update is therefore not load-bearing. If the rejection
|
||||
lands first, the surface stays open, shows the failure feedback, and closes
|
||||
when the store update arrives; if the store update lands first, the surface is
|
||||
already gone and the application-level feedback still reaches the user. If the
|
||||
store update never arrives, the surface simply stays open under
|
||||
`AC-TASKS-TASK-ACTIONS-MENU-004.4`, which is the safe default.
|
||||
- A failed request leaves the task at its last confirmed state and leaves the
|
||||
surface open on it. The archive-and-switch flow already restores the previous
|
||||
active task and route when its archive request rejects after an optimistic
|
||||
switch.
|
||||
- A task removed by another client is handled by each surface's existing
|
||||
missing-subject behavior. The menu is closed by then, because the removal is
|
||||
observed through state rather than through a menu interaction.
|
||||
|
||||
## Persistence
|
||||
|
||||
None. This capability adds no storage, no migration, and no restart behavior.
|
||||
All state is client-side and derived from the existing task store.
|
||||
|
||||
## Security
|
||||
|
||||
None added. Every action reuses its existing request, so backend authorization
|
||||
is unchanged and no surface bypasses it.
|
||||
|
||||
## Observability
|
||||
|
||||
None added. Failures surface through the existing per-action feedback, and
|
||||
failed archive and delete requests keep their existing console diagnostics.
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit: shared builder parity for a not-archived task, an archived task, and an
|
||||
unresolved board row; the step ordering tiebreak.
|
||||
- Component: each trigger renders, opens, closes on Escape with focus returned,
|
||||
and does not activate its neighbouring controls.
|
||||
- End-to-end: the two surfaces are user-visible, so each gets a scenario that
|
||||
opens the menu by its surface test id and confirms Archive, asserting the
|
||||
post-action outcome named in `REQ-TASKS-TASK-ACTIONS-MENU-003`. The existing
|
||||
Kanban card-menu scenarios must continue to pass unchanged, as the evidence
|
||||
for `AC-TASKS-TASK-ACTIONS-MENU-002.10`.
|
||||
Reference in New Issue
Block a user