dataflow-rs 2.1.5

A lightweight rules engine for building IFTTT-style automation and data processing pipelines in Rust. Define rules with JSONLogic conditions, execute actions, and chain workflows.
Documentation
import { useState, useMemo, useEffect, useRef } from 'react';
import { Layers } from 'lucide-react';
import type { Workflow } from '../../../types';
import type { TreeSelectionType } from '../WorkflowVisualizer';
import { useDebuggerOptional } from '../context';
import { TreeNode, WorkflowNode, FolderNode, TREE_COLORS } from '../components';
import { buildFolderTree, getFirstLevelFolderIds, getParentFolderIds } from '../utils/folderTree';
import { NODE_IDS, PLAYBACK } from '../constants';

interface TreeViewProps {
  workflows: Workflow[];
  selection: TreeSelectionType;
  onSelect: (selection: TreeSelectionType) => void;
  /** Enable debug mode with state indicators */
  debugMode?: boolean;
}

export function TreeView({ workflows, selection, onSelect, debugMode = false }: TreeViewProps) {
  // Use optional hook that returns null if no provider exists
  const debuggerContext = useDebuggerOptional();
  const effectiveDebugContext = debugMode ? debuggerContext : null;

  // Build folder tree from workflows
  const folderTree = useMemo(() => buildFolderTree(workflows), [workflows]);

  // Sort root-level folders alphabetically
  const sortedRootFolders = useMemo(() => {
    return Array.from(folderTree.folders.values()).sort((a, b) =>
      a.name.localeCompare(b.name)
    );
  }, [folderTree]);

  // Root-level workflows (no path) - already sorted by priority in buildFolderTree
  const rootWorkflows = folderTree.workflows;

  const [expandedNodes, setExpandedNodes] = useState<Set<string>>(() => {
    // Initially expand the root "Workflows" node and first-level folders
    const initial = new Set<string>([NODE_IDS.ROOT]);
    getFirstLevelFolderIds(folderTree).forEach(id => initial.add(id));
    return initial;
  });

  // Update expanded nodes when folder tree changes (e.g., new workflows loaded)
  useEffect(() => {
    setExpandedNodes((prev) => {
      const next = new Set(prev);
      next.add(NODE_IDS.ROOT);
      // Expand first-level folders by default
      getFirstLevelFolderIds(folderTree).forEach(id => next.add(id));
      return next;
    });
  }, [folderTree]);

  // Expand the first workflow when workflows change
  useEffect(() => {
    const allWorkflows = [...folderTree.workflows];
    // Also collect workflows from folders
    function collectWorkflows(folders: Map<string, typeof folderTree.folders extends Map<string, infer T> ? T : never>) {
      for (const folder of folders.values()) {
        allWorkflows.push(...folder.workflows);
        collectWorkflows(folder.folders);
      }
    }
    collectWorkflows(folderTree.folders);

    if (allWorkflows.length > 0) {
      // Sort by priority and expand the first one
      allWorkflows.sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0));
      setExpandedNodes((prev) => {
        const next = new Set(prev);
        next.add(NODE_IDS.workflow(allWorkflows[0].id));
        // Also expand parent folders if needed
        getParentFolderIds(allWorkflows[0].path).forEach(id => next.add(id));
        return next;
      });
    }
  }, [folderTree]);

  // Track last selected step to prevent redundant selections
  const lastSelectedRef = useRef<{ workflowId: string; taskId?: string } | null>(null);

  // Ref for tree container (used for auto-scroll)
  const treeContainerRef = useRef<HTMLDivElement>(null);

  // Auto-expand and select based on current debug step
  useEffect(() => {
    // Don't auto-select if at step -1 (ready state) or no step
    if (!debugMode || !effectiveDebugContext?.currentStep ||
        effectiveDebugContext.state.currentStepIndex < 0) {
      return;
    }

    const { workflow_id, task_id } = effectiveDebugContext.currentStep;

    // Check if we already selected this step
    if (lastSelectedRef.current?.workflowId === workflow_id &&
        lastSelectedRef.current?.taskId === task_id) {
      return;
    }

    // Find the workflow to get its path
    const workflow = workflows.find(w => w.id === workflow_id);

    // Auto-expand nodes to show current step
    setExpandedNodes((prev) => {
      const next = new Set(prev);
      next.add(NODE_IDS.ROOT);
      // Expand parent folders if workflow has a path
      if (workflow?.path) {
        getParentFolderIds(workflow.path).forEach(id => next.add(id));
      }
      next.add(NODE_IDS.workflow(workflow_id));
      if (task_id) {
        next.add(NODE_IDS.task(workflow_id, task_id));
      }
      return next;
    });

    // Auto-select the current task or workflow
    if (task_id) {
      const task = workflow?.tasks.find(t => t.id === task_id);
      if (workflow && task) {
        lastSelectedRef.current = { workflowId: workflow_id, taskId: task_id };
        onSelect({ type: 'task', task, workflow });
      }
    } else {
      lastSelectedRef.current = { workflowId: workflow_id };
    }

    // Auto-scroll to current step after a short delay (to allow DOM to update)
    setTimeout(() => {
      const currentStepElement = treeContainerRef.current?.querySelector('[data-current-step="true"]');
      if (currentStepElement) {
        currentStepElement.scrollIntoView({
          behavior: 'smooth',
          block: 'nearest',
        });
      }
    }, PLAYBACK.AUTO_SCROLL_DELAY_MS);
  }, [debugMode, effectiveDebugContext?.currentStep, effectiveDebugContext?.state.currentStepIndex, workflows, onSelect]);

  const toggleNode = (id: string) => {
    setExpandedNodes((prev) => {
      const next = new Set(prev);
      if (next.has(id)) {
        next.delete(id);
      } else {
        next.add(id);
      }
      return next;
    });
  };

  const isRootExpanded = expandedNodes.has(NODE_IDS.ROOT);
  const totalWorkflowCount = workflows.length;

  return (
    <div ref={treeContainerRef} className={`df-tree-view ${debugMode ? 'df-tree-view-debug' : ''}`}>
      <TreeNode
        label="Workflows"
        icon={<Layers size={14} />}
        iconColor={TREE_COLORS.workflow}
        isExpanded={isRootExpanded}
        isSelected={selection.type === 'folder' && selection.name === 'Workflows'}
        hasChildren={totalWorkflowCount > 0}
        level={0}
        onToggle={() => toggleNode(NODE_IDS.ROOT)}
        onClick={() => onSelect({ type: 'folder', workflows, name: 'Workflows' })}
      >
        {/* Render folders first (alphabetically) */}
        {sortedRootFolders.map((folder) => (
          <FolderNode
            key={folder.fullPath}
            folder={folder}
            level={1}
            selection={selection}
            onSelect={onSelect}
            expandedNodes={expandedNodes}
            toggleNode={toggleNode}
            debugMode={debugMode}
          />
        ))}

        {/* Render root-level workflows (by priority) */}
        {rootWorkflows.map((workflow) => (
          <WorkflowNode
            key={workflow.id}
            workflow={workflow}
            level={1}
            selection={selection}
            onSelect={onSelect}
            expandedNodes={expandedNodes}
            toggleNode={toggleNode}
            debugMode={debugMode}
          />
        ))}
      </TreeNode>
    </div>
  );
}