feat: implement task dependencies with validation and update handling

This commit is contained in:
Abhimanyu Saharan
2026-02-07 00:21:44 +05:30
parent 8970ee6742
commit 4bab455912
34 changed files with 1241 additions and 157 deletions

View File

@@ -0,0 +1,11 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
export interface BlockedTaskDetail {
blocked_by_task_ids?: string[];
message: string;
}

View File

@@ -0,0 +1,11 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
import type { BlockedTaskDetail } from "./blockedTaskDetail";
export interface BlockedTaskError {
detail: BlockedTaskDetail;
}

View File

@@ -29,6 +29,8 @@ export * from "./approvalReadPayload";
export * from "./approvalReadRubricScores";
export * from "./approvalReadStatus";
export * from "./approvalUpdate";
export * from "./blockedTaskDetail";
export * from "./blockedTaskError";
export * from "./boardCreate";
export * from "./boardCreateSuccessMetrics";
export * from "./boardMemoryCreate";

View File

@@ -11,13 +11,16 @@ export interface TaskCardRead {
approvals_pending_count?: number;
assigned_agent_id?: string | null;
assignee?: string | null;
blocked_by_task_ids?: string[];
board_id: string | null;
created_at: string;
created_by_user_id: string | null;
depends_on_task_ids?: string[];
description?: string | null;
due_at?: string | null;
id: string;
in_progress_at: string | null;
is_blocked?: boolean;
priority?: string;
status?: TaskCardReadStatus;
title: string;

View File

@@ -9,6 +9,7 @@ import type { TaskCreateStatus } from "./taskCreateStatus";
export interface TaskCreate {
assigned_agent_id?: string | null;
created_by_user_id?: string | null;
depends_on_task_ids?: string[];
description?: string | null;
due_at?: string | null;
priority?: string;

View File

@@ -8,13 +8,16 @@ import type { TaskReadStatus } from "./taskReadStatus";
export interface TaskRead {
assigned_agent_id?: string | null;
blocked_by_task_ids?: string[];
board_id: string | null;
created_at: string;
created_by_user_id: string | null;
depends_on_task_ids?: string[];
description?: string | null;
due_at?: string | null;
id: string;
in_progress_at: string | null;
is_blocked?: boolean;
priority?: string;
status?: TaskReadStatus;
title: string;

View File

@@ -8,6 +8,7 @@
export interface TaskUpdate {
assigned_agent_id?: string | null;
comment?: string | null;
depends_on_task_ids?: string[] | null;
description?: string | null;
due_at?: string | null;
priority?: string | null;

View File

@@ -21,6 +21,7 @@ import type {
} from "@tanstack/react-query";
import type {
BlockedTaskError,
HTTPValidationError,
LimitOffsetPageTypeVarCustomizedTaskCommentRead,
LimitOffsetPageTypeVarCustomizedTaskRead,
@@ -278,6 +279,11 @@ export type createTaskApiV1BoardsBoardIdTasksPostResponse200 = {
status: 200;
};
export type createTaskApiV1BoardsBoardIdTasksPostResponse409 = {
data: BlockedTaskError;
status: 409;
};
export type createTaskApiV1BoardsBoardIdTasksPostResponse422 = {
data: HTTPValidationError;
status: 422;
@@ -287,10 +293,12 @@ export type createTaskApiV1BoardsBoardIdTasksPostResponseSuccess =
createTaskApiV1BoardsBoardIdTasksPostResponse200 & {
headers: Headers;
};
export type createTaskApiV1BoardsBoardIdTasksPostResponseError =
createTaskApiV1BoardsBoardIdTasksPostResponse422 & {
headers: Headers;
};
export type createTaskApiV1BoardsBoardIdTasksPostResponseError = (
| createTaskApiV1BoardsBoardIdTasksPostResponse409
| createTaskApiV1BoardsBoardIdTasksPostResponse422
) & {
headers: Headers;
};
export type createTaskApiV1BoardsBoardIdTasksPostResponse =
| createTaskApiV1BoardsBoardIdTasksPostResponseSuccess
@@ -319,7 +327,7 @@ export const createTaskApiV1BoardsBoardIdTasksPost = async (
};
export const getCreateTaskApiV1BoardsBoardIdTasksPostMutationOptions = <
TError = HTTPValidationError,
TError = BlockedTaskError | HTTPValidationError,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
@@ -361,13 +369,14 @@ export type CreateTaskApiV1BoardsBoardIdTasksPostMutationResult = NonNullable<
>;
export type CreateTaskApiV1BoardsBoardIdTasksPostMutationBody = TaskCreate;
export type CreateTaskApiV1BoardsBoardIdTasksPostMutationError =
HTTPValidationError;
| BlockedTaskError
| HTTPValidationError;
/**
* @summary Create Task
*/
export const useCreateTaskApiV1BoardsBoardIdTasksPost = <
TError = HTTPValidationError,
TError = BlockedTaskError | HTTPValidationError,
TContext = unknown,
>(
options?: {
@@ -776,6 +785,11 @@ export type updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponse200 = {
status: 200;
};
export type updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponse409 = {
data: BlockedTaskError;
status: 409;
};
export type updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponse422 = {
data: HTTPValidationError;
status: 422;
@@ -785,10 +799,12 @@ export type updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponseSuccess =
updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponse200 & {
headers: Headers;
};
export type updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponseError =
updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponse422 & {
headers: Headers;
};
export type updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponseError = (
| updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponse409
| updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponse422
) & {
headers: Headers;
};
export type updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponse =
| updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponseSuccess
@@ -819,7 +835,7 @@ export const updateTaskApiV1BoardsBoardIdTasksTaskIdPatch = async (
};
export const getUpdateTaskApiV1BoardsBoardIdTasksTaskIdPatchMutationOptions = <
TError = HTTPValidationError,
TError = BlockedTaskError | HTTPValidationError,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
@@ -868,13 +884,14 @@ export type UpdateTaskApiV1BoardsBoardIdTasksTaskIdPatchMutationResult =
export type UpdateTaskApiV1BoardsBoardIdTasksTaskIdPatchMutationBody =
TaskUpdate;
export type UpdateTaskApiV1BoardsBoardIdTasksTaskIdPatchMutationError =
HTTPValidationError;
| BlockedTaskError
| HTTPValidationError;
/**
* @summary Update Task
*/
export const useUpdateTaskApiV1BoardsBoardIdTasksTaskIdPatch = <
TError = HTTPValidationError,
TError = BlockedTaskError | HTTPValidationError,
TContext = unknown,
>(
options?: {

View File

@@ -23,6 +23,9 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import DropdownSelect, {
type DropdownSelectOption,
} from "@/components/ui/dropdown-select";
import {
Select,
SelectContent,
@@ -406,6 +409,9 @@ export default function BoardDetailPage() {
const [editStatus, setEditStatus] = useState<TaskStatus>("inbox");
const [editPriority, setEditPriority] = useState("medium");
const [editAssigneeId, setEditAssigneeId] = useState("");
const [editDependsOnTaskIds, setEditDependsOnTaskIds] = useState<string[]>(
[],
);
const [isSavingTask, setIsSavingTask] = useState(false);
const [saveTaskError, setSaveTaskError] = useState<string | null>(null);
@@ -796,6 +802,7 @@ export default function BoardDetailPage() {
setEditStatus("inbox");
setEditPriority("medium");
setEditAssigneeId("");
setEditDependsOnTaskIds([]);
setSaveTaskError(null);
return;
}
@@ -804,6 +811,7 @@ export default function BoardDetailPage() {
setEditStatus(selectedTask.status);
setEditPriority(selectedTask.priority);
setEditAssigneeId(selectedTask.assigned_agent_id ?? "");
setEditDependsOnTaskIds(selectedTask.depends_on_task_ids ?? []);
setSaveTaskError(null);
}, [selectedTask]);
@@ -1165,6 +1173,14 @@ export default function BoardDetailPage() {
return map;
}, [tasks]);
const taskById = useMemo(() => {
const map = new Map<string, Task>();
tasks.forEach((task) => {
map.set(task.id, task);
});
return map;
}, [tasks]);
const orderedLiveFeed = useMemo(() => {
return [...liveFeed].sort((a, b) => {
const aTime = new Date(a.created_at).getTime();
@@ -1178,21 +1194,51 @@ export default function BoardDetailPage() {
[agents],
);
const dependencyOptions = useMemo<DropdownSelectOption[]>(() => {
if (!selectedTask) return [];
const alreadySelected = new Set(editDependsOnTaskIds);
return tasks
.filter((task) => task.id !== selectedTask.id)
.map((task) => ({
value: task.id,
label: `${task.title} (${task.status.replace(/_/g, " ")})`,
disabled: alreadySelected.has(task.id),
}));
}, [editDependsOnTaskIds, selectedTask, tasks]);
const addTaskDependency = useCallback((dependencyId: string) => {
setEditDependsOnTaskIds((prev) =>
prev.includes(dependencyId) ? prev : [...prev, dependencyId],
);
}, []);
const removeTaskDependency = useCallback((dependencyId: string) => {
setEditDependsOnTaskIds((prev) =>
prev.filter((value) => value !== dependencyId),
);
}, []);
const hasTaskChanges = useMemo(() => {
if (!selectedTask) return false;
const normalizedTitle = editTitle.trim();
const normalizedDescription = editDescription.trim();
const currentDescription = (selectedTask.description ?? "").trim();
const currentAssignee = selectedTask.assigned_agent_id ?? "";
const currentDeps = [...(selectedTask.depends_on_task_ids ?? [])]
.sort()
.join("|");
const nextDeps = [...editDependsOnTaskIds].sort().join("|");
return (
normalizedTitle !== selectedTask.title ||
normalizedDescription !== currentDescription ||
editStatus !== selectedTask.status ||
editPriority !== selectedTask.priority ||
editAssigneeId !== currentAssignee
editAssigneeId !== currentAssignee ||
currentDeps !== nextDeps
);
}, [
editAssigneeId,
editDependsOnTaskIds,
editDescription,
editPriority,
editStatus,
@@ -1348,18 +1394,49 @@ export default function BoardDetailPage() {
setIsSavingTask(true);
setSaveTaskError(null);
try {
const currentDeps = [...(selectedTask.depends_on_task_ids ?? [])]
.sort()
.join("|");
const nextDeps = [...editDependsOnTaskIds].sort().join("|");
const depsChanged = currentDeps !== nextDeps;
const updatePayload: Parameters<
typeof updateTaskApiV1BoardsBoardIdTasksTaskIdPatch
>[2] = {
title: trimmedTitle,
description: editDescription.trim() || null,
status: editStatus,
priority: editPriority,
assigned_agent_id: editAssigneeId || null,
};
if (depsChanged && selectedTask.status !== "done") {
updatePayload.depends_on_task_ids = editDependsOnTaskIds;
}
const result = await updateTaskApiV1BoardsBoardIdTasksTaskIdPatch(
boardId,
selectedTask.id,
{
title: trimmedTitle,
description: editDescription.trim() || null,
status: editStatus,
priority: editPriority,
assigned_agent_id: editAssigneeId || null,
},
updatePayload,
);
if (result.status !== 200) throw new Error("Unable to update task.");
if (result.status === 409) {
const blockedIds = result.data.detail.blocked_by_task_ids ?? [];
const blockedTitles = blockedIds
.map((id) => taskTitleById.get(id) ?? id)
.join(", ");
setSaveTaskError(
blockedTitles
? `${result.data.detail.message} Blocked by: ${blockedTitles}`
: result.data.detail.message,
);
return;
}
if (result.status === 422) {
setSaveTaskError(
result.data.detail?.[0]?.msg ?? "Validation error while saving task.",
);
return;
}
const previous =
tasksRef.current.find((task) => task.id === selectedTask.id) ??
selectedTask;
@@ -1393,6 +1470,7 @@ export default function BoardDetailPage() {
setEditStatus(selectedTask.status);
setEditPriority(selectedTask.priority);
setEditAssigneeId(selectedTask.assigned_agent_id ?? "");
setEditDependsOnTaskIds(selectedTask.depends_on_task_ids ?? []);
setSaveTaskError(null);
};
@@ -1422,6 +1500,10 @@ export default function BoardDetailPage() {
if (!isSignedIn || !boardId) return;
const currentTask = tasksRef.current.find((task) => task.id === taskId);
if (!currentTask || currentTask.status === status) return;
if (currentTask.is_blocked && status !== "inbox") {
setError("Task is blocked by incomplete dependencies.");
return;
}
const previousTasks = tasksRef.current;
setTasks((prev) =>
prev.map((task) =>
@@ -1442,7 +1524,22 @@ export default function BoardDetailPage() {
taskId,
{ status },
);
if (result.status !== 200) throw new Error("Unable to move task.");
if (result.status === 409) {
const blockedIds = result.data.detail.blocked_by_task_ids ?? [];
const blockedTitles = blockedIds
.map((id) => taskTitleById.get(id) ?? id)
.join(", ");
throw new Error(
blockedTitles
? `${result.data.detail.message} Blocked by: ${blockedTitles}`
: result.data.detail.message,
);
}
if (result.status === 422) {
throw new Error(
result.data.detail?.[0]?.msg ?? "Validation error while moving task.",
);
}
const assignee = result.data.assigned_agent_id
? agentsRef.current.find((agent) => agent.id === result.data.assigned_agent_id)
?.name ?? null
@@ -1461,7 +1558,7 @@ export default function BoardDetailPage() {
setTasks(previousTasks);
setError(err instanceof Error ? err.message : "Unable to move task.");
}
}, [boardId, isSignedIn]);
}, [boardId, isSignedIn, taskTitleById]);
const agentInitials = (agent: Agent) =>
agent.name
@@ -1980,6 +2077,68 @@ export default function BoardDetailPage() {
<p className="text-sm text-slate-500">No description provided.</p>
)}
</div>
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Dependencies
</p>
{selectedTask?.depends_on_task_ids?.length ? (
<div className="space-y-2">
{selectedTask.depends_on_task_ids.map((depId) => {
const depTask = taskById.get(depId);
const title = depTask?.title ?? depId;
const statusLabel = depTask?.status
? depTask.status.replace(/_/g, " ")
: "unknown";
const isDone = depTask?.status === "done";
const isBlocking = (
selectedTask.blocked_by_task_ids ?? []
).includes(depId);
return (
<button
key={depId}
type="button"
onClick={() => openComments({ id: depId })}
disabled={!depTask}
className={cn(
"w-full rounded-lg border px-3 py-2 text-left transition",
isBlocking
? "border-rose-200 bg-rose-50 hover:bg-rose-100/40"
: isDone
? "border-emerald-200 bg-emerald-50 hover:bg-emerald-100/40"
: "border-slate-200 bg-white hover:bg-slate-50",
!depTask && "cursor-not-allowed opacity-60",
)}
>
<div className="flex items-center justify-between gap-3">
<p className="truncate text-sm font-medium text-slate-900">
{title}
</p>
<span
className={cn(
"text-[10px] font-semibold uppercase tracking-wide",
isBlocking
? "text-rose-700"
: isDone
? "text-emerald-700"
: "text-slate-500",
)}
>
{statusLabel}
</span>
</div>
</button>
);
})}
</div>
) : (
<p className="text-sm text-slate-500">No dependencies.</p>
)}
{selectedTask?.is_blocked ? (
<div className="rounded-lg border border-rose-200 bg-rose-50 p-3 text-xs text-rose-700">
Blocked by incomplete dependencies.
</div>
) : null}
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
@@ -2333,6 +2492,73 @@ export default function BoardDetailPage() {
</p>
) : null}
</div>
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Dependencies
</label>
<p className="text-xs text-slate-500">
Tasks stay blocked until every dependency is marked done.
</p>
<DropdownSelect
ariaLabel="Add dependency"
placeholder="Add dependency"
options={dependencyOptions}
onValueChange={addTaskDependency}
disabled={
!selectedTask ||
isSavingTask ||
selectedTask.status === "done"
}
emptyMessage="No other tasks found."
/>
{selectedTask?.status === "done" ? (
<p className="text-xs text-slate-500">
Dependencies can only be edited until the task is done.
</p>
) : null}
{editDependsOnTaskIds.length === 0 ? (
<p className="text-xs text-slate-500">No dependencies.</p>
) : (
<div className="flex flex-wrap gap-2">
{editDependsOnTaskIds.map((depId) => {
const depTask = taskById.get(depId);
const label = depTask?.title ?? depId;
const statusLabel = depTask?.status
? depTask.status.replace(/_/g, " ")
: null;
const isDone = depTask?.status === "done";
return (
<span
key={depId}
className={cn(
"inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs",
isDone
? "border-emerald-200 bg-emerald-50 text-emerald-800"
: "border-slate-200 bg-slate-50 text-slate-700",
)}
>
<span className="max-w-[18rem] truncate">{label}</span>
{statusLabel ? (
<span className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">
{statusLabel}
</span>
) : null}
{selectedTask?.status !== "done" ? (
<button
type="button"
onClick={() => removeTaskDependency(depId)}
className="rounded-full p-0.5 text-slate-500 transition hover:bg-white hover:text-slate-700"
aria-label="Remove dependency"
>
<X className="h-3 w-3" />
</button>
) : null}
</span>
);
})}
</div>
)}
</div>
{saveTaskError ? (
<div className="rounded-lg border border-slate-200 bg-white p-3 text-xs text-slate-600">
{saveTaskError}

View File

@@ -8,6 +8,8 @@ interface TaskCardProps {
assignee?: string;
due?: string;
approvalsPendingCount?: number;
isBlocked?: boolean;
blockedByCount?: number;
onClick?: () => void;
draggable?: boolean;
isDragging?: boolean;
@@ -21,6 +23,8 @@ export function TaskCard({
assignee,
due,
approvalsPendingCount = 0,
isBlocked = false,
blockedByCount = 0,
onClick,
draggable = false,
isDragging = false,
@@ -28,6 +32,11 @@ export function TaskCard({
onDragEnd,
}: TaskCardProps) {
const hasPendingApproval = approvalsPendingCount > 0;
const leftBarClassName = isBlocked
? "bg-rose-400"
: hasPendingApproval
? "bg-amber-400"
: null;
const priorityBadge = (value?: string) => {
if (!value) return null;
const normalized = value.toLowerCase();
@@ -51,6 +60,7 @@ export function TaskCard({
"group relative cursor-pointer rounded-lg border border-slate-200 bg-white p-4 shadow-sm transition-all hover:-translate-y-0.5 hover:border-slate-300 hover:shadow-md",
isDragging && "opacity-60 shadow-none",
hasPendingApproval && "border-amber-200 bg-amber-50/40",
isBlocked && "border-rose-200 bg-rose-50/50",
)}
draggable={draggable}
onDragStart={onDragStart}
@@ -65,12 +75,23 @@ export function TaskCard({
}
}}
>
{hasPendingApproval ? (
<span className="absolute left-0 top-0 h-full w-1 rounded-l-lg bg-amber-400" />
{leftBarClassName ? (
<span
className={cn(
"absolute left-0 top-0 h-full w-1 rounded-l-lg",
leftBarClassName,
)}
/>
) : null}
<div className="flex items-start justify-between gap-3">
<div className="space-y-2">
<p className="text-sm font-medium text-slate-900">{title}</p>
{isBlocked ? (
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wide text-rose-700">
<span className="h-1.5 w-1.5 rounded-full bg-rose-500" />
Blocked{blockedByCount > 0 ? ` · ${blockedByCount}` : ""}
</div>
) : null}
{hasPendingApproval ? (
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wide text-amber-700">
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" />

View File

@@ -17,6 +17,9 @@ type Task = {
assigned_agent_id?: string | null;
assignee?: string | null;
approvals_pending_count?: number;
depends_on_task_ids?: string[];
blocked_by_task_ids?: string[];
is_blocked?: boolean;
};
type TaskBoardProps = {
@@ -253,6 +256,10 @@ export const TaskBoard = memo(function TaskBoard({
const handleDragStart =
(task: Task) => (event: React.DragEvent<HTMLDivElement>) => {
if (task.is_blocked) {
event.preventDefault();
return;
}
setDraggingId(task.id);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData(
@@ -342,8 +349,10 @@ export const TaskBoard = memo(function TaskBoard({
assignee={task.assignee ?? undefined}
due={formatDueDate(task.due_at)}
approvalsPendingCount={task.approvals_pending_count}
isBlocked={task.is_blocked}
blockedByCount={task.blocked_by_task_ids?.length ?? 0}
onClick={() => onTaskSelect?.(task)}
draggable
draggable={!task.is_blocked}
isDragging={draggingId === task.id}
onDragStart={handleDragStart(task)}
onDragEnd={handleDragEnd}