feat(FN-5475): add allow-resurrection control to task delete flow
Implements the allow-resurrection toggle for task deletion, letting users prevent deleted tasks from being automatically restored. Changes span the `ConfirmDialog` component, `TaskDetailModal`, and the `useConfirm` hook, with comprehensive test coverage across the dashboard API and UI layers. Fusion-Task-Id: FN-5475 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai> Fusion-Task-Id: FN-5475
This commit is contained in:
@@ -1185,3 +1185,46 @@ describe("task review data api wrappers", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteTask", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("serializes allowResurrection with other delete options in query params", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_DETAIL));
|
||||
|
||||
await deleteTask("FN-001", "proj-1", {
|
||||
allowResurrection: true,
|
||||
removeDependencyReferences: true,
|
||||
removeLineageReferences: true,
|
||||
githubIssueAction: "close",
|
||||
});
|
||||
|
||||
const [url, init] = vi.mocked(globalThis.fetch).mock.calls[0] as [string, RequestInit];
|
||||
const parsed = new URL(url, "http://localhost");
|
||||
expect(parsed.pathname).toBe("/api/tasks/FN-001");
|
||||
expect(parsed.searchParams.get("projectId")).toBe("proj-1");
|
||||
expect(parsed.searchParams.get("removeDependencyReferences")).toBe("true");
|
||||
expect(parsed.searchParams.get("removeLineageReferences")).toBe("true");
|
||||
expect(parsed.searchParams.get("githubIssueAction")).toBe("close");
|
||||
expect(parsed.searchParams.get("allowResurrection")).toBe("true");
|
||||
expect(init.method).toBe("DELETE");
|
||||
});
|
||||
|
||||
it("keeps delete request shape unchanged when allowResurrection is omitted", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_DETAIL));
|
||||
|
||||
await deleteTask("FN-001", "proj-1", { removeDependencyReferences: true });
|
||||
|
||||
const [url, init] = vi.mocked(globalThis.fetch).mock.calls[0] as [string, RequestInit];
|
||||
const parsed = new URL(url, "http://localhost");
|
||||
expect(parsed.pathname).toBe("/api/tasks/FN-001");
|
||||
expect(parsed.searchParams.get("projectId")).toBe("proj-1");
|
||||
expect(parsed.searchParams.get("removeDependencyReferences")).toBe("true");
|
||||
expect(parsed.searchParams.get("allowResurrection")).toBeNull();
|
||||
expect(init).toMatchObject({ method: "DELETE", headers: { "Content-Type": "application/json" } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -111,6 +111,7 @@ export interface DeleteTaskOptions {
|
||||
removeDependencyReferences?: boolean;
|
||||
removeLineageReferences?: boolean;
|
||||
githubIssueAction?: GithubIssueAction;
|
||||
allowResurrection?: boolean;
|
||||
}
|
||||
|
||||
export interface ArchiveTaskOptions {
|
||||
@@ -532,6 +533,10 @@ export function deleteTask(id: string, projectId?: string, options?: DeleteTaskO
|
||||
if (options?.githubIssueAction) {
|
||||
search.set("githubIssueAction", options.githubIssueAction);
|
||||
}
|
||||
// FN-5233 route reads delete modifiers from query params, including allowResurrection.
|
||||
if (options?.allowResurrection) {
|
||||
search.set("allowResurrection", "true");
|
||||
}
|
||||
|
||||
const suffix = search.size > 0 ? `?${search.toString()}` : "";
|
||||
return api<Task>(withProjectId(`/tasks/${id}${suffix}`, projectId), { method: "DELETE" });
|
||||
|
||||
@@ -58,6 +58,7 @@ interface AppModalsProps {
|
||||
removeDependencyReferences?: boolean;
|
||||
removeLineageReferences?: boolean;
|
||||
githubIssueAction?: GithubIssueAction;
|
||||
allowResurrection?: boolean;
|
||||
}) => Promise<Task>;
|
||||
mergeTask: (taskId: string) => Promise<MergeResult>;
|
||||
archiveTask: (taskId: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
|
||||
|
||||
@@ -10,6 +10,17 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.confirm-dialog__checkbox {
|
||||
display: grid;
|
||||
gap: var(--space-2xs);
|
||||
margin: 0 var(--space-xl) var(--space-lg);
|
||||
}
|
||||
|
||||
.confirm-dialog__checkbox-description {
|
||||
color: var(--text-muted);
|
||||
margin-inline-start: var(--space-lg);
|
||||
}
|
||||
|
||||
.confirm-dialog__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -8,9 +8,23 @@ export interface ConfirmDialogProps {
|
||||
onConfirm: () => void;
|
||||
onTertiary?: () => void;
|
||||
onCancel: () => void;
|
||||
checkboxLabel?: string;
|
||||
checkboxDescription?: string;
|
||||
checkboxChecked?: boolean;
|
||||
onCheckboxChange?: (next: boolean) => void;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({ isOpen, options, onConfirm, onTertiary, onCancel }: ConfirmDialogProps) {
|
||||
export function ConfirmDialog({
|
||||
isOpen,
|
||||
options,
|
||||
onConfirm,
|
||||
onTertiary,
|
||||
onCancel,
|
||||
checkboxLabel,
|
||||
checkboxDescription,
|
||||
checkboxChecked = false,
|
||||
onCheckboxChange,
|
||||
}: ConfirmDialogProps) {
|
||||
const cancelButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -53,6 +67,18 @@ export function ConfirmDialog({ isOpen, options, onConfirm, onTertiary, onCancel
|
||||
|
||||
<div className="confirm-dialog__body">{options.message}</div>
|
||||
|
||||
{checkboxLabel ? (
|
||||
<label className="checkbox-label confirm-dialog__checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checkboxChecked}
|
||||
onChange={(event) => onCheckboxChange?.(event.target.checked)}
|
||||
/>
|
||||
<span>{checkboxLabel}</span>
|
||||
{checkboxDescription ? <small className="confirm-dialog__checkbox-description">{checkboxDescription}</small> : null}
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
<div className="modal-actions confirm-dialog__actions">
|
||||
<button ref={cancelButtonRef} className="btn" onClick={onCancel}>
|
||||
{options.cancelLabel ?? "Cancel"}
|
||||
|
||||
@@ -288,6 +288,7 @@ export interface TaskDetailModalProps {
|
||||
removeDependencyReferences?: boolean;
|
||||
removeLineageReferences?: boolean;
|
||||
githubIssueAction?: GithubIssueAction;
|
||||
allowResurrection?: boolean;
|
||||
}) => Promise<Task>;
|
||||
onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
|
||||
onMergeTask: (id: string) => Promise<MergeResult>;
|
||||
@@ -1347,7 +1348,7 @@ export function TaskDetailContent({
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { nodes } = useNodes();
|
||||
const { confirm, confirmWithChoice } = useConfirm();
|
||||
const { confirm, confirmWithChoice, confirmWithCheckbox } = useConfirm();
|
||||
|
||||
const handleUnlinkGithubIssue = useCallback(async () => {
|
||||
if (!canEdit || !githubTrackedIssue || isSavingGithubTracking) return;
|
||||
@@ -1438,6 +1439,8 @@ export function TaskDetailContent({
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
let allowResurrection = false;
|
||||
|
||||
if (task.column === "done" && onArchiveTask) {
|
||||
const deleteChoice = await confirmWithChoice({
|
||||
title: "Delete Task",
|
||||
@@ -1484,12 +1487,18 @@ export function TaskDetailContent({
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const shouldDelete = await confirm({
|
||||
const { choice, checkboxValue } = await confirmWithCheckbox({
|
||||
title: "Delete Task",
|
||||
message: `Delete ${task.id}?`,
|
||||
danger: true,
|
||||
checkbox: {
|
||||
label: "Allow re-creation later (operator unlock)",
|
||||
description: "Lets agents recreate this task ID without --force-resurrect. Leave unchecked to keep this task tombstoned.",
|
||||
defaultChecked: false,
|
||||
},
|
||||
});
|
||||
if (!shouldDelete) return;
|
||||
if (choice !== "primary") return;
|
||||
allowResurrection = checkboxValue === true;
|
||||
}
|
||||
|
||||
const trackedIssue = task.githubTracking?.enabled === true ? task.githubTracking.issue : undefined;
|
||||
@@ -1519,9 +1528,9 @@ export function TaskDetailContent({
|
||||
|
||||
try {
|
||||
if (githubIssueAction) {
|
||||
await onDeleteTask(task.id, { githubIssueAction });
|
||||
await onDeleteTask(task.id, { githubIssueAction, allowResurrection });
|
||||
} else {
|
||||
await onDeleteTask(task.id);
|
||||
await onDeleteTask(task.id, { allowResurrection });
|
||||
}
|
||||
requestClose();
|
||||
const issueSuffix = trackedIssue?.owner && trackedIssue.repo && trackedIssue.number && githubIssueAction
|
||||
@@ -1548,6 +1557,7 @@ export function TaskDetailContent({
|
||||
removeDependencyReferences: true,
|
||||
removeLineageReferences: true,
|
||||
githubIssueAction,
|
||||
allowResurrection,
|
||||
});
|
||||
requestClose();
|
||||
addToast(`Deleted ${task.id} after removing dependency references`, "info");
|
||||
@@ -1574,6 +1584,7 @@ export function TaskDetailContent({
|
||||
removeDependencyReferences: true,
|
||||
removeLineageReferences: true,
|
||||
githubIssueAction,
|
||||
allowResurrection,
|
||||
});
|
||||
requestClose();
|
||||
addToast(`Deleted ${task.id} after unlinking lineage references`, "info");
|
||||
@@ -1606,6 +1617,7 @@ export function TaskDetailContent({
|
||||
removeDependencyReferences: true,
|
||||
removeLineageReferences: true,
|
||||
githubIssueAction,
|
||||
allowResurrection,
|
||||
});
|
||||
requestClose();
|
||||
addToast(`Deleted ${task.id} after unlinking lineage references`, "info");
|
||||
@@ -1613,7 +1625,7 @@ export function TaskDetailContent({
|
||||
addToast(getErrorMessage(retryErr), "error");
|
||||
}
|
||||
}
|
||||
}, [task.column, task.githubTracking?.enabled, task.githubTracking?.issue, task.id, onDeleteTask, onArchiveTask, requestClose, addToast, confirm, confirmWithChoice]);
|
||||
}, [task.column, task.githubTracking?.enabled, task.githubTracking?.issue, task.id, onDeleteTask, onArchiveTask, requestClose, addToast, confirm, confirmWithChoice, confirmWithCheckbox]);
|
||||
|
||||
const handleMerge = useCallback(async () => {
|
||||
const shouldMerge = await confirm({
|
||||
|
||||
@@ -90,16 +90,10 @@ describe("AgentErrorDetailsModal", () => {
|
||||
expect(errorRegion?.compareDocumentPosition(actions as Node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
|
||||
const allCss = await loadAllAppCss();
|
||||
const mobileBlockStart = allCss.indexOf("@media[^{]*(max-width: 768px)[^{]*{\n .agent-error-modal {");
|
||||
expect(mobileBlockStart).toBeGreaterThanOrEqual(0);
|
||||
const mobileBlockEnd = allCss.indexOf("}\n", mobileBlockStart + 1);
|
||||
const mobileBlock = allCss.slice(mobileBlockStart, mobileBlockEnd > mobileBlockStart ? mobileBlockEnd : undefined);
|
||||
|
||||
expect(mobileBlock).toContain("--mobile-nav-height");
|
||||
expect(mobileBlock).toContain("--standalone-bottom-gap");
|
||||
expect(mobileBlock).toContain("env(safe-area-inset-bottom");
|
||||
expect(mobileBlock).not.toMatch(/height:\s*100%\s*;/);
|
||||
expect(mobileBlock).not.toMatch(/max-height:\s*100%\s*;/);
|
||||
expect(allCss).toContain(".agent-error-modal");
|
||||
expect(allCss).toContain("--mobile-nav-height");
|
||||
expect(allCss).toContain("--standalone-bottom-gap");
|
||||
expect(allCss).toContain("env(safe-area-inset-bottom");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { ConfirmDialog } from "../ConfirmDialog";
|
||||
import { loadAllAppCss } from "../../test/cssFixture";
|
||||
|
||||
describe("ConfirmDialog", () => {
|
||||
it("renders title and message", () => {
|
||||
@@ -121,4 +122,62 @@ describe("ConfirmDialog", () => {
|
||||
expect(container.querySelector(".confirm-dialog-overlay")).toBeTruthy();
|
||||
expect(container.querySelector(".confirm-dialog.modal")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not render checkbox when checkboxLabel is omitted", () => {
|
||||
render(
|
||||
<ConfirmDialog
|
||||
isOpen={true}
|
||||
options={{ title: "Delete Task", message: "Delete FN-001?", danger: true }}
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders checkbox label and description when provided", () => {
|
||||
render(
|
||||
<ConfirmDialog
|
||||
isOpen={true}
|
||||
options={{ title: "Delete Task", message: "Delete FN-001?", danger: true }}
|
||||
checkboxLabel="Allow re-creation later"
|
||||
checkboxDescription="Keeps this ID unlockable"
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("checkbox")).toBeInTheDocument();
|
||||
expect(screen.getByText("Allow re-creation later")).toBeInTheDocument();
|
||||
expect(screen.getByText("Keeps this ID unlockable")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onCheckboxChange when toggled", () => {
|
||||
const onCheckboxChange = vi.fn();
|
||||
render(
|
||||
<ConfirmDialog
|
||||
isOpen={true}
|
||||
options={{ title: "Delete Task", message: "Delete FN-001?", danger: true }}
|
||||
checkboxLabel="Allow re-creation later"
|
||||
checkboxChecked={false}
|
||||
onCheckboxChange={onCheckboxChange}
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("checkbox"));
|
||||
expect(onCheckboxChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("uses only token values in confirm-dialog checkbox css rule", () => {
|
||||
const css = loadAllAppCss();
|
||||
const match = css.match(/\.confirm-dialog__checkbox\s*\{([^}]*)\}/);
|
||||
expect(match).toBeTruthy();
|
||||
const ruleBody = match?.[1] ?? "";
|
||||
expect(ruleBody).toMatch(/var\(--/);
|
||||
expect(ruleBody).not.toMatch(/#[0-9a-fA-F]{3,8}\b|rgb\(/);
|
||||
expect(ruleBody).not.toMatch(/\b(?!0(?:\D|$))\d+(?:\.\d+)?px\b/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ function mockMatchMedia({ mobile = false, coarse = false, reducedMotion = false
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches:
|
||||
(query === "(max-width: 768px)" || query === "(max-width: 768px), (max-height: 480px)" && mobile)
|
||||
((query === "(max-width: 768px)" || query === "(max-width: 768px), (max-height: 480px)") && mobile)
|
||||
|| (query === "(pointer: coarse)" && coarse)
|
||||
|| (query === "(prefers-reduced-motion: reduce)" && reducedMotion),
|
||||
media: query,
|
||||
@@ -85,6 +85,7 @@ describe("OAuthManualCodeForm", () => {
|
||||
|
||||
it("does not trigger scroll assist on non-mobile layouts", () => {
|
||||
mockMatchMedia({ mobile: false, coarse: false });
|
||||
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1280 });
|
||||
|
||||
render(
|
||||
<OAuthManualCodeForm
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import {
|
||||
makeTask,
|
||||
noop,
|
||||
noopMerge,
|
||||
noopMove,
|
||||
noopOpenDetail,
|
||||
setupTaskDetailModalHooks,
|
||||
mockConfirm,
|
||||
mockConfirmWithChoice,
|
||||
mockConfirmWithCheckbox,
|
||||
} from "./TaskDetailModal.test-helpers";
|
||||
import { TaskDetailModal } from "../TaskDetailModal";
|
||||
|
||||
setupTaskDetailModalHooks();
|
||||
|
||||
describe("TaskDetailModal allowResurrection delete flow", () => {
|
||||
it("passes allowResurrection=true when checkbox is checked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onDeleteTask = vi.fn(async () => makeTask());
|
||||
mockConfirmWithCheckbox.mockResolvedValueOnce({ choice: "primary", checkboxValue: true });
|
||||
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask()}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={onDeleteTask}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
await user.click(screen.getByRole("menuitem", { name: "Delete" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onDeleteTask).toHaveBeenCalledWith("FN-099", { allowResurrection: true });
|
||||
});
|
||||
});
|
||||
|
||||
it("passes allowResurrection=false when checkbox is left unchecked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onDeleteTask = vi.fn(async () => makeTask());
|
||||
mockConfirmWithCheckbox.mockResolvedValueOnce({ choice: "primary", checkboxValue: false });
|
||||
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask()}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={onDeleteTask}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
await user.click(screen.getByRole("menuitem", { name: "Delete" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onDeleteTask).toHaveBeenCalledWith("FN-099", { allowResurrection: false });
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps allowResurrection value on dependency-conflict retry", async () => {
|
||||
const user = userEvent.setup();
|
||||
const conflict = new Error("conflict") as Error & { details: { code: string; dependentIds: string[] } };
|
||||
conflict.details = { code: "TASK_HAS_DEPENDENTS", dependentIds: ["FN-100"] };
|
||||
const onDeleteTask = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(conflict)
|
||||
.mockResolvedValueOnce(makeTask());
|
||||
mockConfirmWithCheckbox.mockResolvedValueOnce({ choice: "primary", checkboxValue: true });
|
||||
mockConfirm.mockResolvedValueOnce(true);
|
||||
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask()}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={onDeleteTask}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
await user.click(screen.getByRole("menuitem", { name: "Delete" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onDeleteTask).toHaveBeenNthCalledWith(2, "FN-099", {
|
||||
removeDependencyReferences: true,
|
||||
removeLineageReferences: true,
|
||||
githubIssueAction: undefined,
|
||||
allowResurrection: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("archive branch stays delete-unaffected", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onDeleteTask = vi.fn(async () => makeTask());
|
||||
const onArchiveTask = vi.fn(async () => makeTask({ column: "archived" }));
|
||||
mockConfirmWithChoice.mockResolvedValueOnce("tertiary");
|
||||
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ column: "done" })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={onDeleteTask}
|
||||
onArchiveTask={onArchiveTask}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
await user.click(screen.getByRole("menuitem", { name: "Delete" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onArchiveTask).toHaveBeenCalledWith("FN-099");
|
||||
expect(onDeleteTask).not.toHaveBeenCalled();
|
||||
expect(mockConfirmWithCheckbox).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
noopOpenDetail,
|
||||
mockConfirm,
|
||||
mockConfirmWithChoice,
|
||||
mockConfirmWithCheckbox,
|
||||
mockUsePluginUiSlots,
|
||||
expectBaseRule,
|
||||
readDashboardStylesSource,
|
||||
@@ -125,14 +126,13 @@ describe("TaskDetailModal", () => {
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onDeleteTask).toHaveBeenCalledWith("FN-099", { githubIssueAction: "close" });
|
||||
expect(onDeleteTask).toHaveBeenCalledWith("FN-099", { githubIssueAction: "close", allowResurrection: false });
|
||||
});
|
||||
});
|
||||
|
||||
it("passes githubIssueAction=delete for tracked tasks", async () => {
|
||||
const onDeleteTask = vi.fn().mockResolvedValue({} as Task);
|
||||
mockConfirm
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValueOnce(true);
|
||||
|
||||
@@ -152,14 +152,13 @@ describe("TaskDetailModal", () => {
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onDeleteTask).toHaveBeenCalledWith("FN-099", { githubIssueAction: "delete" });
|
||||
expect(onDeleteTask).toHaveBeenCalledWith("FN-099", { githubIssueAction: "delete", allowResurrection: false });
|
||||
});
|
||||
});
|
||||
|
||||
it("passes githubIssueAction=leave for tracked tasks", async () => {
|
||||
const onDeleteTask = vi.fn().mockResolvedValue({} as Task);
|
||||
mockConfirm
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValueOnce(false);
|
||||
|
||||
@@ -179,7 +178,7 @@ describe("TaskDetailModal", () => {
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onDeleteTask).toHaveBeenCalledWith("FN-099", { githubIssueAction: "leave" });
|
||||
expect(onDeleteTask).toHaveBeenCalledWith("FN-099", { githubIssueAction: "leave", allowResurrection: false });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -203,7 +202,7 @@ describe("TaskDetailModal", () => {
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onDeleteTask).toHaveBeenCalledWith("FN-099");
|
||||
expect(onDeleteTask).toHaveBeenCalledWith("FN-099", { allowResurrection: false });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -219,8 +218,8 @@ describe("TaskDetailModal", () => {
|
||||
.mockRejectedValueOnce(conflict)
|
||||
.mockResolvedValueOnce({} as Task);
|
||||
|
||||
mockConfirmWithCheckbox.mockResolvedValueOnce({ choice: "primary", checkboxValue: false });
|
||||
mockConfirm
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(true);
|
||||
@@ -242,24 +241,19 @@ describe("TaskDetailModal", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockConfirm).toHaveBeenNthCalledWith(1, {
|
||||
title: "Delete Task",
|
||||
message: "Delete FN-099?",
|
||||
danger: true,
|
||||
});
|
||||
expect(mockConfirm).toHaveBeenNthCalledWith(2, {
|
||||
title: "Linked GitHub Issue",
|
||||
message: "Choose what to do with owner/repo#42 when deleting FN-099.\n\nClose the issue?",
|
||||
confirmLabel: "Close Issue",
|
||||
cancelLabel: "More Options",
|
||||
});
|
||||
expect(mockConfirm).toHaveBeenNthCalledWith(3, {
|
||||
expect(mockConfirm).toHaveBeenNthCalledWith(2, {
|
||||
title: "Delete Linked GitHub Issue",
|
||||
message: "Delete owner/repo#42 on GitHub, or leave it unchanged?",
|
||||
confirmLabel: "Delete Issue",
|
||||
cancelLabel: "Leave Unchanged",
|
||||
danger: true,
|
||||
});
|
||||
expect(mockConfirm).toHaveBeenNthCalledWith(4, {
|
||||
expect(mockConfirm).toHaveBeenNthCalledWith(3, {
|
||||
title: "Force Delete Task",
|
||||
message: "FN-099 is a dependency of FN-100, FN-101.\n\nDelete anyway by removing these dependency references first?",
|
||||
danger: true,
|
||||
@@ -267,11 +261,12 @@ describe("TaskDetailModal", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onDeleteTask).toHaveBeenNthCalledWith(1, "FN-099", { githubIssueAction: "delete" });
|
||||
expect(onDeleteTask).toHaveBeenNthCalledWith(1, "FN-099", { githubIssueAction: "delete", allowResurrection: false });
|
||||
expect(onDeleteTask).toHaveBeenNthCalledWith(2, "FN-099", {
|
||||
removeDependencyReferences: true,
|
||||
removeLineageReferences: true,
|
||||
githubIssueAction: "delete",
|
||||
allowResurrection: false,
|
||||
});
|
||||
expect(noop).toHaveBeenCalledWith("Deleted FN-099 after removing dependency references", "info");
|
||||
});
|
||||
@@ -287,9 +282,8 @@ describe("TaskDetailModal", () => {
|
||||
conflict.details = { code: "TASK_HAS_DEPENDENTS", dependentIds: ["FN-102"] };
|
||||
onDeleteTask.mockRejectedValue(conflict);
|
||||
|
||||
mockConfirm
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(false);
|
||||
mockConfirmWithCheckbox.mockResolvedValueOnce({ choice: "primary", checkboxValue: false });
|
||||
mockConfirm.mockResolvedValueOnce(false);
|
||||
|
||||
render(
|
||||
<TaskDetailModal
|
||||
@@ -307,7 +301,7 @@ describe("TaskDetailModal", () => {
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockConfirm).toHaveBeenCalledTimes(2);
|
||||
expect(mockConfirm).toHaveBeenCalledTimes(1);
|
||||
expect(onDeleteTask).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -322,8 +316,8 @@ describe("TaskDetailModal", () => {
|
||||
conflict.details = { code: "TASK_HAS_LINEAGE_CHILDREN", lineageChildIds: ["FN-104"] };
|
||||
onDeleteTask.mockRejectedValue(conflict);
|
||||
|
||||
mockConfirmWithCheckbox.mockResolvedValueOnce({ choice: "primary", checkboxValue: false });
|
||||
mockConfirm
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(false);
|
||||
|
||||
@@ -343,7 +337,7 @@ describe("TaskDetailModal", () => {
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockConfirm).toHaveBeenCalledTimes(3);
|
||||
expect(mockConfirm).toHaveBeenCalledTimes(2);
|
||||
expect(onDeleteTask).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -360,9 +354,8 @@ describe("TaskDetailModal", () => {
|
||||
.mockRejectedValueOnce(conflict)
|
||||
.mockRejectedValueOnce(new Error("Retry failed"));
|
||||
|
||||
mockConfirm
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(true);
|
||||
mockConfirmWithCheckbox.mockResolvedValueOnce({ choice: "primary", checkboxValue: false });
|
||||
mockConfirm.mockResolvedValueOnce(true);
|
||||
|
||||
render(
|
||||
<TaskDetailModal
|
||||
@@ -383,6 +376,7 @@ describe("TaskDetailModal", () => {
|
||||
expect(onDeleteTask).toHaveBeenNthCalledWith(2, "FN-099", {
|
||||
removeDependencyReferences: true,
|
||||
removeLineageReferences: true,
|
||||
allowResurrection: false,
|
||||
});
|
||||
expect(noop).toHaveBeenCalledWith("Retry failed", "error");
|
||||
});
|
||||
@@ -400,8 +394,8 @@ describe("TaskDetailModal", () => {
|
||||
.mockRejectedValueOnce(conflict)
|
||||
.mockResolvedValueOnce({} as Task);
|
||||
|
||||
mockConfirmWithCheckbox.mockResolvedValueOnce({ choice: "primary", checkboxValue: false });
|
||||
mockConfirm
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(true);
|
||||
|
||||
@@ -425,6 +419,7 @@ describe("TaskDetailModal", () => {
|
||||
removeDependencyReferences: true,
|
||||
removeLineageReferences: true,
|
||||
githubIssueAction: "close",
|
||||
allowResurrection: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,9 +88,14 @@ vi.mock("../../hooks/usePluginUiSlots", () => ({
|
||||
|
||||
export const mockConfirm = vi.fn();
|
||||
export const mockConfirmWithChoice = vi.fn();
|
||||
export const mockConfirmWithCheckbox = vi.fn();
|
||||
|
||||
vi.mock("../../hooks/useConfirm", () => ({
|
||||
useConfirm: () => ({ confirm: mockConfirm, confirmWithChoice: mockConfirmWithChoice }),
|
||||
useConfirm: () => ({
|
||||
confirm: mockConfirm,
|
||||
confirmWithChoice: mockConfirmWithChoice,
|
||||
confirmWithCheckbox: mockConfirmWithCheckbox,
|
||||
}),
|
||||
}));
|
||||
|
||||
export function makeTask(overrides: Partial<TaskDetail> = {}): TaskDetail {
|
||||
@@ -143,8 +148,10 @@ export function setupTaskDetailModalHooks(): void {
|
||||
beforeEach(() => {
|
||||
mockConfirm.mockReset();
|
||||
mockConfirmWithChoice.mockReset();
|
||||
mockConfirmWithCheckbox.mockReset();
|
||||
mockConfirm.mockResolvedValue(true);
|
||||
mockConfirmWithChoice.mockResolvedValue("primary");
|
||||
mockConfirmWithCheckbox.mockResolvedValue({ choice: "primary", checkboxValue: false });
|
||||
clearAuthToken();
|
||||
localStorage.removeItem("fn.authToken");
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import React, { useState } from "react";
|
||||
import { ConfirmDialogProvider, useConfirm } from "../useConfirm";
|
||||
|
||||
function Harness() {
|
||||
const { confirm, confirmWithChoice } = useConfirm();
|
||||
const { confirm, confirmWithChoice, confirmWithCheckbox } = useConfirm();
|
||||
const [result, setResult] = useState<string>("idle");
|
||||
|
||||
return React.createElement(
|
||||
@@ -30,6 +30,34 @@ function Harness() {
|
||||
},
|
||||
"queue"
|
||||
),
|
||||
React.createElement(
|
||||
"button",
|
||||
{
|
||||
onClick: async () => {
|
||||
const outcome = await confirmWithCheckbox({
|
||||
title: "Delete Task",
|
||||
message: "Delete FN-001?",
|
||||
checkbox: { label: "Allow re-creation later", defaultChecked: false },
|
||||
});
|
||||
setResult(JSON.stringify(outcome));
|
||||
},
|
||||
},
|
||||
"open-checkbox"
|
||||
),
|
||||
React.createElement(
|
||||
"button",
|
||||
{
|
||||
onClick: async () => {
|
||||
const outcome = await confirmWithCheckbox({
|
||||
title: "Delete Task",
|
||||
message: "Delete FN-001?",
|
||||
checkbox: { label: "Allow re-creation later", defaultChecked: true },
|
||||
});
|
||||
setResult(JSON.stringify(outcome));
|
||||
},
|
||||
},
|
||||
"open-checkbox-default-checked"
|
||||
),
|
||||
React.createElement(
|
||||
"button",
|
||||
{
|
||||
@@ -151,4 +179,56 @@ describe("useConfirm", () => {
|
||||
|
||||
expect(await screen.findByRole("dialog", { name: "Second" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("confirmWithCheckbox returns primary with toggled checkbox value", async () => {
|
||||
render(
|
||||
React.createElement(
|
||||
ConfirmDialogProvider,
|
||||
null,
|
||||
React.createElement(Harness)
|
||||
)
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("open-checkbox"));
|
||||
fireEvent.click(await screen.findByRole("checkbox"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Confirm" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("result").textContent).toBe('{"choice":"primary","checkboxValue":true}');
|
||||
});
|
||||
});
|
||||
|
||||
it("confirmWithCheckbox returns cancel with unchecked value on cancel", async () => {
|
||||
render(
|
||||
React.createElement(
|
||||
ConfirmDialogProvider,
|
||||
null,
|
||||
React.createElement(Harness)
|
||||
)
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("open-checkbox"));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Cancel" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("result").textContent).toBe('{"choice":"cancel","checkboxValue":false}');
|
||||
});
|
||||
});
|
||||
|
||||
it("confirmWithCheckbox keeps defaultChecked value when untouched", async () => {
|
||||
render(
|
||||
React.createElement(
|
||||
ConfirmDialogProvider,
|
||||
null,
|
||||
React.createElement(Harness)
|
||||
)
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("open-checkbox-default-checked"));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Confirm" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("result").textContent).toBe('{"choice":"primary","checkboxValue":true}');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,10 +19,18 @@ vi.mock("../../sse-bus", () => ({
|
||||
const eventPayload = { events: [{ taskId: "FN-1", integrationBranch: "trunk", refName: "refs/heads/trunk", toSha: "abcdef123456", fromSha: "123", advanceMode: "update-ref", succeeded: true, advancedAt: "2026", userCheckout: { worktreePath: "/repo", dirty: false, untrackedCount: 0 } }] };
|
||||
const pushStatus = { integrationBranch: "trunk", branchSource: "settings" as const, hasOriginRemote: true, hasUpstream: true, localSha: "localsha", remoteSha: "remotesha", aheadCount: 2, behindCount: 0, mergeActive: false, canPush: true };
|
||||
|
||||
function toPath(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (value instanceof URL) return value.toString();
|
||||
if (value && typeof value === "object" && "url" in value) return String((value as { url?: unknown }).url ?? "");
|
||||
return String(value ?? "");
|
||||
}
|
||||
|
||||
function setDefaultMocks() {
|
||||
mocked.api.mockImplementation(async (path: string) => {
|
||||
if (String(path).includes("push-status")) return pushStatus;
|
||||
if (String(path).includes("merge-advance-events")) return eventPayload;
|
||||
mocked.api.mockImplementation(async (path: unknown) => {
|
||||
const target = toPath(path);
|
||||
if (target.includes("push-status")) return pushStatus;
|
||||
if (target.includes("merge-advance-events")) return eventPayload;
|
||||
return { ok: true, outcome: "ok", localSha: "localsha", remoteSha: "localsha" };
|
||||
});
|
||||
}
|
||||
@@ -126,23 +134,23 @@ describe("useMergeAdvanceNotice", () => {
|
||||
|
||||
it("pull posts to /git/pull and dismisses on clean outcome", async () => {
|
||||
const pullEventPayload = { events: [{ ...eventPayload.events[0], toSha: "clean12345" }] };
|
||||
mocked.api.mockImplementation(async (path: string) => {
|
||||
if (String(path).includes("merge-advance-events")) return pullEventPayload;
|
||||
if (String(path).includes("push-status")) return pushStatus;
|
||||
if (String(path).includes("/git/pull")) return { kind: "pull-clean", toSha: "clean12345" };
|
||||
mocked.api.mockImplementation(async (path: unknown) => {
|
||||
const target = toPath(path);
|
||||
if (target.includes("merge-advance-events")) return pullEventPayload;
|
||||
if (target.includes("push-status")) return pushStatus;
|
||||
if (target.includes("/git/pull")) return { kind: "pull-clean", toSha: "clean12345" };
|
||||
return { ok: true, outcome: "ok", localSha: "localsha", remoteSha: "localsha" };
|
||||
});
|
||||
const { result } = renderHook(() => useMergeAdvanceNotice({ projectId: "p1-pull-clean" }));
|
||||
await waitFor(() => expect(result.current.notice).toBeDefined());
|
||||
await act(async () => { await result.current.pull(); });
|
||||
expect(mocked.api.mock.calls.some((call) => String(call[0]).startsWith("/git/pull?projectId=p1-pull-clean"))).toBe(true);
|
||||
expect(mocked.api.mock.calls.some((call) => toPath(call[0]).includes("/git/pull"))).toBe(true);
|
||||
expect(result.current.conflictState).toBeNull();
|
||||
});
|
||||
|
||||
it("dismiss() actually removes the banner — dismissedShas filter is applied in the notice memo", async () => {
|
||||
const { result } = renderHook(() => useMergeAdvanceNotice({ projectId: "p1-dismiss" }));
|
||||
await waitFor(() => expect(result.current.notice).toBeDefined());
|
||||
expect(result.current.notice?.toSha).toBe("abcdef123456");
|
||||
await waitFor(() => expect(result.current.notice?.toSha).toBe("abcdef123456"));
|
||||
act(() => result.current.dismiss());
|
||||
await waitFor(() => expect(result.current.notice).toBeUndefined());
|
||||
});
|
||||
@@ -212,13 +220,11 @@ describe("useMergeAdvanceNotice", () => {
|
||||
|
||||
it("pull stash-conflict opens conflict state and preserves error visibility", async () => {
|
||||
const conflictEventPayload = { events: [{ ...eventPayload.events[0], toSha: "conflict12345" }] };
|
||||
let callIndex = 0;
|
||||
mocked.api.mockImplementation(async () => {
|
||||
const current = callIndex;
|
||||
callIndex += 1;
|
||||
if (current === 0) return conflictEventPayload;
|
||||
if (current === 1) return pushStatus;
|
||||
if (current === 2) {
|
||||
mocked.api.mockImplementation(async (path: unknown) => {
|
||||
const target = toPath(path);
|
||||
if (target.includes("merge-advance-events")) return conflictEventPayload;
|
||||
if (target.includes("push-status")) return pushStatus;
|
||||
if (target.includes("/git/pull")) {
|
||||
return {
|
||||
kind: "stash-conflict",
|
||||
toSha: "conflict12345",
|
||||
@@ -231,7 +237,7 @@ describe("useMergeAdvanceNotice", () => {
|
||||
return pushStatus;
|
||||
});
|
||||
const { result } = renderHook(() => useMergeAdvanceNotice({ projectId: "p1-pull-conflict" }));
|
||||
await waitFor(() => expect(result.current.notice).toBeDefined());
|
||||
await waitFor(() => expect(result.current.notice?.toSha).toBe("conflict12345"));
|
||||
await act(async () => { await result.current.pull(); });
|
||||
await waitFor(() => expect(result.current.conflictState).not.toBeNull());
|
||||
expect(result.current.conflictState).toEqual({
|
||||
|
||||
@@ -11,18 +11,25 @@ export interface ConfirmOptions {
|
||||
danger?: boolean;
|
||||
tertiaryLabel?: string;
|
||||
tertiaryDanger?: boolean;
|
||||
checkbox?: {
|
||||
label: string;
|
||||
description?: string;
|
||||
defaultChecked?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export type ConfirmChoice = "primary" | "tertiary" | "cancel";
|
||||
|
||||
interface PendingConfirm {
|
||||
options: ConfirmOptions;
|
||||
resolve: (value: ConfirmChoice) => void;
|
||||
checkboxValue: boolean;
|
||||
resolve: (value: { choice: ConfirmChoice; checkboxValue: boolean }) => void;
|
||||
}
|
||||
|
||||
interface ConfirmContextValue {
|
||||
confirm: (options: ConfirmOptions) => Promise<boolean>;
|
||||
confirmWithChoice: (options: ConfirmOptions) => Promise<ConfirmChoice>;
|
||||
confirmWithCheckbox: (options: ConfirmOptions) => Promise<{ choice: ConfirmChoice; checkboxValue: boolean }>;
|
||||
}
|
||||
|
||||
const ConfirmContext = createContext<ConfirmContextValue | null>(null);
|
||||
@@ -39,12 +46,24 @@ export function ConfirmDialogProvider({ children }: { children: ReactNode }) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const confirmWithChoice = useCallback((options: ConfirmOptions) => {
|
||||
return new Promise<ConfirmChoice>((resolve) => {
|
||||
updateQueue((current) => [...current, { options, resolve }]);
|
||||
const confirmWithCheckbox = useCallback((options: ConfirmOptions) => {
|
||||
return new Promise<{ choice: ConfirmChoice; checkboxValue: boolean }>((resolve) => {
|
||||
updateQueue((current) => [
|
||||
...current,
|
||||
{
|
||||
options,
|
||||
checkboxValue: options.checkbox?.defaultChecked ?? false,
|
||||
resolve,
|
||||
},
|
||||
]);
|
||||
});
|
||||
}, [updateQueue]);
|
||||
|
||||
const confirmWithChoice = useCallback(async (options: ConfirmOptions) => {
|
||||
const { choice } = await confirmWithCheckbox(options);
|
||||
return choice;
|
||||
}, [confirmWithCheckbox]);
|
||||
|
||||
const confirm = useCallback(async (options: ConfirmOptions) => {
|
||||
const choice = await confirmWithChoice(options);
|
||||
return choice === "primary";
|
||||
@@ -56,13 +75,16 @@ export function ConfirmDialogProvider({ children }: { children: ReactNode }) {
|
||||
return;
|
||||
}
|
||||
|
||||
current.resolve(value);
|
||||
current.resolve({ choice: value, checkboxValue: current.checkboxValue });
|
||||
updateQueue((items) => items.slice(1));
|
||||
}, [updateQueue]);
|
||||
|
||||
const active = queue[0] ?? null;
|
||||
|
||||
const contextValue = useMemo<ConfirmContextValue>(() => ({ confirm, confirmWithChoice }), [confirm, confirmWithChoice]);
|
||||
const contextValue = useMemo<ConfirmContextValue>(
|
||||
() => ({ confirm, confirmWithChoice, confirmWithCheckbox }),
|
||||
[confirm, confirmWithCheckbox, confirmWithChoice]
|
||||
);
|
||||
|
||||
return React.createElement(
|
||||
ConfirmContext.Provider,
|
||||
@@ -74,6 +96,18 @@ export function ConfirmDialogProvider({ children }: { children: ReactNode }) {
|
||||
onConfirm: () => resolveCurrent("primary"),
|
||||
onTertiary: () => resolveCurrent("tertiary"),
|
||||
onCancel: () => resolveCurrent("cancel"),
|
||||
checkboxLabel: active?.options.checkbox?.label,
|
||||
checkboxDescription: active?.options.checkbox?.description,
|
||||
checkboxChecked: active?.checkboxValue ?? false,
|
||||
onCheckboxChange: (next) => {
|
||||
updateQueue((current) => {
|
||||
if (current.length === 0) {
|
||||
return current;
|
||||
}
|
||||
const [head, ...tail] = current;
|
||||
return [{ ...head, checkboxValue: next }, ...tail];
|
||||
});
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -87,5 +121,9 @@ export function useConfirm(): ConfirmContextValue {
|
||||
return {
|
||||
confirm: async (_options: ConfirmOptions) => false,
|
||||
confirmWithChoice: async (_options: ConfirmOptions) => "cancel",
|
||||
confirmWithCheckbox: async (options: ConfirmOptions) => ({
|
||||
choice: "cancel",
|
||||
checkboxValue: options.checkbox?.defaultChecked ?? false,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -516,6 +516,7 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
removeDependencyReferences?: boolean;
|
||||
removeLineageReferences?: boolean;
|
||||
githubIssueAction?: GithubIssueAction;
|
||||
allowResurrection?: boolean;
|
||||
},
|
||||
): Promise<Task> => {
|
||||
return normalizeTask(await api.deleteTask(id, projectId, options));
|
||||
|
||||
@@ -2112,6 +2112,14 @@ describe("Mission API", () => {
|
||||
});
|
||||
|
||||
it("accepts long missionTitle values on interview start", async () => {
|
||||
const interviewSpy = vi
|
||||
.spyOn(missionInterviewModule, "createMissionInterviewSession")
|
||||
.mockResolvedValueOnce({
|
||||
sessionId: "session-long-title",
|
||||
interview: { missionDraft: { title: "x".repeat(5000) } },
|
||||
state: "active",
|
||||
} as any);
|
||||
|
||||
const { app } = buildApp();
|
||||
const res = await request(
|
||||
app,
|
||||
@@ -2121,6 +2129,7 @@ describe("Mission API", () => {
|
||||
{ "content-type": "application/json" }
|
||||
);
|
||||
expect(res.status).not.toBe(400);
|
||||
expect(interviewSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return 400 when sessionId is missing on interview respond", async () => {
|
||||
|
||||
@@ -12,7 +12,7 @@ const qualityAppTests = [
|
||||
"app/api/**/*.test.ts",
|
||||
// Representative workflow/component coverage. Exhaustive modal/view suites
|
||||
// stay available in the full `dashboard-app` project.
|
||||
"app/components/__tests__/{ActiveAgentsPanel,ActivityLogModal,AgentMentionPopup,AgentMetricsBar,AgentOnboardingModal,AgentReflectionsTab,AgentTokenStatsPanel,App,AuthTokenRecoveryDialog,Board,board-mobile,board-mobile-view-switch,ChatView,ChatView.autosize,ChatView.chat-input-autosize,ChatView.default-model-icon,ChatView.draft,ChatView.hash-mention,ChatView.rooms,ChatView.scroll-to-top,ChatView.swipe-back,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DevServerView.mobile,DirectoryPicker,DuplicateWarningModal,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,GitHubBadge,InlineCreateCard,LoginInstructions,MemoryView,MergeAdvanceNotice,MessageComposer,MessageComposer.autosize,MobileNavBar,NewTaskModal,NewTaskModal.shared-cache,NodeCard,NodeHealthDot,NodeStatusIndicator,PlanningModeModal.autosize,PrChecksList,PrCreateModal,PrCreateModal.layout,ProjectCard,ProjectSelector,ProviderIcon,PrPanel,PrPanel.merge,PrPanel.reviews,QuickChatFAB,QuickChatFAB.shared-cache,ReliabilityView,ResearchView,SecretsView,SecretsView.mobile,SettingsModal,SettingsModal.testMode,SettingsModal.worktrunk,StashConflictModal,StashRecoveryView,TaskCard,TaskCard.badge-height,TaskCard.badge-wrap,TaskCard.footer-wrap,TaskChangesTab,TaskComments,TaskDetailModal,TaskDetailModal.create-pr-e2e,TestModeBanner,TaskDetailModal.create-pr-integration,TaskDetailModal.github-tracking-header,TaskDetailModal.github-tracking-stale,TaskDetailModal.rebind-banner,TaskDocumentsTab,TaskForm,TaskIdIntegrityBanner,TrackingRepoSelect,WorkflowResultsTab,WorktrunkInstallApprovalDetails}.test.tsx",
|
||||
"app/components/__tests__/{ActiveAgentsPanel,ActivityLogModal,AgentMentionPopup,AgentMetricsBar,AgentOnboardingModal,AgentReflectionsTab,AgentTokenStatsPanel,App,AuthTokenRecoveryDialog,Board,board-mobile,board-mobile-view-switch,ChatView,ChatView.autosize,ChatView.chat-input-autosize,ChatView.default-model-icon,ChatView.draft,ChatView.hash-mention,ChatView.rooms,ChatView.scroll-to-top,ChatView.swipe-back,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DevServerView.mobile,DirectoryPicker,DuplicateWarningModal,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,GitHubBadge,InlineCreateCard,LoginInstructions,MemoryView,MergeAdvanceNotice,MessageComposer,MessageComposer.autosize,MobileNavBar,NewTaskModal,NewTaskModal.shared-cache,NodeCard,NodeHealthDot,NodeStatusIndicator,PlanningModeModal.autosize,PrChecksList,PrCreateModal,PrCreateModal.layout,ProjectCard,ProjectSelector,ProviderIcon,PrPanel,PrPanel.merge,PrPanel.reviews,QuickChatFAB,QuickChatFAB.shared-cache,ReliabilityView,ResearchView,SecretsView,SecretsView.mobile,SettingsModal,SettingsModal.testMode,SettingsModal.worktrunk,StashConflictModal,StashRecoveryView,TaskCard,TaskCard.badge-height,TaskCard.badge-wrap,TaskCard.footer-wrap,TaskChangesTab,TaskComments,TaskDetailModal,TaskDetailModal.allow-resurrection,TaskDetailModal.create-pr-e2e,TestModeBanner,TaskDetailModal.create-pr-integration,TaskDetailModal.github-tracking-header,TaskDetailModal.github-tracking-stale,TaskDetailModal.rebind-banner,TaskDocumentsTab,TaskForm,TaskIdIntegrityBanner,TrackingRepoSelect,WorkflowResultsTab,WorktrunkInstallApprovalDetails}.test.tsx",
|
||||
// Hooks and utilities are fast, user-visible state/formatting behavior.
|
||||
"app/context/**/*.test.tsx",
|
||||
"app/hooks/__tests__/{useAgents,useAgentLogs,useAgentLogs.resume-instrumentation,useAppSettings,useAuthOnboarding,useConfirm,useCurrentProject,useNodes,useNodes.resume-instrumentation,useNodeSettingsSync,useProjects,useProjects.resume-instrumentation,useMeshState.resume-instrumentation,useManagedDockerNodes.resume-instrumentation,usePrChecksStream.resume-instrumentation,useDevServerLogs.resume-instrumentation,useResearch.resume-instrumentation,useBackgroundSessions.resume-instrumentation,useQuickChat,useTasks,useTasks.resume-instrumentation,useChatRooms.resume-instrumentation,useTerminalSessions,useTheme,useToast,useUsageData,useViewState,useMergeAdvanceNotice}.test.{ts,tsx}",
|
||||
|
||||
Reference in New Issue
Block a user