Files
fusion/packages/dashboard/app/components/boardCanDropTask.ts
gsxdsm e57db86a5c FN-6029: restore board drop-check regression coverage
Restore Board drag-drop pre-check coverage by extracting the decision logic into a reusable helper.

- extract Board canDropTask rejection logic into a shared helper used by the component
- replace Lane-mocking coverage with direct helper tests for workflow, column, and capacity branches
- add regression cases for missing workflows, default-workflow fallback, same-column WIP drops, and cross-workflow occupancy handling

Files changed:
 packages/dashboard/app/components/Board.tsx        |  37 +--
 .../__tests__/Board.canDropTask.test.tsx           | 261 ++++++++++++---------
 .../dashboard/app/components/boardCanDropTask.ts   |  56 +++++
 3 files changed, 218 insertions(+), 136 deletions(-)

Fusion-Task-Id: FN-6029

Fusion-Task-Lineage: cd71bbc5-0f97-49c3-9eef-0dd8221341c0
2026-06-08 13:32:58 -07:00

57 lines
2.0 KiB
TypeScript

import type { Task } from "@fusion/core";
import type { BoardWorkflowsPayload } from "../api";
export interface BoardCanDropTaskInput {
boardWorkflows: BoardWorkflowsPayload | null | undefined;
tasks: Task[];
maxConcurrent: number;
taskId: string;
targetColumnId: string;
laneWorkflowId: string;
}
/**
* Canonical Board drag pre-check (R17). Deterministic rejections return a stable
* i18n message key; `null` means the pre-check allows the drop or cannot decide.
*/
export function getBoardCanDropTaskRejection({
boardWorkflows,
tasks,
maxConcurrent,
taskId,
targetColumnId,
laneWorkflowId,
}: BoardCanDropTaskInput): string | null {
if (!boardWorkflows) return null;
const sourceTask = tasks.find((task) => task.id === taskId);
if (!sourceTask) return null;
const sourceWorkflowId = boardWorkflows.taskWorkflowIds[taskId] ?? boardWorkflows.defaultWorkflowId;
// Cross-lane drag never switches workflows (R17).
if (sourceWorkflowId !== laneWorkflowId) {
return "board.rejection.workflowMismatch";
}
const workflow = boardWorkflows.workflows.find((candidate) => candidate.id === laneWorkflowId);
if (!workflow) return null;
const targetColumn = workflow.columns.find((column) => column.id === targetColumnId);
if (!targetColumn) return "board.rejection.unknownColumn";
// Capacity pre-check: a wip-flagged column that is already full rejects.
if (targetColumn.flags.countsTowardWip) {
const occupants = tasks.filter(
(task) => task.column === targetColumnId
&& (boardWorkflows.taskWorkflowIds[task.id] ?? boardWorkflows.defaultWorkflowId) === laneWorkflowId,
).length;
// The default workflow's in-progress limit is maxConcurrent; custom limits
// are enforced authoritatively server-side (the 409 fallback still snaps back).
if (Number.isFinite(maxConcurrent) && maxConcurrent > 0 && sourceTask.column !== targetColumnId && occupants >= maxConcurrent) {
return "board.rejection.capacityExhausted";
}
}
return null;
}