Files
kandev/apps/web/components/kanban/graph2-task-pipeline-step-run.tsx
2026-09-12 16:37:27 +01:00

244 lines
8.9 KiB
TypeScript

"use client";
import { useEffect, useRef } from "react";
import { cn } from "@kandev/ui/lib/utils";
import { Graph2Connector } from "./graph2-connector";
import { Graph2StepNode, Graph2UnassignedStepMarker } from "./graph2-step-node";
import { isOrphanMoveTarget } from "./swimlane-kanban-content";
import type { Task } from "@/components/kanban-card";
import type { WorkflowStep } from "@/components/kanban-column";
type ConnectorType = "past" | "transition" | "future";
function getStepPhase(index: number, currentStepIndex: number): "past" | "current" | "future" {
if (index < currentStepIndex) return "past";
if (index === currentStepIndex) return "current";
return "future";
}
function getConnectorType(
phase: "past" | "current" | "future",
nextPhase: "past" | "current" | "future",
): ConnectorType {
if (phase === "past" && nextPhase === "past") return "past";
if (phase === "future" && nextPhase === "future") return "future";
return "transition";
}
export type StepAdjacency = {
hasPrev: boolean;
prevStepId?: string;
hasNext: boolean;
nextStepId?: string;
};
export type StepMoveTargets = StepAdjacency & {
prevStepTitle?: string;
prevStepHidden: boolean;
nextStepTitle?: string;
nextStepHidden: boolean;
};
/**
* Computes the prev/next move targets for the node at `index`. The synthetic
* "Needs Reassignment" node is display-only: it marks where an orphaned task
* currently sits, but is never itself a valid move destination (there is no
* backing workflow step to move into), so it is excluded as an adjacency
* target in either direction.
*/
export function getStepAdjacency(steps: WorkflowStep[], index: number): StepAdjacency {
const hasPrev = index > 0 && !isOrphanMoveTarget(steps[index - 1].id);
const hasNext = index < steps.length - 1 && !isOrphanMoveTarget(steps[index + 1].id);
return {
hasPrev,
prevStepId: hasPrev ? steps[index - 1].id : undefined,
hasNext,
nextStepId: hasNext ? steps[index + 1].id : undefined,
};
}
export function getStepAdjacencyForStep(
moveTargetSteps: WorkflowStep[],
stepId: string,
): StepAdjacency {
const index = moveTargetSteps.findIndex((step) => step.id === stepId);
if (index < 0) return { hasPrev: false, hasNext: false };
return getStepAdjacency(moveTargetSteps, index);
}
/**
* Excludes the synthetic orphan step from the steps handed to the shared task
* menu. The step-pill chevrons need the orphan present so the current step's
* own adjacency can be computed against it, but the shared menu's move-to-step
* submenu turns every entry it receives into a selectable destination, and the
* orphan is display-only: it has no backing workflow step to move into.
*/
export function excludeOrphanFromMoveMenu(moveTargetSteps: WorkflowStep[]): WorkflowStep[] {
return moveTargetSteps.filter((step) => !isOrphanMoveTarget(step.id));
}
export function getStepMoveTargets(
visibleSteps: WorkflowStep[],
moveTargetSteps: WorkflowStep[],
stepId: string,
): StepMoveTargets;
export function getStepMoveTargets(
moveTargetSteps: WorkflowStep[],
stepId: string,
): StepMoveTargets;
export function getStepMoveTargets(
visibleStepsOrMoveTargetSteps: WorkflowStep[],
moveTargetStepsOrStepId: WorkflowStep[] | string,
maybeStepId?: string,
): StepMoveTargets {
const legacySignature = typeof moveTargetStepsOrStepId === "string";
const visibleSteps = visibleStepsOrMoveTargetSteps;
const moveTargetSteps = legacySignature ? visibleStepsOrMoveTargetSteps : moveTargetStepsOrStepId;
const stepId = legacySignature ? moveTargetStepsOrStepId : maybeStepId;
if (typeof stepId !== "string" || typeof moveTargetSteps === "string") {
return {
hasPrev: false,
hasNext: false,
prevStepHidden: false,
nextStepHidden: false,
};
}
const adjacency = getStepAdjacencyForStep(moveTargetSteps, stepId);
const prevStep = moveTargetSteps.find((step) => step.id === adjacency.prevStepId);
const nextStep = moveTargetSteps.find((step) => step.id === adjacency.nextStepId);
const visibleStepIds = new Set(visibleSteps.map((step) => step.id));
return {
...adjacency,
prevStepTitle: prevStep?.title,
prevStepHidden: !!adjacency.prevStepId && !visibleStepIds.has(adjacency.prevStepId),
nextStepTitle: nextStep?.title,
nextStepHidden: !!adjacency.nextStepId && !visibleStepIds.has(adjacency.nextStepId),
};
}
type HorizontalRect = { left: number; right: number };
/**
* The scrollLeft adjustment that brings `nodeRect` fully within `containerRect`
* horizontally, without moving it further than necessary. Zero when the node
* is already within bounds.
*/
const MOVE_CONTROL_OVERHANG_PX = 12;
export function computeAnchorScrollDelta(
nodeRect: HorizontalRect,
containerRect: HorizontalRect,
): number {
if (nodeRect.left < containerRect.left) return nodeRect.left - containerRect.left;
if (nodeRect.right > containerRect.right) return nodeRect.right - containerRect.right;
return 0;
}
export function PipelineStepNodes({
steps,
moveTargetSteps,
currentStepIndex,
task,
onMoveTask,
isMoving,
atTerminus,
}: {
steps: WorkflowStep[];
moveTargetSteps: WorkflowStep[];
currentStepIndex: number;
task: Task;
onMoveTask: (task: Task, targetStepId: string) => void;
isMoving?: boolean;
atTerminus: boolean;
}) {
// A task whose `workflowStepId` matches no displayed step
// (currentStepIndex === -1) gets one synthetic labelled marker before the
// run, so the row keeps its single-label invariant. An empty `steps` list
// is a distinct case and renders no run at all.
const showUnassignedMarker = currentStepIndex === -1 && steps.length > 0;
const currentNodeRef = useRef<HTMLDivElement>(null);
// Every step keeps its own labelled pill (no dot-collapsing), so a
// many-step run routinely overflows its lane. When it does, the current
// step must stay the anchor: scroll it into the lane's visible bounds so
// past steps are what falls off-screen, never the step the task is
// actually on. This walks scrollLeft on the lane's own scroll container
// directly rather than calling scrollIntoView, which also nudges every
// vertically scrollable ancestor (the task list, the page) toward whichever
// row's effect happens to run last.
useEffect(() => {
const node = currentNodeRef.current;
const scrollRoot = node?.closest<HTMLElement>('[data-testid="pipeline-row-overflow-region"]');
if (!node || !scrollRoot) return;
let container: HTMLElement | null = node.parentElement;
while (container && container !== scrollRoot.parentElement) {
if (container.scrollWidth > container.clientWidth) break;
container = container.parentElement;
}
if (!container) return;
// MoveButton (graph2-step-node.tsx) overhangs its step pill by
// `-left-3`/`-right-3` (12px); a bounding rect never includes an
// absolutely-positioned descendant's overflow, so anchoring against the
// pill's own rect can still leave the move control clipped past the edge.
const rawRect = node.getBoundingClientRect();
const nodeRect = {
left: rawRect.left - MOVE_CONTROL_OVERHANG_PX,
right: rawRect.right + MOVE_CONTROL_OVERHANG_PX,
};
const delta = computeAnchorScrollDelta(nodeRect, container.getBoundingClientRect());
if (delta !== 0) container.scrollLeft += delta;
}, [task.id, currentStepIndex, steps, atTerminus]);
return (
<div
className={cn(
"flex items-center gap-0",
atTerminus ? "shrink-0" : "min-w-0 flex-1 overflow-x-auto scrollbar-hide",
)}
data-testid="pipeline-step-run-scroll"
>
{showUnassignedMarker && (
<div className="flex items-center">
<Graph2UnassignedStepMarker />
<Graph2Connector type="future" />
</div>
)}
{steps.map((step, index) => {
const phase = getStepPhase(index, currentStepIndex);
const hasConnector = index < steps.length - 1;
const connectorType = hasConnector
? getConnectorType(phase, getStepPhase(index + 1, currentStepIndex))
: null;
const moveTargets = getStepMoveTargets(steps, moveTargetSteps, step.id);
return (
<div
key={step.id}
ref={phase === "current" ? currentNodeRef : undefined}
className="flex items-center"
>
<Graph2StepNode
step={step}
phase={phase}
task={task}
hasPrev={moveTargets.hasPrev}
hasNext={moveTargets.hasNext}
prevStepId={moveTargets.prevStepId}
nextStepId={moveTargets.nextStepId}
prevStepTitle={moveTargets.prevStepTitle}
nextStepTitle={moveTargets.nextStepTitle}
prevStepHidden={moveTargets.prevStepHidden}
nextStepHidden={moveTargets.nextStepHidden}
onMoveTask={onMoveTask}
isMoving={isMoving}
/>
{connectorType && <Graph2Connector type={connectorType} />}
</div>
);
})}
</div>
);
}