All files / src/components/dag DagView.tsx

82.6% Statements 19/23
40% Branches 2/5
57.14% Functions 4/7
81.81% Lines 18/22

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127                                      3x                         3x     3x                     6x   6x 6x       6x     6x 6x 6x 6x 66x   6x     6x               6x               6x 3x                         3x                                                              
import { useCallback, useMemo, type JSX, type MouseEvent } from "react";
import {
  ReactFlow,
  Background,
  Controls,
  MiniMap,
  BackgroundVariant,
  type NodeTypes,
  type Node,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import type { TaskData } from "../../hooks/types.js";
import { useDagLayout, type TaskNodeData } from "./useDagLayout.js";
import { TaskNode } from "./TaskNode.js";
import { taskUrl, newTaskUrl, useAppNavigate } from "../../utils/navigation.js";
import { STATUS_CSS_VAR_MAP } from "../../utils/taskStatus.js";
import styles from "./DagView.module.scss";
 
/** Stable fitView options — inline objects trigger React Flow's StoreUpdater cascade under React 19. */
const FIT_VIEW_OPTIONS: { padding: number } = { padding: 0.2 };
 
/** Props for the DagView component. */
interface Props {
  workspaceId: string;
  environmentId: string;
  /** All tasks — filtered internally by workspaceId. */
  tasks: TaskData[];
  /** Resolved theme ID, used to recompute CSS variable colors for the MiniMap. */
  resolvedThemeId: string;
}
 
/** CSS variable mapping for MiniMap node coloring by task status. */
const STATUS_VAR_MAP: Record<string, string> = STATUS_CSS_VAR_MAP;
 
/** Custom node type registry for React Flow. */
const nodeTypes: NodeTypes = {
  task: TaskNode,
};
 
/** Interactive DAG visualization of task hierarchy and dependency relationships. */
export function DagView({
  workspaceId,
  environmentId,
  tasks,
  resolvedThemeId,
}: Props): JSX.Element {
  const navigate = useAppNavigate();
 
  const workspaceTasks = useMemo(
    () => tasks.filter((t) => t.workspaceId === workspaceId),
    [tasks, workspaceId],
  );
 
  const { nodes, edges } = useDagLayout(workspaceTasks);
 
  /** Cached color map — recomputed only when the theme changes. */
  const statusColors = useMemo(() => {
    const style = getComputedStyle(document.documentElement);
    const colors: Record<string, string> = {};
    for (const [status, varName] of Object.entries(STATUS_VAR_MAP)) {
      colors[status] = style.getPropertyValue(varName).trim() || "#6b7a8d";
    }
    return colors;
  }, [resolvedThemeId]);
 
  const onNodeClick = useCallback(
    (_event: MouseEvent, node: Node) => {
      navigate(taskUrl(node.id, undefined, workspaceId, environmentId));
    },
    [navigate, workspaceId, environmentId],
  );
 
  /** Returns a hex color for the MiniMap based on task status. */
  const minimapNodeColor = useCallback(
    (node: Node): string => {
      const data = node.data as TaskNodeData;
      return statusColors[data.task.status] || statusColors.pending;
    },
    [statusColors],
  );
 
  if (workspaceTasks.length === 0) {
    return (
      <div className={styles.emptyCta}>
        <button
          className={styles.ctaButton}
          onClick={() => navigate(newTaskUrl(workspaceId, undefined, environmentId))}
        >
          Create Task
        </button>
        <div className={styles.ctaDescription}>Create tasks to see the dependency graph</div>
      </div>
    );
  }
 
  return (
    <div className={styles.dagContainer}>
      <ReactFlow
        nodes={nodes}
        edges={edges}
        nodeTypes={nodeTypes}
        onNodeClick={onNodeClick}
        // Read-only DAG: dependencies aren't created by dragging (no onConnect),
        // so disable the drag-to-connect affordance on node handles (#1303).
        nodesConnectable={false}
        fitView
        fitViewOptions={FIT_VIEW_OPTIONS}
        minZoom={0.3}
        maxZoom={2}
      >
        <Background
          variant={BackgroundVariant.Dots}
          gap={24}
          size={1}
          color="var(--text-disabled)"
        />
        <Controls showInteractive={false} />
        <MiniMap
          nodeColor={minimapNodeColor}
          maskColor="var(--bg-overlay)"
          style={{ background: "var(--bg-inset)" }}
        />
      </ReactFlow>
    </div>
  );
}