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 { useEffect, useCallback, useRef } from 'react';
import {
  Play,
  Pause,
  ChevronLeft,
  ChevronRight,
  ChevronsLeft,
  ChevronsRight,
  RotateCcw,
  CheckCircle,
  XCircle,
  Loader2,
  AlertCircle,
} from 'lucide-react';
import { useDebugger } from '../context';
import type { Workflow } from '../../../types';
import { PLAYBACK } from '../constants';

interface IntegratedDebugToolbarProps {
  /** Workflows to execute */
  workflows: Workflow[];
  /** Payload for execution */
  payload?: Record<string, unknown>;
  /** Auto-execute on change */
  autoExecute?: boolean;
  /** Callback when execution completes */
  onExecutionComplete?: (trace: import('../../../types').ExecutionTrace) => void;
  /** Callback when execution errors */
  onExecutionError?: (error: string) => void;
  /** Additional CSS class */
  className?: string;
}

/**
 * Compact debug toolbar for integration into the visualizer header.
 * Includes execution status, step counter, and playback controls.
 */
export function IntegratedDebugToolbar({
  workflows,
  payload,
  autoExecute = false,
  onExecutionComplete,
  onExecutionError,
  className = '',
}: IntegratedDebugToolbarProps) {
  const {
    state,
    play,
    pause,
    stop,
    reset,
    stepForward,
    stepBackward,
    goToStep,
    startExecution,
    executeTrace,
    setExecutionError,
    runExecution,
    isAtStart,
    isAtEnd,
    hasTrace,
    totalSteps,
    currentFilteredPosition,
    filteredStepIndices,
    isEngineReady,
    skipFailedConditions,
    setSkipFailedConditions,
  } = useDebugger();

  const { playbackState, isExecuting, executionError, trace } = state;

  // Track last execution to prevent duplicates
  const lastExecutionRef = useRef<{ workflows: string; payload: string } | null>(null);

  // Execute workflows with duplicate prevention
  const handleExecute = useCallback(async () => {
    if (!isEngineReady || workflows.length === 0) return;

    // Check if this is the same execution as last time
    const workflowsJson = JSON.stringify(workflows);
    const payloadJson = JSON.stringify(payload || {});
    const current = { workflows: workflowsJson, payload: payloadJson };

    if (
      lastExecutionRef.current?.workflows === current.workflows &&
      lastExecutionRef.current?.payload === current.payload
    ) {
      return; // Skip duplicate execution
    }

    startExecution();
    try {
      const result = await runExecution(workflows, payload || {});
      if (result) {
        executeTrace(result);
        lastExecutionRef.current = current; // Track successful execution
        onExecutionComplete?.(result);
      }
    } catch (err) {
      const errorMessage = err instanceof Error ? err.message : 'Execution failed';
      setExecutionError(errorMessage);
      onExecutionError?.(errorMessage);
    }
  }, [
    isEngineReady,
    workflows,
    payload,
    startExecution,
    runExecution,
    executeTrace,
    setExecutionError,
    onExecutionComplete,
    onExecutionError,
  ]);

  // Reset handler that clears tracking ref to allow re-execution
  const handleReset = useCallback(() => {
    reset();
    lastExecutionRef.current = null; // Allow re-execution after reset
  }, [reset]);

  // Go to initial state (before first step)
  const goToFirst = useCallback(() => {
    if (hasTrace) {
      stop(); // Sets currentStepIndex to -1 (ready state)
    }
  }, [hasTrace, stop]);

  // Go to last step (use actual index from filtered list)
  const goToLast = useCallback(() => {
    if (hasTrace && filteredStepIndices.length > 0) {
      goToStep(filteredStepIndices[filteredStepIndices.length - 1]);
    }
  }, [hasTrace, filteredStepIndices, goToStep]);

  // Debounced auto-execute when workflows or payload change
  useEffect(() => {
    if (!autoExecute || !isEngineReady || workflows.length === 0) return;

    const timeoutId = setTimeout(() => {
      handleExecute();
    }, PLAYBACK.AUTO_EXECUTE_DEBOUNCE_MS);

    return () => clearTimeout(timeoutId);
  }, [autoExecute, isEngineReady, workflows, payload]); // eslint-disable-line react-hooks/exhaustive-deps

  // Keyboard shortcuts
  const handleKeyDown = useCallback(
    (e: KeyboardEvent) => {
      // Don't handle if in an input/textarea
      if (
        e.target instanceof HTMLInputElement ||
        e.target instanceof HTMLTextAreaElement
      ) {
        return;
      }

      switch (e.key) {
        case ' ':
          e.preventDefault();
          if (playbackState === 'playing') {
            pause();
          } else if (hasTrace && !isAtEnd) {
            play();
          }
          break;
        case 'ArrowRight':
          e.preventDefault();
          if (hasTrace && !isAtEnd) {
            stepForward();
          }
          break;
        case 'ArrowLeft':
          e.preventDefault();
          if (hasTrace && !isAtStart) {
            stepBackward();
          }
          break;
        case 'Home':
          e.preventDefault();
          if (hasTrace) {
            goToFirst();
          }
          break;
        case 'End':
          e.preventDefault();
          if (hasTrace) {
            goToLast();
          }
          break;
        case 'r':
          if (e.metaKey || e.ctrlKey) {
            e.preventDefault();
            handleReset();
          }
          break;
      }
    },
    [playbackState, hasTrace, isAtEnd, isAtStart, play, pause, stepForward, stepBackward, goToFirst, goToLast, handleReset]
  );

  useEffect(() => {
    window.addEventListener('keydown', handleKeyDown);
    return () => window.removeEventListener('keydown', handleKeyDown);
  }, [handleKeyDown]);

  // Determine status icon
  const getStatusIcon = () => {
    if (isExecuting) {
      return <Loader2 size={14} className="df-debug-spin" />;
    }
    if (executionError) {
      return <XCircle size={14} className="df-debug-toolbar-status-error" />;
    }
    if (hasTrace) {
      // Check if final message has errors
      const finalStep = trace?.steps[trace.steps.length - 1];
      const hasErrors = finalStep?.message?.errors && finalStep.message.errors.length > 0;
      if (hasErrors) {
        return <AlertCircle size={14} className="df-debug-toolbar-status-warning" />;
      }
      return <CheckCircle size={14} className="df-debug-toolbar-status-success" />;
    }
    return null;
  };

  // Get step display text
  const getStepText = () => {
    if (isExecuting) {
      return 'Executing...';
    }
    if (!hasTrace) {
      return 'Ready';
    }
    if (currentFilteredPosition < 0) {
      return 'Ready';
    }
    return `Step ${currentFilteredPosition + 1} / ${totalSteps}`;
  };

  return (
    <div className={`df-debug-toolbar-integrated ${className}`}>
      {/* Step counter / status */}
      <div className="df-debug-toolbar-status">
        {getStatusIcon()}
        <span className="df-debug-toolbar-step-text">{getStepText()}</span>
      </div>

      {/* Playback controls */}
      <div className="df-debug-toolbar-controls">
        <button
          className="df-debug-toolbar-btn"
          onClick={goToFirst}
          disabled={!hasTrace || isAtStart || isExecuting}
          title="First step (Home)"
        >
          <ChevronsLeft size={14} />
        </button>

        <button
          className="df-debug-toolbar-btn"
          onClick={stepBackward}
          disabled={!hasTrace || isAtStart || isExecuting}
          title="Previous step (Left Arrow)"
        >
          <ChevronLeft size={14} />
        </button>

        {playbackState === 'playing' ? (
          <button
            className="df-debug-toolbar-btn df-debug-toolbar-btn-primary"
            onClick={pause}
            disabled={!hasTrace || isExecuting}
            title="Pause (Space)"
          >
            <Pause size={14} />
          </button>
        ) : (
          <button
            className="df-debug-toolbar-btn df-debug-toolbar-btn-primary"
            onClick={play}
            disabled={!hasTrace || isAtEnd || isExecuting}
            title="Play (Space)"
          >
            <Play size={14} />
          </button>
        )}

        <button
          className="df-debug-toolbar-btn"
          onClick={stepForward}
          disabled={!hasTrace || isAtEnd || isExecuting}
          title="Next step (Right Arrow)"
        >
          <ChevronRight size={14} />
        </button>

        <button
          className="df-debug-toolbar-btn"
          onClick={goToLast}
          disabled={!hasTrace || isAtEnd || isExecuting}
          title="Last step (End)"
        >
          <ChevronsRight size={14} />
        </button>
      </div>

      {/* Filter workflows checkbox */}
      <div className="df-debug-toolbar-options">
        <label className="df-debug-toolbar-checkbox-label">
          <input
            type="checkbox"
            checked={skipFailedConditions}
            onChange={(e) => setSkipFailedConditions(e.target.checked)}
            className="df-debug-toolbar-checkbox"
            disabled={isExecuting}
          />
          <span>Filter Workflows</span>
        </label>
      </div>

      {/* Run / Reset buttons */}
      <div className="df-debug-toolbar-actions">
        {hasTrace ? (
          <button
            className="df-debug-toolbar-btn-action df-debug-toolbar-btn-reset"
            onClick={handleReset}
            disabled={isExecuting}
            title="Reset (Ctrl+R)"
          >
            <RotateCcw size={12} />
            <span>Reset</span>
          </button>
        ) : (
          <button
            className="df-debug-toolbar-btn-action df-debug-toolbar-btn-run"
            onClick={handleExecute}
            disabled={!isEngineReady || isExecuting || workflows.length === 0}
            title="Run workflow"
          >
            {isExecuting ? (
              <Loader2 size={12} className="df-debug-spin" />
            ) : (
              <Play size={12} />
            )}
            <span>{isExecuting ? 'Running...' : 'Run'}</span>
          </button>
        )}
      </div>
    </div>
  );
}