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, useCallback, useEffect, useRef, useMemo } from 'react';
import { Sun, Moon, BookOpen, ChevronDown, PanelLeftClose, PanelLeft, Braces } from 'lucide-react';

const GithubIcon = ({ size = 16 }: { size?: number }) => (
  <svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
    <path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/>
  </svg>
);

import { WorkflowVisualizer } from './components/workflow-visualizer';
import { JsonEditor, StatusBar } from './components/common';
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
import { defaultEngineFactory } from './engines';
import { useResizable } from './components/workflow-visualizer/hooks';
import { LAYOUT } from './components/workflow-visualizer/constants';
import { ThemeProvider, useTheme } from './components/workflow-visualizer/context';
import initWasm from '@goplasmatic/dataflow-wasm';
import type { Workflow, EngineFactory } from './types';
import { SAMPLE_WORKFLOWS } from './data/sampleWorkflows';
import './App.css';

function AppContent({ engineFactory }: { engineFactory: EngineFactory | undefined }) {
  const { resolvedTheme, setTheme } = useTheme();

  const [workflowsText, setWorkflowsText] = useState('');
  const [workflows, setWorkflows] = useState<Workflow[]>([]);
  const [workflowsError, setWorkflowsError] = useState<string | null>(null);

  const [payloadText, setPayloadText] = useState('{}');
  const [payloadError, setPayloadError] = useState<string | null>(null);

  const [selectedExample, setSelectedExample] = useState(Object.keys(SAMPLE_WORKFLOWS)[0]);
  const [dropdownOpen, setDropdownOpen] = useState(false);
  const dropdownRef = useRef<HTMLDivElement>(null);

  const [isPanelCollapsed, setIsPanelCollapsed] = useState(false);
  const containerRef = useRef<HTMLElement>(null);

  const [cursorPosition, setCursorPosition] = useState({ line: 1, column: 1 });

  const {
    size: panelWidth,
    isDragging,
    onMouseDown: handleMouseDown,
  } = useResizable({
    containerRef,
    direction: 'horizontal',
    min: LAYOUT.APP_PANEL.MIN,
    max: LAYOUT.APP_PANEL.MAX,
    initial: LAYOUT.APP_PANEL.DEFAULT,
  });

  // Set data-theme on document for App-level styling
  useEffect(() => {
    document.documentElement.setAttribute('data-theme', resolvedTheme);
  }, [resolvedTheme]);

  const toggleTheme = useCallback(() => {
    setTheme(resolvedTheme === 'light' ? 'dark' : 'light');
  }, [resolvedTheme, setTheme]);

  // Handle workflows text change
  const handleWorkflowsChange = useCallback((text: string) => {
    setWorkflowsText(text);
    if (!text.trim()) {
      setWorkflows([]);
      setWorkflowsError(null);
      return;
    }
    try {
      const parsed = JSON.parse(text);
      if (!Array.isArray(parsed)) {
        setWorkflowsError('Workflows must be an array');
        return;
      }
      setWorkflows(parsed);
      setWorkflowsError(null);
    } catch (err) {
      setWorkflowsError(err instanceof Error ? err.message : 'Invalid JSON');
    }
  }, []);

  // Handle payload text change
  const handlePayloadChange = useCallback((text: string) => {
    setPayloadText(text);
    if (!text.trim()) {
      setPayloadError(null);
      return;
    }
    try {
      const parsed = JSON.parse(text);
      if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
        setPayloadError('Payload must be a JSON object');
        return;
      }
      setPayloadError(null);
    } catch (err) {
      setPayloadError(err instanceof Error ? err.message : 'Invalid JSON');
    }
  }, []);

  // Load sample
  const loadSample = useCallback((name: string) => {
    const sample = SAMPLE_WORKFLOWS[name];
    if (sample) {
      setSelectedExample(name);
      setWorkflows(sample.workflows);
      setWorkflowsText(JSON.stringify(sample.workflows, null, 2));
      setWorkflowsError(null);
      setPayloadText(JSON.stringify(sample.payload, null, 2));
      setPayloadError(null);
      setDropdownOpen(false);
    }
  }, []);

  // Load first sample on mount
  useEffect(() => {
    loadSample(Object.keys(SAMPLE_WORKFLOWS)[0]);
  }, [loadSample]);

  // Close dropdown on outside click
  useEffect(() => {
    const handleClickOutside = (e: MouseEvent) => {
      if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
        setDropdownOpen(false);
      }
    };
    document.addEventListener('mousedown', handleClickOutside);
    return () => document.removeEventListener('mousedown', handleClickOutside);
  }, []);

  // Toggle panel
  const togglePanel = useCallback(() => {
    setIsPanelCollapsed((prev) => !prev);
  }, []);

  // Format JSON
  const formatJson = useCallback(() => {
    try {
      const parsedWorkflows = JSON.parse(workflowsText);
      setWorkflowsText(JSON.stringify(parsedWorkflows, null, 2));
    } catch {
      // Ignore formatting errors
    }
    try {
      const parsedPayload = JSON.parse(payloadText);
      setPayloadText(JSON.stringify(parsedPayload, null, 2));
    } catch {
      // Ignore formatting errors
    }
  }, [workflowsText, payloadText]);

  // Keyboard shortcuts
  useKeyboardShortcuts({
    onTogglePanel: togglePanel,
    onFormatJson: formatJson,
  });

  // Handle cursor position change
  const handleCursorChange = useCallback((line: number, column: number) => {
    setCursorPosition({ line, column });
  }, []);

  return (
    <>
      <div className="app" data-theme={resolvedTheme}>
        <header className="app-header">
          <div className="header-title">
            <h1>Dataflow Visualizer</h1>
            <span className="header-subtitle">Workflow Engine Debugger</span>
          </div>
          <div className="header-controls">
            <button
              className="header-action"
              onClick={togglePanel}
              title={isPanelCollapsed ? 'Show Editor (Ctrl+B)' : 'Hide Editor (Ctrl+B)'}
            >
              {isPanelCollapsed ? <PanelLeft size={16} /> : <PanelLeftClose size={16} />}
            </button>
            <button
              className="header-action"
              onClick={formatJson}
              title="Format JSON (Ctrl+Shift+F)"
            >
              <Braces size={16} />
              <span>Format</span>
            </button>
            <div className="header-divider" />
            <a
              href="https://github.com/GoPlasmatic/dataflow-rs"
              target="_blank"
              rel="noopener noreferrer"
              className="header-link"
            >
              <GithubIcon size={16} />
              <span>GitHub</span>
            </a>
            <a
              href="https://goplasmatic.github.io/dataflow-rs/"
              target="_blank"
              rel="noopener noreferrer"
              className="header-link"
            >
              <BookOpen size={16} />
              <span>Docs</span>
            </a>
            <div className="header-divider" />
            <div className="examples-dropdown" ref={dropdownRef}>
              <button
                className="examples-dropdown-trigger"
                onClick={() => setDropdownOpen(!dropdownOpen)}
              >
                <span className="examples-dropdown-label">Examples</span>
                <span className="examples-dropdown-value">{selectedExample}</span>
                <ChevronDown size={14} className={`dropdown-icon ${dropdownOpen ? 'open' : ''}`} />
              </button>
              {dropdownOpen && (
                <div className="examples-dropdown-menu">
                  {Object.keys(SAMPLE_WORKFLOWS).map((name) => (
                    <button
                      key={name}
                      className={`examples-dropdown-item ${name === selectedExample ? 'selected' : ''}`}
                      onClick={() => loadSample(name)}
                    >
                      {name}
                    </button>
                  ))}
                </div>
              )}
            </div>
            <button className="theme-toggle" onClick={toggleTheme} title="Toggle Theme">
              {resolvedTheme === 'light' ? <Moon size={18} /> : <Sun size={18} />}
            </button>
          </div>
        </header>

        <main className="app-main" ref={containerRef}>
          {/* Left Panel - JSON Editors */}
          <div
            className={`panel editor-panel ${isPanelCollapsed ? 'collapsed' : ''}`}
            style={{ width: isPanelCollapsed ? 0 : panelWidth }}
          >
            <div className="editor-section">
              <div className="editor-header">
                <h3>Workflows</h3>
                {workflowsError && <span className="editor-error">{workflowsError}</span>}
              </div>
              <div className="editor-content">
                <JsonEditor
                  value={workflowsText}
                  onChange={handleWorkflowsChange}
                  theme={resolvedTheme}
                  onCursorChange={handleCursorChange}
                />
              </div>
            </div>
            <div className="editor-section">
              <div className="editor-header">
                <h3>Payload</h3>
                {payloadError && <span className="editor-error">{payloadError}</span>}
              </div>
              <div className="editor-content">
                <JsonEditor
                  value={payloadText}
                  onChange={handlePayloadChange}
                  theme={resolvedTheme}
                  onCursorChange={handleCursorChange}
                />
              </div>
            </div>
          </div>

          {/* Divider */}
          {!isPanelCollapsed && (
            <div
              className={`divider ${isDragging ? 'dragging' : ''}`}
              onMouseDown={handleMouseDown}
            />
          )}

          {/* Right Panel - Visualizer */}
          <div className="panel visual-panel">
            <div className="panel-content">
              <WorkflowVisualizer
                key={workflowsText}
                workflows={workflows}
                theme={resolvedTheme}
                debugConfig={{
                  enabled: true,
                  engineFactory: engineFactory!,
                  autoExecute: true,
                }}
                debugPayload={(() => {
                  try {
                    return JSON.parse(payloadText);
                  } catch {
                    return {};
                  }
                })()}
              />
            </div>
          </div>
        </main>

        {/* Status Bar */}
        <StatusBar
          workflows={workflows}
          workflowsError={workflowsError}
          messageError={payloadError}
          cursorPosition={cursorPosition}
        />
      </div>
    </>
  );
}

function App() {
  const [wasmReady, setWasmReady] = useState(false);

  // Initialize WASM module
  useEffect(() => {
    initWasm()
      .then(() => {
        setWasmReady(true);
        console.log('WASM module initialized');
      })
      .catch((err) => {
        console.error('Failed to initialize WASM:', err);
      });
  }, []);

  // Only provide engineFactory once WASM is ready
  const engineFactory = useMemo(
    () => (wasmReady ? defaultEngineFactory : undefined),
    [wasmReady]
  );

  return (
    <ThemeProvider defaultTheme="system">
      <AppContent engineFactory={engineFactory} />
    </ThemeProvider>
  );
}

export default App;