mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[F020] kanban: confirm admin-override drags that skip lifecycle preconditions
A drag on the operator kanban routes the status move through the admin status-override, which bypasses the in-band lifecycle validator entirely. That override is intentional (it's how an operator recovers a wedged task) but it also let a careless drag skip material preconditions silently — completing a task with no open PR, QA-bypassing, finishing docs on a task whose docs aren't complete. Leave the override intact but make the bypass explicit: compute the preconditions the dragged move would skip (open PR, docs complete, self-verified + commits + progress for submit-qa, visible non-terminal subtasks for coordination-root targets) and, when any are skipped, hold the move behind a confirmation dialog that lists exactly what's being skipped. Precision over recall — only warn on what the panel can verify from the task and its in-list children; never fabricate a 'satisfied' claim, and stay silent on benign transitions that gate on nothing we can check. The admin status-override capability is preserved (Confirm still fires it); this only surfaces the bypass instead of letting it happen silently. Does not touch the master-merge invariant — the board's updateTask is the operator override, not the Main-PM merge path.
This commit is contained in:
@@ -0,0 +1,133 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { TaskStatus, Team, TaskType, type Task } from "@/types";
|
||||||
|
import { skippedPreconditions } from "../bypass-preconditions";
|
||||||
|
|
||||||
|
function buildTask(overrides: Partial<Task> = {}): Task {
|
||||||
|
return {
|
||||||
|
id: "t1",
|
||||||
|
title: "A task",
|
||||||
|
description: "desc",
|
||||||
|
acceptance_criteria: ["a"],
|
||||||
|
status: TaskStatus.IN_PROGRESS,
|
||||||
|
priority: 2,
|
||||||
|
sequence: 0,
|
||||||
|
team: Team.BACKEND,
|
||||||
|
created_by: "ceo",
|
||||||
|
assigned_to: null,
|
||||||
|
parent_task_id: null,
|
||||||
|
dependency_ids: [],
|
||||||
|
blocker_ids: [],
|
||||||
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
|
updated_at: null,
|
||||||
|
claimed_at: null,
|
||||||
|
started_at: null,
|
||||||
|
completed_at: null,
|
||||||
|
target_date: null,
|
||||||
|
estimated_complexity: "M" as never,
|
||||||
|
nature: "feature" as never,
|
||||||
|
task_type: TaskType.CODE,
|
||||||
|
project_id: "p1",
|
||||||
|
docs_complete: false,
|
||||||
|
pr_created: false,
|
||||||
|
pm_approvals: {},
|
||||||
|
plan: null,
|
||||||
|
checkpoints: [],
|
||||||
|
progress_updates: [],
|
||||||
|
commits: [],
|
||||||
|
self_verified: false,
|
||||||
|
qa_verified: null,
|
||||||
|
sessions: [],
|
||||||
|
branch_name: null,
|
||||||
|
pr_number: null,
|
||||||
|
pr_url: null,
|
||||||
|
...overrides,
|
||||||
|
} as unknown as Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("skippedPreconditions — F020 (what an admin-override drag skips)", () => {
|
||||||
|
it("flags a PR-less drag to awaiting_qa with the submit-qa gate's missing checks", () => {
|
||||||
|
const task = buildTask({ pr_number: null, self_verified: false });
|
||||||
|
const skipped = skippedPreconditions(task, TaskStatus.AWAITING_QA, [task]);
|
||||||
|
expect(skipped).toEqual([
|
||||||
|
"no open PR",
|
||||||
|
"not self-verified",
|
||||||
|
"no commits linked",
|
||||||
|
"no progress updates",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays silent when a task meets the awaiting_qa preconditions (no false alarm)", () => {
|
||||||
|
const task = buildTask({
|
||||||
|
pr_number: 42,
|
||||||
|
self_verified: true,
|
||||||
|
commits: [{ sha: "abc", message: "m", timestamp: "t" } as never],
|
||||||
|
progress_updates: [{ note: "p" } as never],
|
||||||
|
});
|
||||||
|
expect(skippedPreconditions(task, TaskStatus.AWAITING_QA, [task])).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags a PR-less, undocumented drag to completed", () => {
|
||||||
|
const task = buildTask({ pr_number: null, docs_complete: false });
|
||||||
|
const skipped = skippedPreconditions(task, TaskStatus.COMPLETED, [task]);
|
||||||
|
expect(skipped).toContain("no open PR");
|
||||||
|
expect(skipped).toContain("documentation not marked complete");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags non-terminal subtasks when completing a coordination root", () => {
|
||||||
|
const parent = buildTask({ id: "root", pr_number: 7, docs_complete: true });
|
||||||
|
const children = [
|
||||||
|
buildTask({
|
||||||
|
id: "c1",
|
||||||
|
parent_task_id: "root",
|
||||||
|
status: TaskStatus.COMPLETED,
|
||||||
|
}),
|
||||||
|
buildTask({
|
||||||
|
id: "c2",
|
||||||
|
parent_task_id: "root",
|
||||||
|
status: TaskStatus.IN_PROGRESS,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const skipped = skippedPreconditions(parent, TaskStatus.COMPLETED, [
|
||||||
|
parent,
|
||||||
|
...children,
|
||||||
|
]);
|
||||||
|
expect(skipped).toContain("1 non-terminal subtask(s)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fabricate a subtask warning when no children are visible", () => {
|
||||||
|
// The board's task list is team-filtered; a root's children may be in other
|
||||||
|
// teams and absent here. Precision over recall: don't claim "subtasks
|
||||||
|
// terminal" we can't verify — and don't false-alarm either.
|
||||||
|
const parent = buildTask({ id: "root", pr_number: 7, docs_complete: true });
|
||||||
|
expect(
|
||||||
|
skippedPreconditions(parent, TaskStatus.COMPLETED, [parent]),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays silent for benign transitions that skip no material precondition", () => {
|
||||||
|
// pending -> claimed, in_progress -> blocked, etc. don't gate on PR/docs.
|
||||||
|
const task = buildTask({ status: TaskStatus.PENDING });
|
||||||
|
expect(skippedPreconditions(task, TaskStatus.CLAIMED, [task])).toEqual([]);
|
||||||
|
const inProgress = buildTask({ status: TaskStatus.IN_PROGRESS });
|
||||||
|
expect(
|
||||||
|
skippedPreconditions(inProgress, TaskStatus.BLOCKED, [inProgress]),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags a PR-less drag to awaiting_documentation (pass-qa gate needs a PR)", () => {
|
||||||
|
const task = buildTask({ pr_number: null });
|
||||||
|
expect(
|
||||||
|
skippedPreconditions(task, TaskStatus.AWAITING_DOCUMENTATION, [task]),
|
||||||
|
).toContain("no open PR");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags missing docs for a drag to awaiting_pm_review", () => {
|
||||||
|
const task = buildTask({ pr_number: 9, docs_complete: false });
|
||||||
|
const skipped = skippedPreconditions(task, TaskStatus.AWAITING_PM_REVIEW, [
|
||||||
|
task,
|
||||||
|
]);
|
||||||
|
expect(skipped).toEqual(["documentation not marked complete"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { TaskStatus, Team, TaskType, type Task } from "@/types";
|
||||||
|
|
||||||
|
// Capture the board's onDragEnd so the test can synthesize a drop without
|
||||||
|
// driving the real dnd-kit pointer sensor (painful in jsdom).
|
||||||
|
const dragRef = vi.hoisted(() => ({
|
||||||
|
onDragEnd: null as ((e: unknown) => void) | null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dnd-kit/core", () => ({
|
||||||
|
DndContext: ({
|
||||||
|
onDragEnd,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
onDragEnd: (e: unknown) => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) => {
|
||||||
|
dragRef.onDragEnd = onDragEnd;
|
||||||
|
return <>{children}</>;
|
||||||
|
},
|
||||||
|
DragOverlay: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
PointerSensor: () => null,
|
||||||
|
useSensor: () => null,
|
||||||
|
useSensors: () => [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { mutateAsync, refetch, tasksRef } = vi.hoisted(() => ({
|
||||||
|
mutateAsync: vi.fn().mockResolvedValue(undefined),
|
||||||
|
refetch: vi.fn().mockResolvedValue(undefined),
|
||||||
|
tasksRef: { current: [] as Task[] },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-tasks", () => ({
|
||||||
|
useTasks: () => ({ data: tasksRef.current, isLoading: false, refetch }),
|
||||||
|
useTaskLifecycle: () => ({
|
||||||
|
claim: { mutateAsync: vi.fn() },
|
||||||
|
start: { mutateAsync: vi.fn() },
|
||||||
|
block: { mutateAsync: vi.fn() },
|
||||||
|
unblock: { mutateAsync: vi.fn() },
|
||||||
|
pause: { mutateAsync: vi.fn() },
|
||||||
|
resume: { mutateAsync: vi.fn() },
|
||||||
|
verify: { mutateAsync: vi.fn() },
|
||||||
|
submitQa: { mutateAsync: vi.fn() },
|
||||||
|
passQa: { mutateAsync: vi.fn() },
|
||||||
|
failQa: { mutateAsync: vi.fn() },
|
||||||
|
complete: { mutateAsync: vi.fn() },
|
||||||
|
}),
|
||||||
|
useUpdateTask: () => ({ mutateAsync, isPending: false }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Avoid rendering the real columns/cards — the bypass gate lives in the board's
|
||||||
|
// drag handler, not in the column children.
|
||||||
|
vi.mock("../kanban-column", () => ({ KanbanColumn: () => null }));
|
||||||
|
vi.mock("../kanban-card", () => ({ KanbanCard: () => null }));
|
||||||
|
vi.mock("@/components/tasks/task-detail/task-action-dialogs", () => ({
|
||||||
|
RequiredNotesDialog: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { KanbanBoard } from "../kanban-board";
|
||||||
|
|
||||||
|
function buildTask(overrides: Partial<Task> = {}): Task {
|
||||||
|
return {
|
||||||
|
id: "t1",
|
||||||
|
title: "A task",
|
||||||
|
description: "desc",
|
||||||
|
acceptance_criteria: ["a"],
|
||||||
|
status: TaskStatus.IN_PROGRESS,
|
||||||
|
priority: 2,
|
||||||
|
sequence: 0,
|
||||||
|
team: Team.BACKEND,
|
||||||
|
created_by: "ceo",
|
||||||
|
assigned_to: null,
|
||||||
|
parent_task_id: null,
|
||||||
|
dependency_ids: [],
|
||||||
|
blocker_ids: [],
|
||||||
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
|
updated_at: null,
|
||||||
|
claimed_at: null,
|
||||||
|
started_at: null,
|
||||||
|
completed_at: null,
|
||||||
|
target_date: null,
|
||||||
|
estimated_complexity: "M" as never,
|
||||||
|
nature: "feature" as never,
|
||||||
|
task_type: TaskType.CODE,
|
||||||
|
project_id: "p1",
|
||||||
|
docs_complete: false,
|
||||||
|
pr_created: false,
|
||||||
|
pm_approvals: {},
|
||||||
|
plan: null,
|
||||||
|
checkpoints: [],
|
||||||
|
progress_updates: [],
|
||||||
|
commits: [],
|
||||||
|
self_verified: false,
|
||||||
|
qa_verified: null,
|
||||||
|
sessions: [],
|
||||||
|
branch_name: null,
|
||||||
|
pr_number: null,
|
||||||
|
pr_url: null,
|
||||||
|
...overrides,
|
||||||
|
} as unknown as Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLUMNS = [
|
||||||
|
{ id: "pending", status: TaskStatus.PENDING, title: "Pending", color: "" },
|
||||||
|
{
|
||||||
|
id: "in_progress",
|
||||||
|
status: TaskStatus.IN_PROGRESS,
|
||||||
|
title: "In Progress",
|
||||||
|
color: "",
|
||||||
|
},
|
||||||
|
{ id: "completed", status: TaskStatus.COMPLETED, title: "Done", color: "" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function drop(activeId: string, overId: TaskStatus) {
|
||||||
|
dragRef.onDragEnd?.({ active: { id: activeId }, over: { id: overId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("KanbanBoard — admin-override bypass confirmation (F020)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mutateAsync.mockClear();
|
||||||
|
refetch.mockClear();
|
||||||
|
tasksRef.current = [];
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("confirms before a drag that skips lifecycle preconditions, then fires the override on confirm", async () => {
|
||||||
|
tasksRef.current = [
|
||||||
|
buildTask({ id: "t1", status: TaskStatus.IN_PROGRESS, pr_number: null }),
|
||||||
|
];
|
||||||
|
render(<KanbanBoard title="Board" columns={COLUMNS} />);
|
||||||
|
|
||||||
|
// Drag a PR-less task straight to Done — completing with no open PR skips
|
||||||
|
// the in-band gate. The board must NOT fire the override silently; it must
|
||||||
|
// surface what's skipped and wait for an explicit confirm.
|
||||||
|
drop("t1", TaskStatus.COMPLETED);
|
||||||
|
|
||||||
|
// The board holds the move and surfaces what's skipped — it must NOT fire
|
||||||
|
// the admin status-override silently.
|
||||||
|
await screen.findByText(/no open pr/i);
|
||||||
|
expect(
|
||||||
|
screen.getByText(/documentation not marked complete/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(mutateAsync).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Confirm the override — now the admin status-override fires.
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /override & move/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1));
|
||||||
|
expect(mutateAsync).toHaveBeenCalledWith({
|
||||||
|
taskId: "t1",
|
||||||
|
updates: { status: TaskStatus.COMPLETED },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fire the override when the confirmation is cancelled", async () => {
|
||||||
|
tasksRef.current = [
|
||||||
|
buildTask({ id: "t1", status: TaskStatus.IN_PROGRESS, pr_number: null }),
|
||||||
|
];
|
||||||
|
render(<KanbanBoard title="Board" columns={COLUMNS} />);
|
||||||
|
|
||||||
|
drop("t1", TaskStatus.COMPLETED);
|
||||||
|
await screen.findByText(/no open pr/i);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByText(/no open pr/i)).not.toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
expect(mutateAsync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fires the move directly when the drag skips no material precondition", async () => {
|
||||||
|
// pending -> claimed gates on nothing the panel can check — no dialog.
|
||||||
|
tasksRef.current = [
|
||||||
|
buildTask({ id: "t1", status: TaskStatus.PENDING, pr_number: null }),
|
||||||
|
];
|
||||||
|
render(<KanbanBoard title="Board" columns={COLUMNS} />);
|
||||||
|
|
||||||
|
drop("t1", TaskStatus.CLAIMED);
|
||||||
|
|
||||||
|
await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1));
|
||||||
|
expect(mutateAsync).toHaveBeenCalledWith({
|
||||||
|
taskId: "t1",
|
||||||
|
updates: { status: TaskStatus.CLAIMED },
|
||||||
|
});
|
||||||
|
// No bypass confirmation should ever have been surfaced.
|
||||||
|
expect(screen.queryByText(/override & move/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { TaskStatus, type Task } from "@/types";
|
||||||
|
|
||||||
|
// A drag on the operator kanban routes the status move through the admin
|
||||||
|
// status-override, which bypasses the in-band lifecycle validator entirely.
|
||||||
|
// That override is intentional — it's how an operator recovers a task wedged
|
||||||
|
// in a state with no valid in-band move. But it also lets a careless drag skip
|
||||||
|
// material preconditions silently (completing a task with no PR, QA-bypassing
|
||||||
|
// a task with no PR, finishing docs on a task whose docs aren't complete).
|
||||||
|
//
|
||||||
|
// This returns the human-readable preconditions the dragged move would skip,
|
||||||
|
// computed only from what the panel can see reliably on the task (and its
|
||||||
|
// in-list children). Precision over recall: we never claim a precondition is
|
||||||
|
// satisfied when we can't verify it, and we never false-alarm on a transition
|
||||||
|
// that gates on nothing we can check. An empty list means the drag skips no
|
||||||
|
// material precondition the panel can detect — proceed without a prompt.
|
||||||
|
|
||||||
|
// Targets whose in-band entry requires an open PR.
|
||||||
|
const PR_REQUIRED: ReadonlySet<TaskStatus> = new Set([
|
||||||
|
TaskStatus.AWAITING_QA,
|
||||||
|
TaskStatus.AWAITING_DOCUMENTATION,
|
||||||
|
TaskStatus.NEEDS_REVISION,
|
||||||
|
TaskStatus.AWAITING_PR_REVIEW,
|
||||||
|
TaskStatus.AWAITING_CEO_APPROVAL,
|
||||||
|
TaskStatus.COMPLETED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Targets whose in-band entry requires the documentation phase to be complete.
|
||||||
|
const DOCS_REQUIRED: ReadonlySet<TaskStatus> = new Set([
|
||||||
|
TaskStatus.AWAITING_PM_REVIEW,
|
||||||
|
TaskStatus.COMPLETED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Coordination-root targets: the in-band entry requires all subtasks terminal.
|
||||||
|
const SUBTASKS_TERMINAL_REQUIRED: ReadonlySet<TaskStatus> = new Set([
|
||||||
|
TaskStatus.AWAITING_PM_REVIEW,
|
||||||
|
TaskStatus.AWAITING_CEO_APPROVAL,
|
||||||
|
TaskStatus.COMPLETED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const TERMINAL: ReadonlySet<TaskStatus> = new Set([
|
||||||
|
TaskStatus.COMPLETED,
|
||||||
|
TaskStatus.CANCELLED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function skippedPreconditions(
|
||||||
|
task: Task,
|
||||||
|
newStatus: TaskStatus,
|
||||||
|
allTasks: Task[],
|
||||||
|
): string[] {
|
||||||
|
const skipped: string[] = [];
|
||||||
|
|
||||||
|
if (PR_REQUIRED.has(newStatus) && task.pr_number == null) {
|
||||||
|
skipped.push("no open PR");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DOCS_REQUIRED.has(newStatus) && !task.docs_complete) {
|
||||||
|
skipped.push("documentation not marked complete");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The submit-qa gate (verifying -> awaiting_qa) additionally requires
|
||||||
|
// self-verification, at least one linked commit, and a progress update.
|
||||||
|
if (newStatus === TaskStatus.AWAITING_QA) {
|
||||||
|
if (!task.self_verified) skipped.push("not self-verified");
|
||||||
|
if (task.commits.length === 0) skipped.push("no commits linked");
|
||||||
|
if (task.progress_updates.length === 0) skipped.push("no progress updates");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coordination-root targets require every subtask in a terminal state. The
|
||||||
|
// board's task list is team-filtered, so a root's children may live in other
|
||||||
|
// teams and be absent here — only warn on children we can actually see that
|
||||||
|
// are not terminal; never fabricate a "subtasks terminal" claim we can't
|
||||||
|
// verify.
|
||||||
|
if (SUBTASKS_TERMINAL_REQUIRED.has(newStatus)) {
|
||||||
|
const children = allTasks.filter((t) => t.parent_task_id === task.id);
|
||||||
|
const nonTerminal = children.filter((c) => !TERMINAL.has(c.status));
|
||||||
|
if (nonTerminal.length > 0) {
|
||||||
|
skipped.push(`${nonTerminal.length} non-terminal subtask(s)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return skipped;
|
||||||
|
}
|
||||||
@@ -25,6 +25,17 @@ import {
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { KanbanCard } from "./kanban-card";
|
import { KanbanCard } from "./kanban-card";
|
||||||
import { RequiredNotesDialog } from "@/components/tasks/task-detail/task-action-dialogs";
|
import { RequiredNotesDialog } from "@/components/tasks/task-detail/task-action-dialogs";
|
||||||
|
import { skippedPreconditions } from "./bypass-preconditions";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
|
||||||
type NotesActionKind = "pass-qa" | "fail-qa" | "complete";
|
type NotesActionKind = "pass-qa" | "fail-qa" | "complete";
|
||||||
|
|
||||||
@@ -33,6 +44,16 @@ interface PendingNotesAction {
|
|||||||
taskId: string;
|
taskId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A drag that would skip material lifecycle preconditions (completing with no
|
||||||
|
// PR, QA-bypassing, finishing docs that aren't complete, …) is held for an
|
||||||
|
// explicit override confirmation. The admin status-override is intentional
|
||||||
|
// and stays intact — this only makes the bypass visible instead of silent.
|
||||||
|
interface PendingOverride {
|
||||||
|
task: Task;
|
||||||
|
newStatus: TaskStatus;
|
||||||
|
skipped: string[];
|
||||||
|
}
|
||||||
|
|
||||||
interface ColumnConfig {
|
interface ColumnConfig {
|
||||||
id: string;
|
id: string;
|
||||||
status: TaskStatus;
|
status: TaskStatus;
|
||||||
@@ -67,6 +88,8 @@ export function KanbanBoard({
|
|||||||
const [activeTask, setActiveTask] = useState<Task | null>(null);
|
const [activeTask, setActiveTask] = useState<Task | null>(null);
|
||||||
const [pendingNotesAction, setPendingNotesAction] =
|
const [pendingNotesAction, setPendingNotesAction] =
|
||||||
useState<PendingNotesAction | null>(null);
|
useState<PendingNotesAction | null>(null);
|
||||||
|
const [pendingOverride, setPendingOverride] =
|
||||||
|
useState<PendingOverride | null>(null);
|
||||||
const [activeColumnIndex, setActiveColumnIndex] = useState(0);
|
const [activeColumnIndex, setActiveColumnIndex] = useState(0);
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
@@ -125,6 +148,19 @@ export function KanbanBoard({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A drag on this board routes the status move through the admin
|
||||||
|
// status-override, which bypasses the in-band lifecycle validator. When
|
||||||
|
// that override would skip material preconditions the panel can detect
|
||||||
|
// (no open PR, docs not complete, not self-verified, non-terminal
|
||||||
|
// subtasks, …), hold the move for an explicit confirmation that surfaces
|
||||||
|
// exactly what's being skipped. The override stays — this only makes the
|
||||||
|
// bypass visible instead of silent.
|
||||||
|
const skipped = skippedPreconditions(task, newStatus, tasks || []);
|
||||||
|
if (skipped.length > 0) {
|
||||||
|
setPendingOverride({ task, newStatus, skipped });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateTask.mutateAsync({
|
await updateTask.mutateAsync({
|
||||||
taskId,
|
taskId,
|
||||||
@@ -137,6 +173,23 @@ export function KanbanBoard({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOverrideConfirm = async () => {
|
||||||
|
if (!pendingOverride) return;
|
||||||
|
const { task, newStatus } = pendingOverride;
|
||||||
|
try {
|
||||||
|
await updateTask.mutateAsync({
|
||||||
|
taskId: task.id,
|
||||||
|
updates: { status: newStatus },
|
||||||
|
});
|
||||||
|
toast.success(`Task moved to ${newStatus.replace(/_/g, " ")}`);
|
||||||
|
refetch();
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to move task");
|
||||||
|
} finally {
|
||||||
|
setPendingOverride(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleAction = async (action: string, taskId: string) => {
|
const handleAction = async (action: string, taskId: string) => {
|
||||||
try {
|
try {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
@@ -381,6 +434,37 @@ export function KanbanBoard({
|
|||||||
{...notesDialogConfig[pendingNotesAction.kind]}
|
{...notesDialogConfig[pendingNotesAction.kind]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={pendingOverride !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setPendingOverride(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
Override lifecycle preconditions?
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This move routes through the admin status-override, which skips
|
||||||
|
the in-band lifecycle gate. The following preconditions would be
|
||||||
|
skipped:
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<ul className="text-sm text-muted-foreground list-disc pl-6 space-y-1">
|
||||||
|
{pendingOverride?.skipped.map((item) => (
|
||||||
|
<li key={item}>{item}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleOverrideConfirm}>
|
||||||
|
Override & move
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user