mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
feat(subflows): editor, block; fix(copilot): stop, mr (#1877)
This commit is contained in:
@@ -2,18 +2,9 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ==========================================================================
|
||||
SIDEBAR WIDTH PERSISTENCE
|
||||
========================================================================== */
|
||||
/**
|
||||
* CSS-based sidebar width to prevent SSR hydration mismatches.
|
||||
*
|
||||
* How it works:
|
||||
* 1. Default width set here in CSS (232px)
|
||||
* 2. Blocking script in layout.tsx updates this before React hydrates
|
||||
* 3. Store updates this variable when user resizes
|
||||
*
|
||||
* This approach ensures server and client always render identical HTML.
|
||||
* CSS-based sidebar and panel widths to prevent SSR hydration mismatches.
|
||||
* Default widths are set here and updated via blocking script before React hydrates.
|
||||
*/
|
||||
:root {
|
||||
--sidebar-width: 232px;
|
||||
@@ -35,9 +26,9 @@
|
||||
height: var(--terminal-height);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
WORKFLOW COMPONENT Z-INDEX FIXES
|
||||
========================================================================== */
|
||||
/**
|
||||
* Workflow component z-index fixes and background colors
|
||||
*/
|
||||
.workflow-container .react-flow__edges {
|
||||
z-index: 0 !important;
|
||||
}
|
||||
@@ -46,12 +37,6 @@
|
||||
z-index: 21 !important;
|
||||
}
|
||||
|
||||
.workflow-container .react-flow__node-loopNode,
|
||||
.workflow-container .react-flow__node-parallelNode,
|
||||
.workflow-container .react-flow__node-subflowNode {
|
||||
z-index: -1 !important;
|
||||
}
|
||||
|
||||
.workflow-container .react-flow__handle {
|
||||
z-index: 30 !important;
|
||||
}
|
||||
@@ -64,7 +49,6 @@
|
||||
z-index: 60 !important;
|
||||
}
|
||||
|
||||
/* Workflow canvas background */
|
||||
.workflow-container,
|
||||
.workflow-container .react-flow__pane,
|
||||
.workflow-container .react-flow__renderer {
|
||||
@@ -77,183 +61,110 @@
|
||||
background-color: #1b1b1b !important;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
LANDING LOOP ANIMATION
|
||||
========================================================================== */
|
||||
@keyframes dash-animation {
|
||||
from {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
to {
|
||||
stroke-dashoffset: -24;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Landing loop animation styles (keyframes defined in tailwind.config.ts)
|
||||
*/
|
||||
.landing-loop-animated-dash {
|
||||
animation: dash-animation 1.5s linear infinite;
|
||||
/* Ensure animation works in React Flow context */
|
||||
will-change: stroke-dashoffset;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
/* Ensure React Flow doesn't override our animation */
|
||||
.react-flow__node-landingLoop svg rect.landing-loop-animated-dash {
|
||||
animation: dash-animation 1.5s linear infinite !important;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
THEME SYSTEM - CSS CUSTOM PROPERTIES
|
||||
========================================================================== */
|
||||
/**
|
||||
* Theme system - CSS custom properties for light and dark modes
|
||||
*/
|
||||
@layer base {
|
||||
/* Light Mode Theme */
|
||||
:root,
|
||||
.light {
|
||||
/* Core Colors */
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
|
||||
/* Card Colors */
|
||||
--card: 0 0% 99.2%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
|
||||
/* Popover Colors */
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
|
||||
/* Primary Colors */
|
||||
--primary: 0 0% 11.2%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
|
||||
/* Secondary Colors */
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 11.2%;
|
||||
|
||||
/* Muted Colors */
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 46.9%;
|
||||
|
||||
/* Accent Colors */
|
||||
--accent: 0 0% 92.5%;
|
||||
--accent-foreground: 0 0% 11.2%;
|
||||
|
||||
/* Destructive Colors */
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
/* Border & Input Colors */
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--input-background: 0 0% 79.22%;
|
||||
--ring: 0 0% 3.9%;
|
||||
|
||||
/* Border Radius */
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* Scrollbar Properties */
|
||||
--scrollbar-track: 0 0% 85%;
|
||||
--scrollbar-thumb: 0 0% 65%;
|
||||
--scrollbar-thumb-hover: 0 0% 55%;
|
||||
--scrollbar-size: 8px;
|
||||
|
||||
/* Workflow Properties */
|
||||
--workflow-background: 0 0% 100%;
|
||||
--card-background: 0 0% 99.2%;
|
||||
--card-border: 0 0% 89.8%;
|
||||
--card-text: 0 0% 3.9%;
|
||||
--card-hover: 0 0% 96.1%;
|
||||
|
||||
/* Base Component Properties */
|
||||
--base-muted-foreground: #737373;
|
||||
|
||||
/* Gradient Colors */
|
||||
--gradient-primary: 263 85% 70%; /* More vibrant purple */
|
||||
--gradient-secondary: 336 95% 65%; /* More vibrant pink */
|
||||
|
||||
/* Brand Colors (Default Sim Theme) */
|
||||
--brand-primary-hex: #6f3dfa; /* Primary brand purple - matches Get Started gradient start */
|
||||
--brand-primary-hover-hex: #6338d9; /* Primary brand purple hover - matches Get Started hover */
|
||||
--brand-accent-hex: #6f3dfa; /* Accent purple for links - matches sign in button */
|
||||
--brand-accent-hover-hex: #6f3dfa; /* Accent purple hover - matches sign in gradient start */
|
||||
--brand-background-hex: #ffffff; /* Primary light background */
|
||||
|
||||
/* UI Surface Colors */
|
||||
--surface-elevated: #202020; /* Elevated surface background for dark mode */
|
||||
--gradient-primary: 263 85% 70%;
|
||||
--gradient-secondary: 336 95% 65%;
|
||||
--brand-primary-hex: #6f3dfa;
|
||||
--brand-primary-hover-hex: #6338d9;
|
||||
--brand-accent-hex: #6f3dfa;
|
||||
--brand-accent-hover-hex: #6f3dfa;
|
||||
--brand-background-hex: #ffffff;
|
||||
--surface-elevated: #202020;
|
||||
}
|
||||
|
||||
/* Dark Mode Theme */
|
||||
.dark {
|
||||
/* Core Colors */
|
||||
--background: 0 0% 10.59%;
|
||||
--foreground: 0 0% 98%;
|
||||
|
||||
/* Card Colors */
|
||||
--card: 0 0% 9.0%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
|
||||
/* Popover Colors */
|
||||
--popover: 0 0% 9.0%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
|
||||
/* Primary Colors */
|
||||
--primary: 0 0% 11.2%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
|
||||
/* Secondary Colors */
|
||||
--secondary: 0 0% 12.0%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
|
||||
/* Muted Colors */
|
||||
--muted: 0 0% 17.5%;
|
||||
--muted-foreground: 0 0% 65.1%;
|
||||
|
||||
/* Accent Colors */
|
||||
--accent: 0 0% 17.5%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
|
||||
/* Destructive Colors */
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
/* Border & Input Colors */
|
||||
--border: 0 0% 16.1%;
|
||||
--input: 0 0% 16.1%;
|
||||
--input-background: 0 0% 20.78%;
|
||||
--ring: 0 0% 83.9%;
|
||||
|
||||
/* Scrollbar Properties */
|
||||
--scrollbar-track: 0 0% 17.5%;
|
||||
--scrollbar-thumb: 0 0% 30%;
|
||||
--scrollbar-thumb-hover: 0 0% 40%;
|
||||
|
||||
/* Workflow Properties */
|
||||
--workflow-background: 0 0% 10.59%;
|
||||
--card-background: 0 0% 9.0%;
|
||||
--card-border: 0 0% 22.7%;
|
||||
--card-text: 0 0% 98%;
|
||||
--card-hover: 0 0% 12.0%;
|
||||
|
||||
/* Base Component Properties */
|
||||
--base-muted-foreground: #a3a3a3;
|
||||
|
||||
/* Gradient Colors - Adjusted for dark mode */
|
||||
--gradient-primary: 263 90% 75%; /* More vibrant purple for dark mode */
|
||||
--gradient-secondary: 336 100% 72%; /* More vibrant pink for dark mode */
|
||||
|
||||
/* Brand Colors (Keep dark background for actual dark mode) */
|
||||
--brand-primary-hex: #701ffc; /* Primary brand purple */
|
||||
--brand-primary-hover-hex: #802fff; /* Primary brand purple hover */
|
||||
--brand-accent-hex: #9d54ff; /* Accent purple for links */
|
||||
--brand-accent-hover-hex: #a66fff; /* Accent purple hover */
|
||||
--brand-background-hex: #0c0c0c; /* Primary dark background */
|
||||
|
||||
/* UI Surface Colors */
|
||||
--surface-elevated: #202020; /* Elevated surface background for dark mode */
|
||||
--gradient-primary: 263 90% 75%;
|
||||
--gradient-secondary: 336 100% 72%;
|
||||
--brand-primary-hex: #701ffc;
|
||||
--brand-primary-hover-hex: #802fff;
|
||||
--brand-accent-hex: #9d54ff;
|
||||
--brand-accent-hover-hex: #a66fff;
|
||||
--brand-background-hex: #0c0c0c;
|
||||
--surface-elevated: #202020;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
BASE STYLES
|
||||
========================================================================== */
|
||||
/**
|
||||
* Base styles for body, scrollbars, and global elements
|
||||
*/
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
@@ -269,19 +180,14 @@
|
||||
overscroll-behavior-x: none;
|
||||
overscroll-behavior-y: none;
|
||||
min-height: 100vh;
|
||||
/* Prevent layout shifts when scrollbar appears/disappears */
|
||||
scrollbar-gutter: stable;
|
||||
/* Improve animation performance */
|
||||
text-rendering: optimizeSpeed;
|
||||
/* Default text styles */
|
||||
letter-spacing: 0.28px;
|
||||
}
|
||||
|
||||
.dark body {
|
||||
@apply antialiased;
|
||||
}
|
||||
|
||||
/* Global Scrollbar Styling */
|
||||
::-webkit-scrollbar {
|
||||
width: var(--scrollbar-size);
|
||||
height: var(--scrollbar-size);
|
||||
@@ -313,7 +219,6 @@
|
||||
background-color: hsl(var(--muted-foreground) / 0.4);
|
||||
}
|
||||
|
||||
/* Firefox Scrollbar */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(var(--muted-foreground) / 0.3) #f5f5f5;
|
||||
@@ -323,27 +228,24 @@
|
||||
scrollbar-color: hsl(var(--muted-foreground) / 0.3) #272727;
|
||||
}
|
||||
|
||||
/* Copilot Scrollbar - Ensure stable gutter */
|
||||
.copilot-scrollable {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
PANEL STYLES
|
||||
========================================================================== */
|
||||
/**
|
||||
* Panel tab styles
|
||||
*/
|
||||
.panel-tab-base {
|
||||
color: var(--base-muted-foreground);
|
||||
}
|
||||
|
||||
.panel-tab-active {
|
||||
/* Light Mode Panel Tab Active */
|
||||
background-color: #f5f5f5;
|
||||
color: #1a1a1a;
|
||||
border-color: #e5e5e5;
|
||||
}
|
||||
|
||||
/* Dark Mode Panel Tab Active */
|
||||
.dark .panel-tab-active {
|
||||
background-color: #1f1f1f;
|
||||
color: #ffffff;
|
||||
@@ -355,58 +257,51 @@
|
||||
color: hsl(var(--card-foreground));
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
DARK MODE OVERRIDES
|
||||
========================================================================== */
|
||||
/**
|
||||
* Dark mode specific overrides
|
||||
*/
|
||||
.dark .error-badge {
|
||||
background-color: hsl(0, 70%, 20%) !important;
|
||||
/* Darker red background for dark mode */
|
||||
color: hsl(0, 0%, 100%) !important;
|
||||
/* Pure white text for better contrast */
|
||||
}
|
||||
|
||||
.dark .bg-red-500 {
|
||||
@apply bg-red-700;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
BROWSER INPUT OVERRIDES
|
||||
========================================================================== */
|
||||
/* Chrome, Safari, Edge, Opera */
|
||||
/**
|
||||
* Browser input overrides
|
||||
*/
|
||||
input[type="search"]::-webkit-search-cancel-button {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
/* Firefox */
|
||||
input[type="search"]::-moz-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Microsoft Edge */
|
||||
input[type="search"]::-ms-clear {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
LAYOUT UTILITIES
|
||||
========================================================================== */
|
||||
/**
|
||||
* Layout utilities
|
||||
*/
|
||||
.main-content-overlay {
|
||||
z-index: 40;
|
||||
/* Higher z-index to appear above content */
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
ANIMATIONS & UTILITIES
|
||||
========================================================================== */
|
||||
/**
|
||||
* Utilities and special effects
|
||||
* Animation keyframes are defined in tailwind.config.ts
|
||||
*/
|
||||
@layer utilities {
|
||||
/* Animation Performance */
|
||||
.animation-container {
|
||||
contain: paint layout style;
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
/* Scrollbar Utilities */
|
||||
.scrollbar-none {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
@@ -417,17 +312,12 @@ input[type="search"]::-ms-clear {
|
||||
}
|
||||
|
||||
.scrollbar-hide {
|
||||
/* For Webkit browsers (Chrome, Safari, Edge) */
|
||||
-webkit-scrollbar: none;
|
||||
-webkit-scrollbar-width: none;
|
||||
-webkit-scrollbar-track: transparent;
|
||||
-webkit-scrollbar-thumb: transparent;
|
||||
|
||||
/* For Firefox */
|
||||
scrollbar-width: none;
|
||||
scrollbar-color: transparent transparent;
|
||||
|
||||
/* For Internet Explorer and Edge Legacy */
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
@@ -448,19 +338,16 @@ input[type="search"]::-ms-clear {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Gradient Text Utility - Use with Tailwind gradient directions */
|
||||
.gradient-text {
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Input Background Utility */
|
||||
.bg-input-background {
|
||||
background-color: hsl(var(--input-background));
|
||||
}
|
||||
|
||||
/* Brand Color Utilities */
|
||||
.bg-brand-primary {
|
||||
background-color: var(--brand-primary-hex);
|
||||
}
|
||||
@@ -477,7 +364,6 @@ input[type="search"]::-ms-clear {
|
||||
color: var(--brand-accent-hover-hex);
|
||||
}
|
||||
|
||||
/* Gradient Button Utilities */
|
||||
.bg-brand-gradient {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
@@ -502,7 +388,6 @@ input[type="search"]::-ms-clear {
|
||||
);
|
||||
}
|
||||
|
||||
/* Apply fixed default light theme values for auth components */
|
||||
.auth-card {
|
||||
background-color: rgba(255, 255, 255, 0.9) !important;
|
||||
border-color: #e5e5e5 !important;
|
||||
@@ -534,39 +419,16 @@ input[type="search"]::-ms-clear {
|
||||
color: #737373 !important;
|
||||
}
|
||||
|
||||
/* Surface Utilities */
|
||||
.bg-surface-elevated {
|
||||
background-color: var(--surface-elevated);
|
||||
}
|
||||
|
||||
/* Animation Classes */
|
||||
.animate-pulse-ring {
|
||||
animation: pulse-ring 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
.transition-ring {
|
||||
transition-property: box-shadow, transform;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 300ms;
|
||||
}
|
||||
|
||||
.animate-orbit {
|
||||
animation: orbit calc(var(--duration, 2) * 1s) linear infinite;
|
||||
}
|
||||
|
||||
.animate-marquee {
|
||||
animation: marquee var(--duration) infinite linear;
|
||||
}
|
||||
|
||||
.animate-marquee-vertical {
|
||||
animation: marquee-vertical var(--duration) linear infinite;
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.3s ease-in-out forwards;
|
||||
}
|
||||
|
||||
/* Special Effects */
|
||||
.streaming-effect {
|
||||
@apply relative overflow-hidden;
|
||||
}
|
||||
@@ -597,7 +459,6 @@ input[type="search"]::-ms-clear {
|
||||
animation: placeholder-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Auth Button Styles - Default Gradient */
|
||||
.auth-button-gradient {
|
||||
background: linear-gradient(to bottom, #8357ff, #6f3dfa) !important;
|
||||
border-color: #6f3dfa !important;
|
||||
@@ -609,11 +470,9 @@ input[type="search"]::-ms-clear {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Auth Button Styles - CSS Variable Based */
|
||||
.auth-button-custom {
|
||||
background: var(--brand-accent-hex) !important;
|
||||
border-color: var(--brand-accent-hex) !important;
|
||||
/* Remove purple shadow when using custom color */
|
||||
box-shadow: inset 0 2px 4px 0 rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
@@ -623,90 +482,8 @@ input[type="search"]::-ms-clear {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
KEYFRAME ANIMATIONS
|
||||
========================================================================== */
|
||||
@keyframes pulse-ring {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 hsl(var(--border));
|
||||
}
|
||||
|
||||
50% {
|
||||
box-shadow: 0 0 0 8px hsl(var(--border));
|
||||
}
|
||||
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 hsl(var(--border));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes code-shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes orbit {
|
||||
0% {
|
||||
transform: rotate(calc(var(--angle) * 1deg)) translateY(calc(var(--radius) * 1px))
|
||||
rotate(calc(var(--angle) * -1deg));
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(calc(var(--angle) * 1deg + 360deg)) translateY(calc(var(--radius) * 1px))
|
||||
rotate(calc((var(--angle) * -1deg) - 360deg));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes marquee {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(calc(-100% - var(--gap)));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes marquee-vertical {
|
||||
from {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(calc(-100% - var(--gap)));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes placeholder-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Panel tab visibility - prevent flash on hydration
|
||||
* Before React hydrates, use data attribute set by blocking script
|
||||
* to show the correct tab content and button states
|
||||
* Panel tab visibility and styling to prevent hydration flash
|
||||
*/
|
||||
html[data-panel-active-tab="copilot"] .panel-container [data-tab-content="toolbar"],
|
||||
html[data-panel-active-tab="copilot"] .panel-container [data-tab-content="editor"] {
|
||||
@@ -723,13 +500,6 @@ input[type="search"]::-ms-clear {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/**
|
||||
* Style active and inactive tab buttons before hydration
|
||||
* Ensure only the correct tab shows as active
|
||||
* Note: Colors match the ghost button variant for consistency
|
||||
*/
|
||||
|
||||
/* Make only copilot active when it's the active tab */
|
||||
html[data-panel-active-tab="copilot"] .panel-container [data-tab-button="copilot"] {
|
||||
background-color: rgb(53 53 53) !important;
|
||||
color: rgb(230 230 230) !important;
|
||||
@@ -737,10 +507,9 @@ input[type="search"]::-ms-clear {
|
||||
html[data-panel-active-tab="copilot"] .panel-container [data-tab-button="toolbar"],
|
||||
html[data-panel-active-tab="copilot"] .panel-container [data-tab-button="editor"] {
|
||||
background-color: transparent !important;
|
||||
color: rgb(174 174 174) !important; /* Muted gray for inactive tabs */
|
||||
color: rgb(174 174 174) !important;
|
||||
}
|
||||
|
||||
/* Make only toolbar active when it's the active tab */
|
||||
html[data-panel-active-tab="toolbar"] .panel-container [data-tab-button="toolbar"] {
|
||||
background-color: rgb(53 53 53) !important;
|
||||
color: rgb(230 230 230) !important;
|
||||
@@ -748,10 +517,9 @@ input[type="search"]::-ms-clear {
|
||||
html[data-panel-active-tab="toolbar"] .panel-container [data-tab-button="copilot"],
|
||||
html[data-panel-active-tab="toolbar"] .panel-container [data-tab-button="editor"] {
|
||||
background-color: transparent !important;
|
||||
color: rgb(174 174 174) !important; /* Muted gray for inactive tabs */
|
||||
color: rgb(174 174 174) !important;
|
||||
}
|
||||
|
||||
/* Make only editor active when it's the active tab */
|
||||
html[data-panel-active-tab="editor"] .panel-container [data-tab-button="editor"] {
|
||||
background-color: rgb(53 53 53) !important;
|
||||
color: rgb(230 230 230) !important;
|
||||
@@ -759,6 +527,6 @@ input[type="search"]::-ms-clear {
|
||||
html[data-panel-active-tab="editor"] .panel-container [data-tab-button="copilot"],
|
||||
html[data-panel-active-tab="editor"] .panel-container [data-tab-button="toolbar"] {
|
||||
background-color: transparent !important;
|
||||
color: rgb(174 174 174) !important; /* Muted gray for inactive tabs */
|
||||
color: rgb(174 174 174) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,7 +317,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
>
|
||||
<div
|
||||
ref={messageContentRef}
|
||||
className={`relative whitespace-pre-wrap break-words px-[2px] py-1 font-medium font-sans text-[#0D0D0D] text-sm leading-[1.25rem] dark:text-gray-100 ${isSendingMessage && isLastUserMessage ? 'pr-7' : ''} ${!isExpanded && needsExpansion ? 'max-h-[60px] overflow-hidden' : 'overflow-visible'}`}
|
||||
className={`relative whitespace-pre-wrap break-words px-[2px] py-1 font-medium font-sans text-[#0D0D0D] text-sm leading-[1.25rem] dark:text-gray-100 ${isSendingMessage && isLastUserMessage && isHoveringMessage ? 'pr-7' : ''} ${!isExpanded && needsExpansion ? 'max-h-[60px] overflow-hidden' : 'overflow-visible'}`}
|
||||
>
|
||||
{(() => {
|
||||
const text = message.content || ''
|
||||
@@ -374,7 +374,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
title='Stop generation'
|
||||
>
|
||||
<svg
|
||||
className='h-[13px] w-[13px]'
|
||||
className='block h-[13px] w-[13px]'
|
||||
viewBox='0 0 24 24'
|
||||
fill='black'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
|
||||
@@ -695,10 +695,10 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
title='Stop generation'
|
||||
>
|
||||
{isAborting ? (
|
||||
<Loader2 className='h-[13px] w-[13px] animate-spin text-black' />
|
||||
<Loader2 className='block h-[13px] w-[13px] animate-spin text-black' />
|
||||
) : (
|
||||
<svg
|
||||
className='h-[13px] w-[13px]'
|
||||
className='block h-[13px] w-[13px]'
|
||||
viewBox='0 0 24 24'
|
||||
fill='black'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
@@ -719,9 +719,9 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className='h-3.5 w-3.5 animate-spin text-black' />
|
||||
<Loader2 className='block h-3.5 w-3.5 animate-spin text-black' />
|
||||
) : (
|
||||
<ArrowUp className='h-3.5 w-3.5 text-black' strokeWidth={2.25} />
|
||||
<ArrowUp className='block h-3.5 w-3.5 text-black' strokeWidth={2.25} />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { ConnectionBlocks } from './connection-blocks/connection-blocks'
|
||||
export { SubBlock } from './sub-block/sub-block'
|
||||
export { SubflowEditor } from './subflow-editor/subflow-editor'
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
'use client'
|
||||
|
||||
import { ChevronUp } from 'lucide-react'
|
||||
import SimpleCodeEditor from 'react-simple-code-editor'
|
||||
import { Code as CodeEditor, Combobox, getCodeEditorProps, Input, Label } from '@/components/emcn'
|
||||
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
import type { ConnectedBlock } from '../../hooks/use-block-connections'
|
||||
import { useSubflowEditor } from '../../hooks/use-subflow-editor'
|
||||
import { ConnectionBlocks } from '../connection-blocks'
|
||||
|
||||
interface SubflowEditorProps {
|
||||
currentBlock: BlockState
|
||||
currentBlockId: string
|
||||
subBlocksRef: React.RefObject<HTMLDivElement | null>
|
||||
connectionsHeight: number
|
||||
isResizing: boolean
|
||||
hasIncomingConnections: boolean
|
||||
incomingConnections: ConnectedBlock[]
|
||||
handleConnectionsResizeMouseDown: (e: React.MouseEvent) => void
|
||||
toggleConnectionsCollapsed: () => void
|
||||
userCanEdit: boolean
|
||||
isConnectionsAtMinHeight: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* SubflowEditor component for editing loop and parallel blocks
|
||||
*
|
||||
* @param props - Component props
|
||||
* @returns Rendered subflow editor
|
||||
*/
|
||||
export function SubflowEditor({
|
||||
currentBlock,
|
||||
currentBlockId,
|
||||
subBlocksRef,
|
||||
connectionsHeight,
|
||||
isResizing,
|
||||
hasIncomingConnections,
|
||||
incomingConnections,
|
||||
handleConnectionsResizeMouseDown,
|
||||
toggleConnectionsCollapsed,
|
||||
userCanEdit,
|
||||
isConnectionsAtMinHeight,
|
||||
}: SubflowEditorProps) {
|
||||
const {
|
||||
subflowConfig,
|
||||
currentType,
|
||||
isCountMode,
|
||||
isConditionMode,
|
||||
inputValue,
|
||||
editorValue,
|
||||
typeOptions,
|
||||
showTagDropdown,
|
||||
cursorPosition,
|
||||
editorContainerRef,
|
||||
handleSubflowTypeChange,
|
||||
handleSubflowIterationsChange,
|
||||
handleSubflowIterationsSave,
|
||||
handleSubflowEditorChange,
|
||||
handleSubflowTagSelect,
|
||||
highlightWithReferences,
|
||||
setShowTagDropdown,
|
||||
} = useSubflowEditor(currentBlock, currentBlockId)
|
||||
|
||||
if (!subflowConfig) return null
|
||||
|
||||
return (
|
||||
<div className='flex flex-1 flex-col overflow-hidden pt-[0px]'>
|
||||
{/* Subflow Editor Section */}
|
||||
<div ref={subBlocksRef} className='subblocks-section flex flex-1 flex-col overflow-hidden'>
|
||||
<div className='flex-1 overflow-y-auto overflow-x-hidden px-[8px] pt-[5px] pb-[8px]'>
|
||||
{/* Type Selection */}
|
||||
<div>
|
||||
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[#E6E6E6] text-[13px] dark:text-[#E6E6E6]'>
|
||||
{currentBlock.type === 'loop' ? 'Loop Type' : 'Parallel Type'}
|
||||
</Label>
|
||||
<Combobox
|
||||
options={typeOptions}
|
||||
value={currentType || ''}
|
||||
onChange={handleSubflowTypeChange}
|
||||
disabled={!userCanEdit}
|
||||
placeholder='Select type...'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dashed Line Separator */}
|
||||
<div className='px-[2px] pt-[16px] pb-[10px]'>
|
||||
<div
|
||||
className='h-[1.25px]'
|
||||
style={{
|
||||
backgroundImage:
|
||||
'repeating-linear-gradient(to right, #2C2C2C 0px, #2C2C2C 6px, transparent 6px, transparent 12px)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Configuration */}
|
||||
<div>
|
||||
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[#E6E6E6] text-[13px] dark:text-[#E6E6E6]'>
|
||||
{isCountMode
|
||||
? `${currentBlock.type === 'loop' ? 'Loop' : 'Parallel'} Iterations`
|
||||
: isConditionMode
|
||||
? 'While Condition'
|
||||
: `${currentBlock.type === 'loop' ? 'Collection' : 'Parallel'} Items`}
|
||||
</Label>
|
||||
|
||||
{isCountMode ? (
|
||||
<div>
|
||||
<Input
|
||||
type='text'
|
||||
value={inputValue}
|
||||
onChange={handleSubflowIterationsChange}
|
||||
onBlur={handleSubflowIterationsSave}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSubflowIterationsSave()}
|
||||
disabled={!userCanEdit}
|
||||
className='mb-[4px]'
|
||||
/>
|
||||
<div className='text-[10px] text-muted-foreground'>
|
||||
Enter a number between 1 and {subflowConfig.maxIterations}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div ref={editorContainerRef} className='relative'>
|
||||
<CodeEditor.Container>
|
||||
<CodeEditor.Content>
|
||||
<CodeEditor.Placeholder gutterWidth={0} show={editorValue.length === 0}>
|
||||
{isConditionMode ? '<counter.value> < 10' : "['item1', 'item2', 'item3']"}
|
||||
</CodeEditor.Placeholder>
|
||||
|
||||
<SimpleCodeEditor
|
||||
value={editorValue}
|
||||
onValueChange={handleSubflowEditorChange}
|
||||
highlight={highlightWithReferences}
|
||||
{...getCodeEditorProps({
|
||||
isPreview: false,
|
||||
disabled: !userCanEdit,
|
||||
})}
|
||||
/>
|
||||
|
||||
{showTagDropdown && (
|
||||
<TagDropdown
|
||||
visible={showTagDropdown}
|
||||
onSelect={handleSubflowTagSelect}
|
||||
blockId={currentBlockId}
|
||||
activeSourceBlockId={null}
|
||||
inputValue={editorValue}
|
||||
cursorPosition={cursorPosition}
|
||||
onClose={() => setShowTagDropdown(false)}
|
||||
inputRef={{
|
||||
current: editorContainerRef.current?.querySelector(
|
||||
'textarea'
|
||||
) as HTMLTextAreaElement,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</CodeEditor.Content>
|
||||
</CodeEditor.Container>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connections Section - Only show when there are connections */}
|
||||
{hasIncomingConnections && (
|
||||
<div
|
||||
className={
|
||||
'connections-section flex flex-shrink-0 flex-col overflow-hidden border-[#2C2C2C] border-t dark:border-[#2C2C2C]' +
|
||||
(!isResizing ? ' transition-[height] duration-100 ease-out' : '')
|
||||
}
|
||||
style={{ height: `${connectionsHeight}px` }}
|
||||
>
|
||||
{/* Resize Handle */}
|
||||
<div className='relative'>
|
||||
<div
|
||||
className='absolute top-[-4px] right-0 left-0 z-30 h-[8px] cursor-ns-resize'
|
||||
onMouseDown={handleConnectionsResizeMouseDown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Connections Header with Chevron */}
|
||||
<div
|
||||
className='flex flex-shrink-0 cursor-pointer items-center gap-[8px] px-[10px] pt-[5px] pb-[5px]'
|
||||
onClick={toggleConnectionsCollapsed}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
toggleConnectionsCollapsed()
|
||||
}
|
||||
}}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
aria-label={isConnectionsAtMinHeight ? 'Expand connections' : 'Collapse connections'}
|
||||
>
|
||||
<ChevronUp
|
||||
className={
|
||||
'h-[14px] w-[14px] transition-transform' +
|
||||
(!isConnectionsAtMinHeight ? ' rotate-180' : '')
|
||||
}
|
||||
/>
|
||||
<div className='font-medium text-[#E6E6E6] text-[13px] dark:text-[#E6E6E6]'>
|
||||
Connections
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connections Content - Always visible */}
|
||||
<div className='flex-1 overflow-y-auto overflow-x-hidden px-[6px] pb-[8px]'>
|
||||
<ConnectionBlocks connections={incomingConnections} currentBlockId={currentBlock.id} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { BookOpen, ChevronUp, Crosshair, Settings } from 'lucide-react'
|
||||
import { BookOpen, ChevronUp, Crosshair, RepeatIcon, Settings, SplitIcon } from 'lucide-react'
|
||||
import { Button, Tooltip } from '@/components/emcn'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { getSubBlockStableKey } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils'
|
||||
@@ -12,7 +12,7 @@ import { useFocusOnBlock } from '@/hooks/use-focus-on-block'
|
||||
import { usePanelEditorStore } from '@/stores/panel-new/editor/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { ConnectionBlocks, SubBlock } from './components'
|
||||
import { ConnectionBlocks, SubBlock, SubflowEditor } from './components'
|
||||
import {
|
||||
useBlockConnections,
|
||||
useConnectionsResize,
|
||||
@@ -45,6 +45,14 @@ export function Editor() {
|
||||
const blockConfig = currentBlock ? getBlock(currentBlock.type) : null
|
||||
const title = currentBlock?.name || 'Editor'
|
||||
|
||||
// Check if selected block is a subflow (loop or parallel)
|
||||
const isSubflow =
|
||||
currentBlock && (currentBlock.type === 'loop' || currentBlock.type === 'parallel')
|
||||
|
||||
// Get subflow display properties
|
||||
const subflowIcon = isSubflow && currentBlock.type === 'loop' ? RepeatIcon : SplitIcon
|
||||
const subflowBgColor = isSubflow && currentBlock.type === 'loop' ? '#2FB3FF' : '#FEE12B'
|
||||
|
||||
// Refs for resize functionality
|
||||
const subBlocksRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -126,12 +134,15 @@ export function Editor() {
|
||||
{/* Header */}
|
||||
<div className='flex flex-shrink-0 items-center justify-between rounded-[4px] bg-[#2A2A2A] px-[12px] py-[8px] dark:bg-[#2A2A2A]'>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
|
||||
{blockConfig && (
|
||||
{(blockConfig || isSubflow) && (
|
||||
<div
|
||||
className='flex h-[18px] w-[18px] items-center justify-center rounded-[4px]'
|
||||
style={{ backgroundColor: blockConfig.bgColor }}
|
||||
style={{ backgroundColor: isSubflow ? subflowBgColor : blockConfig?.bgColor }}
|
||||
>
|
||||
<IconComponent icon={blockConfig.icon} className='h-[12px] w-[12px] text-[#FFFFFF]' />
|
||||
<IconComponent
|
||||
icon={isSubflow ? subflowIcon : blockConfig?.icon}
|
||||
className='h-[12px] w-[12px] text-[#FFFFFF]'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<h2
|
||||
@@ -160,8 +171,8 @@ export function Editor() {
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
{/* Mode toggles */}
|
||||
{currentBlock && hasAdvancedMode && (
|
||||
{/* Mode toggles - Only show for regular blocks, not subflows */}
|
||||
{currentBlock && !isSubflow && hasAdvancedMode && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
@@ -179,7 +190,7 @@ export function Editor() {
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
{currentBlock && blockConfig?.docsLink && (
|
||||
{currentBlock && !isSubflow && blockConfig?.docsLink && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
@@ -203,6 +214,20 @@ export function Editor() {
|
||||
<div className='flex flex-1 items-center justify-center text-[#8D8D8D] text-[13px]'>
|
||||
Select a block to edit
|
||||
</div>
|
||||
) : isSubflow ? (
|
||||
<SubflowEditor
|
||||
currentBlock={currentBlock}
|
||||
currentBlockId={currentBlockId}
|
||||
subBlocksRef={subBlocksRef}
|
||||
connectionsHeight={connectionsHeight}
|
||||
isResizing={isResizing}
|
||||
hasIncomingConnections={hasIncomingConnections}
|
||||
incomingConnections={incomingConnections}
|
||||
handleConnectionsResizeMouseDown={handleConnectionsResizeMouseDown}
|
||||
toggleConnectionsCollapsed={toggleConnectionsCollapsed}
|
||||
userCanEdit={userPermissions.canEdit}
|
||||
isConnectionsAtMinHeight={isConnectionsAtMinHeight}
|
||||
/>
|
||||
) : (
|
||||
<div className='flex flex-1 flex-col overflow-hidden pt-[0px]'>
|
||||
{/* Subblocks Section */}
|
||||
|
||||
@@ -2,3 +2,4 @@ export { useBlockConnections } from './use-block-connections'
|
||||
export { useConnectionsResize } from './use-connections-resize'
|
||||
export { useEditorBlockProperties } from './use-editor-block-properties'
|
||||
export { useEditorSubblockLayout } from './use-editor-subblock-layout'
|
||||
export { useSubflowEditor } from './use-subflow-editor'
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { highlight, languages } from '@/components/emcn'
|
||||
import {
|
||||
isLikelyReferenceSegment,
|
||||
SYSTEM_REFERENCE_PREFIXES,
|
||||
splitReferenceSegment,
|
||||
} from '@/lib/workflows/references'
|
||||
import { checkTagTrigger } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown'
|
||||
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { normalizeBlockName } from '@/stores/workflows/utils'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
/**
|
||||
* Configuration for subflow types (loop and parallel)
|
||||
*/
|
||||
const SUBFLOW_CONFIG = {
|
||||
loop: {
|
||||
typeLabels: {
|
||||
for: 'For Loop',
|
||||
forEach: 'For Each',
|
||||
while: 'While Loop',
|
||||
doWhile: 'Do While Loop',
|
||||
},
|
||||
typeKey: 'loopType' as const,
|
||||
storeKey: 'loops' as const,
|
||||
maxIterations: 100,
|
||||
configKeys: {
|
||||
iterations: 'iterations' as const,
|
||||
items: 'forEachItems' as const,
|
||||
condition: 'whileCondition' as const,
|
||||
} as any,
|
||||
},
|
||||
parallel: {
|
||||
typeLabels: { count: 'Parallel Count', collection: 'Parallel Each' },
|
||||
typeKey: 'parallelType' as const,
|
||||
storeKey: 'parallels' as const,
|
||||
maxIterations: 20,
|
||||
configKeys: {
|
||||
iterations: 'count' as const,
|
||||
items: 'distribution' as const,
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Hook for managing subflow editor state and logic
|
||||
*
|
||||
* @param currentBlock - The current block being edited
|
||||
* @param currentBlockId - The ID of the current block
|
||||
* @returns Subflow editor state and handlers
|
||||
*/
|
||||
export function useSubflowEditor(currentBlock: BlockState | null, currentBlockId: string | null) {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
const editorContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// State
|
||||
const [tempInputValue, setTempInputValue] = useState<string | null>(null)
|
||||
const [showTagDropdown, setShowTagDropdown] = useState(false)
|
||||
const [cursorPosition, setCursorPosition] = useState(0)
|
||||
|
||||
// Check if current block is a subflow
|
||||
const isSubflow =
|
||||
currentBlock && (currentBlock.type === 'loop' || currentBlock.type === 'parallel')
|
||||
|
||||
// Get subflow configuration
|
||||
const subflowConfig = isSubflow ? SUBFLOW_CONFIG[currentBlock.type as 'loop' | 'parallel'] : null
|
||||
const nodeConfig = isSubflow ? workflowStore[subflowConfig!.storeKey][currentBlockId!] : null
|
||||
|
||||
// Get block data for fallback values
|
||||
const blockData = isSubflow ? currentBlock?.data : null
|
||||
|
||||
// Get accessible prefixes for tag dropdown
|
||||
const accessiblePrefixes = useAccessibleReferencePrefixes(currentBlockId || '')
|
||||
|
||||
// Collaborative actions
|
||||
const {
|
||||
collaborativeUpdateLoopType,
|
||||
collaborativeUpdateParallelType,
|
||||
collaborativeUpdateIterationCount,
|
||||
collaborativeUpdateIterationCollection,
|
||||
} = useCollaborativeWorkflow()
|
||||
|
||||
/**
|
||||
* Checks if a reference should be highlighted based on accessible prefixes
|
||||
*/
|
||||
const shouldHighlightReference = useCallback(
|
||||
(part: string): boolean => {
|
||||
if (!part.startsWith('<') || !part.endsWith('>')) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!isLikelyReferenceSegment(part)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const split = splitReferenceSegment(part)
|
||||
if (!split) {
|
||||
return false
|
||||
}
|
||||
|
||||
const reference = split.reference
|
||||
|
||||
if (!accessiblePrefixes) {
|
||||
return true
|
||||
}
|
||||
|
||||
const inner = reference.slice(1, -1)
|
||||
const [prefix] = inner.split('.')
|
||||
const normalizedPrefix = normalizeBlockName(prefix)
|
||||
|
||||
if (SYSTEM_REFERENCE_PREFIXES.has(normalizedPrefix)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return accessiblePrefixes.has(normalizedPrefix)
|
||||
},
|
||||
[accessiblePrefixes]
|
||||
)
|
||||
|
||||
/**
|
||||
* Highlights code with references and environment variables
|
||||
*/
|
||||
const highlightWithReferences = useCallback(
|
||||
(code: string): string => {
|
||||
const placeholders: Array<{
|
||||
placeholder: string
|
||||
original: string
|
||||
type: 'var' | 'env'
|
||||
}> = []
|
||||
|
||||
let processedCode = code
|
||||
|
||||
processedCode = processedCode.replace(/\{\{([^}]+)\}\}/g, (match) => {
|
||||
const placeholder = `__ENV_VAR_${placeholders.length}__`
|
||||
placeholders.push({ placeholder, original: match, type: 'env' })
|
||||
return placeholder
|
||||
})
|
||||
|
||||
processedCode = processedCode.replace(/<[^>]+>/g, (match) => {
|
||||
if (shouldHighlightReference(match)) {
|
||||
const placeholder = `__VAR_REF_${placeholders.length}__`
|
||||
placeholders.push({ placeholder, original: match, type: 'var' })
|
||||
return placeholder
|
||||
}
|
||||
return match
|
||||
})
|
||||
|
||||
let highlightedCode = highlight(processedCode, languages.javascript, 'javascript')
|
||||
|
||||
placeholders.forEach(({ placeholder, original, type }) => {
|
||||
if (type === 'env') {
|
||||
highlightedCode = highlightedCode.replace(
|
||||
placeholder,
|
||||
`<span class="text-blue-500">${original}</span>`
|
||||
)
|
||||
} else {
|
||||
const escaped = original.replace(/</g, '<').replace(/>/g, '>')
|
||||
highlightedCode = highlightedCode.replace(
|
||||
placeholder,
|
||||
`<span class="text-blue-500">${escaped}</span>`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return highlightedCode
|
||||
},
|
||||
[shouldHighlightReference]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handle subflow type change (loop type or parallel type)
|
||||
*/
|
||||
const handleSubflowTypeChange = useCallback(
|
||||
(newType: string) => {
|
||||
if (!currentBlockId || !isSubflow || !currentBlock) return
|
||||
if (currentBlock.type === 'loop') {
|
||||
collaborativeUpdateLoopType(
|
||||
currentBlockId,
|
||||
newType as 'for' | 'forEach' | 'while' | 'doWhile'
|
||||
)
|
||||
} else {
|
||||
collaborativeUpdateParallelType(currentBlockId, newType as 'count' | 'collection')
|
||||
}
|
||||
},
|
||||
[
|
||||
currentBlockId,
|
||||
isSubflow,
|
||||
currentBlock,
|
||||
collaborativeUpdateLoopType,
|
||||
collaborativeUpdateParallelType,
|
||||
]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handle iterations input change
|
||||
*/
|
||||
const handleSubflowIterationsChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!subflowConfig) return
|
||||
const sanitizedValue = e.target.value.replace(/[^0-9]/g, '')
|
||||
const numValue = Number.parseInt(sanitizedValue)
|
||||
|
||||
if (!Number.isNaN(numValue)) {
|
||||
setTempInputValue(Math.min(subflowConfig.maxIterations, numValue).toString())
|
||||
} else {
|
||||
setTempInputValue(sanitizedValue)
|
||||
}
|
||||
},
|
||||
[subflowConfig]
|
||||
)
|
||||
|
||||
/**
|
||||
* Save iterations value
|
||||
*/
|
||||
const handleSubflowIterationsSave = useCallback(() => {
|
||||
if (!currentBlockId || !isSubflow || !subflowConfig || !currentBlock) return
|
||||
const value = Number.parseInt(tempInputValue ?? '5')
|
||||
|
||||
if (!Number.isNaN(value)) {
|
||||
const newValue = Math.min(subflowConfig.maxIterations, Math.max(1, value))
|
||||
collaborativeUpdateIterationCount(
|
||||
currentBlockId,
|
||||
currentBlock.type as 'loop' | 'parallel',
|
||||
newValue
|
||||
)
|
||||
}
|
||||
setTempInputValue(null)
|
||||
}, [
|
||||
tempInputValue,
|
||||
currentBlockId,
|
||||
isSubflow,
|
||||
subflowConfig,
|
||||
currentBlock,
|
||||
collaborativeUpdateIterationCount,
|
||||
])
|
||||
|
||||
/**
|
||||
* Handle editor value change (collection/condition)
|
||||
*/
|
||||
const handleSubflowEditorChange = useCallback(
|
||||
(value: string) => {
|
||||
if (!currentBlockId || !isSubflow || !currentBlock) return
|
||||
collaborativeUpdateIterationCollection(
|
||||
currentBlockId,
|
||||
currentBlock.type as 'loop' | 'parallel',
|
||||
value
|
||||
)
|
||||
|
||||
const textarea = editorContainerRef.current?.querySelector('textarea')
|
||||
if (textarea) {
|
||||
textareaRef.current = textarea
|
||||
const cursorPos = textarea.selectionStart || 0
|
||||
setCursorPosition(cursorPos)
|
||||
|
||||
const triggerCheck = checkTagTrigger(value, cursorPos)
|
||||
setShowTagDropdown(triggerCheck.show)
|
||||
}
|
||||
},
|
||||
[currentBlockId, isSubflow, currentBlock, collaborativeUpdateIterationCollection]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handle tag selection from dropdown
|
||||
*/
|
||||
const handleSubflowTagSelect = useCallback(
|
||||
(newValue: string) => {
|
||||
if (!currentBlockId || !isSubflow || !currentBlock) return
|
||||
collaborativeUpdateIterationCollection(
|
||||
currentBlockId,
|
||||
currentBlock.type as 'loop' | 'parallel',
|
||||
newValue
|
||||
)
|
||||
setShowTagDropdown(false)
|
||||
|
||||
setTimeout(() => {
|
||||
const textarea = textareaRef.current
|
||||
if (textarea) {
|
||||
textarea.focus()
|
||||
}
|
||||
}, 0)
|
||||
},
|
||||
[currentBlockId, isSubflow, currentBlock, collaborativeUpdateIterationCollection]
|
||||
)
|
||||
|
||||
// Compute derived values
|
||||
const currentType =
|
||||
isSubflow && subflowConfig
|
||||
? (nodeConfig as any)?.[subflowConfig.typeKey] ||
|
||||
(blockData as any)?.[subflowConfig.typeKey] ||
|
||||
(currentBlock!.type === 'loop' ? 'for' : 'count')
|
||||
: null
|
||||
|
||||
const isCountMode = currentType === 'for' || currentType === 'count'
|
||||
const isConditionMode = currentType === 'while' || currentType === 'doWhile'
|
||||
|
||||
const configIterations =
|
||||
isSubflow && subflowConfig
|
||||
? ((nodeConfig as any)?.[subflowConfig.configKeys.iterations] ??
|
||||
(blockData as any)?.count ??
|
||||
5)
|
||||
: 5
|
||||
|
||||
const configCollection =
|
||||
isSubflow && subflowConfig
|
||||
? ((nodeConfig as any)?.[subflowConfig.configKeys.items] ??
|
||||
(blockData as any)?.collection ??
|
||||
'')
|
||||
: ''
|
||||
|
||||
const conditionKey =
|
||||
currentType === 'while'
|
||||
? 'whileCondition'
|
||||
: currentType === 'doWhile'
|
||||
? 'doWhileCondition'
|
||||
: null
|
||||
|
||||
const configCondition =
|
||||
isSubflow && conditionKey
|
||||
? ((nodeConfig as any)?.[conditionKey] ?? (blockData as any)?.[conditionKey] ?? '')
|
||||
: ''
|
||||
|
||||
const iterations = configIterations
|
||||
const collectionString =
|
||||
typeof configCollection === 'string' ? configCollection : JSON.stringify(configCollection) || ''
|
||||
const conditionString = typeof configCondition === 'string' ? configCondition : ''
|
||||
|
||||
const inputValue = tempInputValue ?? iterations.toString()
|
||||
const editorValue = isConditionMode ? conditionString : collectionString
|
||||
|
||||
// Type options for combobox
|
||||
const typeOptions =
|
||||
isSubflow && subflowConfig
|
||||
? Object.entries(subflowConfig.typeLabels).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}))
|
||||
: []
|
||||
|
||||
return {
|
||||
// State
|
||||
isSubflow,
|
||||
subflowConfig,
|
||||
currentType,
|
||||
isCountMode,
|
||||
isConditionMode,
|
||||
inputValue,
|
||||
editorValue,
|
||||
typeOptions,
|
||||
showTagDropdown,
|
||||
cursorPosition,
|
||||
textareaRef,
|
||||
editorContainerRef,
|
||||
|
||||
// Handlers
|
||||
handleSubflowTypeChange,
|
||||
handleSubflowIterationsChange,
|
||||
handleSubflowIterationsSave,
|
||||
handleSubflowEditorChange,
|
||||
handleSubflowTagSelect,
|
||||
highlightWithReferences,
|
||||
setShowTagDropdown,
|
||||
}
|
||||
}
|
||||
@@ -403,6 +403,10 @@ export function Toolbar({ isActive = true }: ToolbarProps) {
|
||||
key={block.type}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
// Mark subflow drag explicitly so canvas can reliably detect and suppress highlight
|
||||
if (block.type === 'loop' || block.type === 'parallel') {
|
||||
document.body.classList.add('sim-drag-subflow')
|
||||
}
|
||||
const iconElement = e.currentTarget.querySelector('.toolbar-item-icon')
|
||||
handleDragStart(e, block.type, false, {
|
||||
name: block.name,
|
||||
@@ -410,6 +414,10 @@ export function Toolbar({ isActive = true }: ToolbarProps) {
|
||||
iconElement: iconElement as HTMLElement | null,
|
||||
})
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
// Always clear the flag at the end of a toolbar drag
|
||||
document.body.classList.remove('sim-drag-subflow')
|
||||
}}
|
||||
onClick={() => handleItemClick(block.type, false)}
|
||||
className={clsx(
|
||||
'group flex h-[25px] items-center gap-[8px] rounded-[8px] px-[5.5px] text-[14px]',
|
||||
|
||||
@@ -1,386 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { LoopType, ParallelType } from '@/lib/workflows/types'
|
||||
|
||||
// Mock hooks
|
||||
const mockCollaborativeUpdates = {
|
||||
collaborativeUpdateLoopType: vi.fn(),
|
||||
collaborativeUpdateParallelType: vi.fn(),
|
||||
collaborativeUpdateIterationCount: vi.fn(),
|
||||
collaborativeUpdateIterationCollection: vi.fn(),
|
||||
}
|
||||
|
||||
const mockStoreData = {
|
||||
loops: {},
|
||||
parallels: {},
|
||||
}
|
||||
|
||||
vi.mock('@/hooks/use-collaborative-workflow', () => ({
|
||||
useCollaborativeWorkflow: () => mockCollaborativeUpdates,
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workflows/workflow/store', () => ({
|
||||
useWorkflowStore: () => mockStoreData,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/badge', () => ({
|
||||
Badge: ({ children, ...props }: any) => (
|
||||
<div data-testid='badge' {...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/input', () => ({
|
||||
Input: (props: any) => <input data-testid='input' {...props} />,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/popover', () => ({
|
||||
Popover: ({ children }: any) => <div data-testid='popover'>{children}</div>,
|
||||
PopoverContent: ({ children }: any) => <div data-testid='popover-content'>{children}</div>,
|
||||
PopoverTrigger: ({ children }: any) => <div data-testid='popover-trigger'>{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/tag-dropdown', () => ({
|
||||
checkTagTrigger: vi.fn(() => ({ show: false })),
|
||||
TagDropdown: ({ children }: any) => <div data-testid='tag-dropdown'>{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('react-simple-code-editor', () => ({
|
||||
default: (props: any) => <textarea data-testid='code-editor' {...props} />,
|
||||
}))
|
||||
|
||||
describe('IterationBadges', () => {
|
||||
const defaultProps = {
|
||||
nodeId: 'test-node-1',
|
||||
data: {
|
||||
width: 500,
|
||||
height: 300,
|
||||
isPreview: false,
|
||||
},
|
||||
iterationType: 'loop' as const,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockStoreData.loops = {}
|
||||
mockStoreData.parallels = {}
|
||||
})
|
||||
|
||||
describe('Component Interface', () => {
|
||||
it.concurrent('should accept required props', () => {
|
||||
expect(defaultProps.nodeId).toBeDefined()
|
||||
expect(defaultProps.data).toBeDefined()
|
||||
expect(defaultProps.iterationType).toBeDefined()
|
||||
})
|
||||
|
||||
it.concurrent('should handle loop iteration type prop', () => {
|
||||
const loopProps = { ...defaultProps, iterationType: 'loop' as const }
|
||||
expect(loopProps.iterationType).toBe('loop')
|
||||
})
|
||||
|
||||
it.concurrent('should handle parallel iteration type prop', () => {
|
||||
const parallelProps = { ...defaultProps, iterationType: 'parallel' as const }
|
||||
expect(parallelProps.iterationType).toBe('parallel')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Configuration System', () => {
|
||||
it.concurrent('should use correct config for loop type', () => {
|
||||
const CONFIG = {
|
||||
loop: {
|
||||
typeLabels: { for: 'For Loop', forEach: 'For Each' },
|
||||
typeKey: 'loopType' as const,
|
||||
storeKey: 'loops' as const,
|
||||
maxIterations: 100,
|
||||
configKeys: {
|
||||
iterations: 'iterations' as const,
|
||||
items: 'forEachItems' as const,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
expect(CONFIG.loop.typeLabels.for).toBe('For Loop')
|
||||
expect(CONFIG.loop.typeLabels.forEach).toBe('For Each')
|
||||
expect(CONFIG.loop.maxIterations).toBe(100)
|
||||
expect(CONFIG.loop.storeKey).toBe('loops')
|
||||
})
|
||||
|
||||
it.concurrent('should use correct config for parallel type', () => {
|
||||
const CONFIG = {
|
||||
parallel: {
|
||||
typeLabels: { count: 'Parallel Count', collection: 'Parallel Each' },
|
||||
typeKey: 'parallelType' as const,
|
||||
storeKey: 'parallels' as const,
|
||||
maxIterations: 20,
|
||||
configKeys: {
|
||||
iterations: 'count' as const,
|
||||
items: 'distribution' as const,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
expect(CONFIG.parallel.typeLabels.count).toBe('Parallel Count')
|
||||
expect(CONFIG.parallel.typeLabels.collection).toBe('Parallel Each')
|
||||
expect(CONFIG.parallel.maxIterations).toBe(20)
|
||||
expect(CONFIG.parallel.storeKey).toBe('parallels')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Type Determination Logic', () => {
|
||||
it.concurrent('should default to "for" for loop type', () => {
|
||||
type IterationType = 'loop' | 'parallel'
|
||||
const determineDefaultType = (iterationType: IterationType) => {
|
||||
return iterationType === 'loop' ? 'for' : 'count'
|
||||
}
|
||||
|
||||
const currentType = determineDefaultType('loop')
|
||||
expect(currentType).toBe('for')
|
||||
})
|
||||
|
||||
it.concurrent('should default to "count" for parallel type', () => {
|
||||
type IterationType = 'loop' | 'parallel'
|
||||
const determineDefaultType = (iterationType: IterationType) => {
|
||||
return iterationType === 'loop' ? 'for' : 'count'
|
||||
}
|
||||
|
||||
const currentType = determineDefaultType('parallel')
|
||||
expect(currentType).toBe('count')
|
||||
})
|
||||
|
||||
it.concurrent('should use explicit loopType when provided', () => {
|
||||
type IterationType = 'loop' | 'parallel'
|
||||
const determineType = (explicitType: string | undefined, iterationType: IterationType) => {
|
||||
return explicitType || (iterationType === 'loop' ? 'for' : 'count')
|
||||
}
|
||||
|
||||
const currentType = determineType('forEach', 'loop')
|
||||
expect(currentType).toBe('forEach')
|
||||
})
|
||||
|
||||
it.concurrent('should use explicit parallelType when provided', () => {
|
||||
type IterationType = 'loop' | 'parallel'
|
||||
const determineType = (explicitType: string | undefined, iterationType: IterationType) => {
|
||||
return explicitType || (iterationType === 'loop' ? 'for' : 'count')
|
||||
}
|
||||
|
||||
const currentType = determineType('collection', 'parallel')
|
||||
expect(currentType).toBe('collection')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Count Mode Detection', () => {
|
||||
it.concurrent('should be in count mode for loop + for combination', () => {
|
||||
type IterationType = 'loop' | 'parallel'
|
||||
|
||||
const iterationType: IterationType = 'loop'
|
||||
const currentType: LoopType = 'for'
|
||||
const isCountMode = iterationType === 'loop' && currentType === 'for'
|
||||
|
||||
expect(isCountMode).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should be in count mode for parallel + count combination', () => {
|
||||
type IterationType = 'loop' | 'parallel'
|
||||
|
||||
const iterationType: IterationType = 'parallel'
|
||||
const currentType: ParallelType = 'count'
|
||||
const isCountMode = iterationType === 'parallel' && currentType === 'count'
|
||||
|
||||
expect(isCountMode).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should not be in count mode for loop + forEach combination', () => {
|
||||
type IterationType = 'loop' | 'parallel'
|
||||
|
||||
const testCountMode = (iterationType: IterationType, currentType: string) => {
|
||||
return iterationType === 'loop' && currentType === 'for'
|
||||
}
|
||||
|
||||
const isCountMode = testCountMode('loop', 'forEach')
|
||||
expect(isCountMode).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should not be in count mode for parallel + collection combination', () => {
|
||||
type IterationType = 'loop' | 'parallel'
|
||||
|
||||
const testCountMode = (iterationType: IterationType, currentType: string) => {
|
||||
return iterationType === 'parallel' && currentType === 'count'
|
||||
}
|
||||
|
||||
const isCountMode = testCountMode('parallel', 'collection')
|
||||
expect(isCountMode).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Configuration Values', () => {
|
||||
it.concurrent('should handle default iteration count', () => {
|
||||
const data = { count: undefined }
|
||||
const configIterations = data.count ?? 5
|
||||
expect(configIterations).toBe(5)
|
||||
})
|
||||
|
||||
it.concurrent('should use provided iteration count', () => {
|
||||
const data = { count: 10 }
|
||||
const configIterations = data.count ?? 5
|
||||
expect(configIterations).toBe(10)
|
||||
})
|
||||
|
||||
it.concurrent('should handle string collection', () => {
|
||||
const collection = '[1, 2, 3, 4, 5]'
|
||||
const collectionString =
|
||||
typeof collection === 'string' ? collection : JSON.stringify(collection) || ''
|
||||
expect(collectionString).toBe('[1, 2, 3, 4, 5]')
|
||||
})
|
||||
|
||||
it.concurrent('should handle object collection', () => {
|
||||
const collection = { items: [1, 2, 3] }
|
||||
const collectionString =
|
||||
typeof collection === 'string' ? collection : JSON.stringify(collection) || ''
|
||||
expect(collectionString).toBe('{"items":[1,2,3]}')
|
||||
})
|
||||
|
||||
it.concurrent('should handle array collection', () => {
|
||||
const collection = [1, 2, 3, 4, 5]
|
||||
const collectionString =
|
||||
typeof collection === 'string' ? collection : JSON.stringify(collection) || ''
|
||||
expect(collectionString).toBe('[1,2,3,4,5]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Preview Mode Handling', () => {
|
||||
it.concurrent('should handle preview mode for loops', () => {
|
||||
const previewProps = {
|
||||
...defaultProps,
|
||||
data: { ...defaultProps.data, isPreview: true },
|
||||
iterationType: 'loop' as const,
|
||||
}
|
||||
|
||||
expect(previewProps.data.isPreview).toBe(true)
|
||||
// In preview mode, collaborative functions shouldn't be called
|
||||
expect(mockCollaborativeUpdates.collaborativeUpdateLoopType).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.concurrent('should handle preview mode for parallels', () => {
|
||||
const previewProps = {
|
||||
...defaultProps,
|
||||
data: { ...defaultProps.data, isPreview: true },
|
||||
iterationType: 'parallel' as const,
|
||||
}
|
||||
|
||||
expect(previewProps.data.isPreview).toBe(true)
|
||||
// In preview mode, collaborative functions shouldn't be called
|
||||
expect(mockCollaborativeUpdates.collaborativeUpdateParallelType).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Store Integration', () => {
|
||||
it.concurrent('should access loops store for loop iteration type', () => {
|
||||
const nodeId = 'loop-node-1'
|
||||
;(mockStoreData.loops as any)[nodeId] = { iterations: 10 }
|
||||
|
||||
const nodeConfig = (mockStoreData.loops as any)[nodeId]
|
||||
expect(nodeConfig).toBeDefined()
|
||||
expect(nodeConfig.iterations).toBe(10)
|
||||
})
|
||||
|
||||
it.concurrent('should access parallels store for parallel iteration type', () => {
|
||||
const nodeId = 'parallel-node-1'
|
||||
;(mockStoreData.parallels as any)[nodeId] = { count: 5 }
|
||||
|
||||
const nodeConfig = (mockStoreData.parallels as any)[nodeId]
|
||||
expect(nodeConfig).toBeDefined()
|
||||
expect(nodeConfig.count).toBe(5)
|
||||
})
|
||||
|
||||
it.concurrent('should handle missing node configuration gracefully', () => {
|
||||
const nodeId = 'missing-node'
|
||||
const nodeConfig = (mockStoreData.loops as any)[nodeId]
|
||||
expect(nodeConfig).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Max Iterations Limits', () => {
|
||||
it.concurrent('should enforce max iterations for loops (100)', () => {
|
||||
const maxIterations = 100
|
||||
const testValue = 150
|
||||
const clampedValue = Math.min(maxIterations, testValue)
|
||||
expect(clampedValue).toBe(100)
|
||||
})
|
||||
|
||||
it.concurrent('should enforce max iterations for parallels (20)', () => {
|
||||
const maxIterations = 20
|
||||
const testValue = 50
|
||||
const clampedValue = Math.min(maxIterations, testValue)
|
||||
expect(clampedValue).toBe(20)
|
||||
})
|
||||
|
||||
it.concurrent('should allow values within limits', () => {
|
||||
const loopMaxIterations = 100
|
||||
const parallelMaxIterations = 20
|
||||
|
||||
expect(Math.min(loopMaxIterations, 50)).toBe(50)
|
||||
expect(Math.min(parallelMaxIterations, 10)).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Collaborative Update Functions', () => {
|
||||
it.concurrent('should have the correct collaborative functions available', () => {
|
||||
expect(mockCollaborativeUpdates.collaborativeUpdateLoopType).toBeDefined()
|
||||
expect(mockCollaborativeUpdates.collaborativeUpdateParallelType).toBeDefined()
|
||||
expect(mockCollaborativeUpdates.collaborativeUpdateIterationCount).toBeDefined()
|
||||
expect(mockCollaborativeUpdates.collaborativeUpdateIterationCollection).toBeDefined()
|
||||
})
|
||||
|
||||
it.concurrent('should call correct function for loop type updates', () => {
|
||||
const handleTypeChange = (newType: string, iterationType: string, nodeId: string) => {
|
||||
if (iterationType === 'loop') {
|
||||
mockCollaborativeUpdates.collaborativeUpdateLoopType(nodeId, newType)
|
||||
} else {
|
||||
mockCollaborativeUpdates.collaborativeUpdateParallelType(nodeId, newType)
|
||||
}
|
||||
}
|
||||
|
||||
handleTypeChange('forEach', 'loop', 'test-node')
|
||||
expect(mockCollaborativeUpdates.collaborativeUpdateLoopType).toHaveBeenCalledWith(
|
||||
'test-node',
|
||||
'forEach'
|
||||
)
|
||||
})
|
||||
|
||||
it.concurrent('should call correct function for parallel type updates', () => {
|
||||
const handleTypeChange = (newType: string, iterationType: string, nodeId: string) => {
|
||||
if (iterationType === 'loop') {
|
||||
mockCollaborativeUpdates.collaborativeUpdateLoopType(nodeId, newType)
|
||||
} else {
|
||||
mockCollaborativeUpdates.collaborativeUpdateParallelType(nodeId, newType)
|
||||
}
|
||||
}
|
||||
|
||||
handleTypeChange('collection', 'parallel', 'test-node')
|
||||
expect(mockCollaborativeUpdates.collaborativeUpdateParallelType).toHaveBeenCalledWith(
|
||||
'test-node',
|
||||
'collection'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Input Sanitization', () => {
|
||||
it.concurrent('should sanitize numeric input by removing non-digits', () => {
|
||||
const testInput = 'abc123def456'
|
||||
const sanitized = testInput.replace(/[^0-9]/g, '')
|
||||
expect(sanitized).toBe('123456')
|
||||
})
|
||||
|
||||
it.concurrent('should handle empty input', () => {
|
||||
const testInput = ''
|
||||
const sanitized = testInput.replace(/[^0-9]/g, '')
|
||||
expect(sanitized).toBe('')
|
||||
})
|
||||
|
||||
it.concurrent('should preserve valid numeric input', () => {
|
||||
const testInput = '42'
|
||||
const sanitized = testInput.replace(/[^0-9]/g, '')
|
||||
expect(sanitized).toBe('42')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,495 +0,0 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import Editor from 'react-simple-code-editor'
|
||||
import { highlight, languages } from '@/components/emcn'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
checkTagTrigger,
|
||||
TagDropdown,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import 'prismjs/components/prism-javascript'
|
||||
import 'prismjs/themes/prism.css'
|
||||
import {
|
||||
isLikelyReferenceSegment,
|
||||
SYSTEM_REFERENCE_PREFIXES,
|
||||
splitReferenceSegment,
|
||||
} from '@/lib/workflows/references'
|
||||
import type { LoopType, ParallelType } from '@/lib/workflows/types'
|
||||
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
||||
import { normalizeBlockName } from '@/stores/workflows/utils'
|
||||
|
||||
type IterationType = 'loop' | 'parallel'
|
||||
|
||||
interface IterationNodeData {
|
||||
width?: number
|
||||
height?: number
|
||||
parentId?: string
|
||||
state?: string
|
||||
type?: string
|
||||
extent?: 'parent'
|
||||
loopType?: LoopType
|
||||
parallelType?: ParallelType
|
||||
// Common
|
||||
count?: number
|
||||
collection?: string | any[] | Record<string, any>
|
||||
isPreview?: boolean
|
||||
executionState?: {
|
||||
currentIteration?: number
|
||||
currentExecution?: number
|
||||
isExecuting: boolean
|
||||
startTime: number | null
|
||||
endTime: number | null
|
||||
}
|
||||
}
|
||||
|
||||
interface IterationBadgesProps {
|
||||
nodeId: string
|
||||
data: IterationNodeData
|
||||
iterationType: IterationType
|
||||
}
|
||||
|
||||
const CONFIG = {
|
||||
loop: {
|
||||
typeLabels: {
|
||||
for: 'For Loop',
|
||||
forEach: 'For Each',
|
||||
while: 'While Loop',
|
||||
doWhile: 'Do While Loop',
|
||||
},
|
||||
typeKey: 'loopType' as const,
|
||||
storeKey: 'loops' as const,
|
||||
maxIterations: 100,
|
||||
configKeys: {
|
||||
iterations: 'iterations' as const,
|
||||
items: 'forEachItems' as const,
|
||||
condition: 'whileCondition' as const,
|
||||
} as any,
|
||||
},
|
||||
parallel: {
|
||||
typeLabels: { count: 'Parallel Count', collection: 'Parallel Each' },
|
||||
typeKey: 'parallelType' as const,
|
||||
storeKey: 'parallels' as const,
|
||||
maxIterations: 20,
|
||||
configKeys: {
|
||||
iterations: 'count' as const,
|
||||
items: 'distribution' as const,
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export function IterationBadges({ nodeId, data, iterationType }: IterationBadgesProps) {
|
||||
const config = CONFIG[iterationType]
|
||||
const isPreview = data?.isPreview || false
|
||||
|
||||
// Get configuration from the workflow store
|
||||
const store = useWorkflowStore()
|
||||
const nodeConfig = store[config.storeKey][nodeId]
|
||||
|
||||
// Determine current type and values
|
||||
const currentType = (data?.[config.typeKey] ||
|
||||
(iterationType === 'loop' ? 'for' : 'count')) as any
|
||||
|
||||
// Determine if we're in count mode, collection mode, or condition mode
|
||||
const isCountMode =
|
||||
(iterationType === 'loop' && currentType === 'for') ||
|
||||
(iterationType === 'parallel' && currentType === 'count')
|
||||
const isConditionMode =
|
||||
iterationType === 'loop' && (currentType === 'while' || currentType === 'doWhile')
|
||||
|
||||
const configIterations = (nodeConfig as any)?.[config.configKeys.iterations] ?? data?.count ?? 5
|
||||
const configCollection = (nodeConfig as any)?.[config.configKeys.items] ?? data?.collection ?? ''
|
||||
|
||||
// Get condition based on loop type - same pattern as forEachItems
|
||||
const conditionKey =
|
||||
currentType === 'while'
|
||||
? 'whileCondition'
|
||||
: currentType === 'doWhile'
|
||||
? 'doWhileCondition'
|
||||
: null
|
||||
const configCondition =
|
||||
iterationType === 'loop' && conditionKey
|
||||
? ((nodeConfig as any)?.[conditionKey] ?? (data as any)?.[conditionKey] ?? '')
|
||||
: ''
|
||||
|
||||
const iterations = configIterations
|
||||
const collectionString =
|
||||
typeof configCollection === 'string' ? configCollection : JSON.stringify(configCollection) || ''
|
||||
const conditionString = typeof configCondition === 'string' ? configCondition : ''
|
||||
|
||||
// State management
|
||||
const [tempInputValue, setTempInputValue] = useState<string | null>(null)
|
||||
const inputValue = tempInputValue ?? iterations.toString()
|
||||
const editorValue = isConditionMode ? conditionString : collectionString
|
||||
const [typePopoverOpen, setTypePopoverOpen] = useState(false)
|
||||
const [configPopoverOpen, setConfigPopoverOpen] = useState(false)
|
||||
const [showTagDropdown, setShowTagDropdown] = useState(false)
|
||||
const [cursorPosition, setCursorPosition] = useState(0)
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
const editorContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Get collaborative functions
|
||||
const {
|
||||
collaborativeUpdateLoopType,
|
||||
collaborativeUpdateParallelType,
|
||||
collaborativeUpdateIterationCount,
|
||||
collaborativeUpdateIterationCollection,
|
||||
} = useCollaborativeWorkflow()
|
||||
const accessiblePrefixes = useAccessibleReferencePrefixes(nodeId)
|
||||
|
||||
const shouldHighlightReference = useCallback(
|
||||
(part: string): boolean => {
|
||||
if (!part.startsWith('<') || !part.endsWith('>')) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!isLikelyReferenceSegment(part)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const split = splitReferenceSegment(part)
|
||||
if (!split) {
|
||||
return false
|
||||
}
|
||||
|
||||
const reference = split.reference
|
||||
|
||||
if (!accessiblePrefixes) {
|
||||
return true
|
||||
}
|
||||
|
||||
const inner = reference.slice(1, -1)
|
||||
const [prefix] = inner.split('.')
|
||||
const normalizedPrefix = normalizeBlockName(prefix)
|
||||
|
||||
if (SYSTEM_REFERENCE_PREFIXES.has(normalizedPrefix)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return accessiblePrefixes.has(normalizedPrefix)
|
||||
},
|
||||
[accessiblePrefixes]
|
||||
)
|
||||
|
||||
const highlightWithReferences = useCallback(
|
||||
(code: string): string => {
|
||||
const placeholders: Array<{
|
||||
placeholder: string
|
||||
original: string
|
||||
type: 'var' | 'env'
|
||||
}> = []
|
||||
|
||||
let processedCode = code
|
||||
|
||||
processedCode = processedCode.replace(/\{\{([^}]+)\}\}/g, (match) => {
|
||||
const placeholder = `__ENV_VAR_${placeholders.length}__`
|
||||
placeholders.push({ placeholder, original: match, type: 'env' })
|
||||
return placeholder
|
||||
})
|
||||
|
||||
processedCode = processedCode.replace(/<[^>]+>/g, (match) => {
|
||||
if (shouldHighlightReference(match)) {
|
||||
const placeholder = `__VAR_REF_${placeholders.length}__`
|
||||
placeholders.push({ placeholder, original: match, type: 'var' })
|
||||
return placeholder
|
||||
}
|
||||
return match
|
||||
})
|
||||
|
||||
let highlightedCode = highlight(processedCode, languages.javascript, 'javascript')
|
||||
|
||||
placeholders.forEach(({ placeholder, original, type }) => {
|
||||
if (type === 'env') {
|
||||
highlightedCode = highlightedCode.replace(
|
||||
placeholder,
|
||||
`<span class="text-blue-500">${original}</span>`
|
||||
)
|
||||
} else {
|
||||
const escaped = original.replace(/</g, '<').replace(/>/g, '>')
|
||||
highlightedCode = highlightedCode.replace(
|
||||
placeholder,
|
||||
`<span class="text-blue-500">${escaped}</span>`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return highlightedCode
|
||||
},
|
||||
[shouldHighlightReference]
|
||||
)
|
||||
|
||||
// Handle type change
|
||||
const handleTypeChange = useCallback(
|
||||
(newType: any) => {
|
||||
if (isPreview) return
|
||||
if (iterationType === 'loop') {
|
||||
collaborativeUpdateLoopType(nodeId, newType)
|
||||
} else {
|
||||
collaborativeUpdateParallelType(nodeId, newType)
|
||||
}
|
||||
setTypePopoverOpen(false)
|
||||
},
|
||||
[nodeId, iterationType, collaborativeUpdateLoopType, collaborativeUpdateParallelType, isPreview]
|
||||
)
|
||||
|
||||
// Handle iterations input change
|
||||
const handleIterationsChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (isPreview) return
|
||||
const sanitizedValue = e.target.value.replace(/[^0-9]/g, '')
|
||||
const numValue = Number.parseInt(sanitizedValue)
|
||||
|
||||
if (!Number.isNaN(numValue)) {
|
||||
setTempInputValue(Math.min(config.maxIterations, numValue).toString())
|
||||
} else {
|
||||
setTempInputValue(sanitizedValue)
|
||||
}
|
||||
},
|
||||
[isPreview, config.maxIterations]
|
||||
)
|
||||
|
||||
// Handle iterations save
|
||||
const handleIterationsSave = useCallback(() => {
|
||||
if (isPreview) return
|
||||
const value = Number.parseInt(inputValue)
|
||||
|
||||
if (!Number.isNaN(value)) {
|
||||
const newValue = Math.min(config.maxIterations, Math.max(1, value))
|
||||
collaborativeUpdateIterationCount(nodeId, iterationType, newValue)
|
||||
}
|
||||
setTempInputValue(null)
|
||||
setConfigPopoverOpen(false)
|
||||
}, [
|
||||
inputValue,
|
||||
nodeId,
|
||||
iterationType,
|
||||
collaborativeUpdateIterationCount,
|
||||
isPreview,
|
||||
config.maxIterations,
|
||||
])
|
||||
|
||||
// Handle editor change
|
||||
const handleEditorChange = useCallback(
|
||||
(value: string) => {
|
||||
if (isPreview) return
|
||||
collaborativeUpdateIterationCollection(nodeId, iterationType, value)
|
||||
|
||||
const textarea = editorContainerRef.current?.querySelector('textarea')
|
||||
if (textarea) {
|
||||
textareaRef.current = textarea
|
||||
const cursorPos = textarea.selectionStart || 0
|
||||
setCursorPosition(cursorPos)
|
||||
|
||||
const triggerCheck = checkTagTrigger(value, cursorPos)
|
||||
setShowTagDropdown(triggerCheck.show)
|
||||
}
|
||||
},
|
||||
[nodeId, iterationType, collaborativeUpdateIterationCollection, isPreview]
|
||||
)
|
||||
|
||||
// Handle tag selection
|
||||
const handleTagSelect = useCallback(
|
||||
(newValue: string) => {
|
||||
if (isPreview) return
|
||||
collaborativeUpdateIterationCollection(nodeId, iterationType, newValue)
|
||||
setShowTagDropdown(false)
|
||||
|
||||
setTimeout(() => {
|
||||
const textarea = textareaRef.current
|
||||
if (textarea) {
|
||||
textarea.focus()
|
||||
}
|
||||
}, 0)
|
||||
},
|
||||
[nodeId, iterationType, collaborativeUpdateIterationCollection, isPreview]
|
||||
)
|
||||
|
||||
// Get type options
|
||||
const typeOptions = Object.entries(config.typeLabels)
|
||||
|
||||
return (
|
||||
<div className='-top-9 absolute right-0 left-0 z-10 flex justify-between'>
|
||||
{/* Type Badge */}
|
||||
<Popover
|
||||
open={!isPreview && typePopoverOpen}
|
||||
onOpenChange={isPreview ? undefined : setTypePopoverOpen}
|
||||
>
|
||||
<PopoverTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
<Badge
|
||||
variant='outline'
|
||||
className={cn(
|
||||
'border-border bg-background/80 py-0.5 pr-1.5 pl-2.5 font-medium text-foreground text-sm backdrop-blur-sm',
|
||||
!isPreview && 'cursor-pointer transition-colors duration-150 hover:bg-accent/50',
|
||||
'flex items-center gap-1'
|
||||
)}
|
||||
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
|
||||
>
|
||||
{config.typeLabels[currentType as keyof typeof config.typeLabels]}
|
||||
{!isPreview && <ChevronDown className='h-3 w-3 text-muted-foreground' />}
|
||||
</Badge>
|
||||
</PopoverTrigger>
|
||||
{!isPreview && (
|
||||
<PopoverContent className='w-48 p-3' align='center' onClick={(e) => e.stopPropagation()}>
|
||||
<div className='space-y-2'>
|
||||
<div className='font-medium text-muted-foreground text-xs'>
|
||||
{iterationType === 'loop' ? 'Loop Type' : 'Parallel Type'}
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
{typeOptions.map(([typeValue, typeLabel]) => (
|
||||
<div
|
||||
key={typeValue}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5',
|
||||
currentType === typeValue ? 'bg-accent' : 'hover:bg-accent/50'
|
||||
)}
|
||||
onClick={() => handleTypeChange(typeValue)}
|
||||
>
|
||||
<span className='text-sm'>{typeLabel}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
)}
|
||||
</Popover>
|
||||
|
||||
{/* Configuration Badge */}
|
||||
<Popover
|
||||
open={!isPreview && configPopoverOpen}
|
||||
onOpenChange={isPreview ? undefined : setConfigPopoverOpen}
|
||||
>
|
||||
<PopoverTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
<Badge
|
||||
variant='outline'
|
||||
className={cn(
|
||||
'border-border bg-background/80 py-0.5 pr-1.5 pl-2.5 font-medium text-foreground text-sm backdrop-blur-sm',
|
||||
!isPreview && 'cursor-pointer transition-colors duration-150 hover:bg-accent/50',
|
||||
'flex items-center gap-1'
|
||||
)}
|
||||
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
|
||||
>
|
||||
{isCountMode ? `Iterations: ${iterations}` : isConditionMode ? 'Condition' : 'Items'}
|
||||
{!isPreview && <ChevronDown className='h-3 w-3 text-muted-foreground' />}
|
||||
</Badge>
|
||||
</PopoverTrigger>
|
||||
{!isPreview && (
|
||||
<PopoverContent
|
||||
className={cn('p-3', !isCountMode ? 'w-72' : 'w-48')}
|
||||
align='center'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className='space-y-2'>
|
||||
<div className='font-medium text-muted-foreground text-xs'>
|
||||
{isCountMode
|
||||
? `${iterationType === 'loop' ? 'Loop' : 'Parallel'} Iterations`
|
||||
: isConditionMode
|
||||
? 'While Condition'
|
||||
: `${iterationType === 'loop' ? 'Collection' : 'Parallel'} Items`}
|
||||
</div>
|
||||
|
||||
{isCountMode ? (
|
||||
// Number input for count-based mode
|
||||
<div className='flex items-center gap-2'>
|
||||
<Input
|
||||
type='text'
|
||||
value={inputValue}
|
||||
onChange={handleIterationsChange}
|
||||
onBlur={handleIterationsSave}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleIterationsSave()}
|
||||
className='h-8 text-sm'
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
) : isConditionMode ? (
|
||||
// Code editor for while condition
|
||||
<div ref={editorContainerRef} className='relative'>
|
||||
<div className='relative min-h-[80px] rounded-md border border-input bg-background px-3 pt-2 pb-3 font-mono text-sm'>
|
||||
{conditionString === '' && (
|
||||
<div className='pointer-events-none absolute top-[8.5px] left-3 select-none text-muted-foreground/50'>
|
||||
{'<counter.value> < 10'}
|
||||
</div>
|
||||
)}
|
||||
<Editor
|
||||
value={conditionString}
|
||||
onValueChange={handleEditorChange}
|
||||
highlight={highlightWithReferences}
|
||||
padding={0}
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
lineHeight: '21px',
|
||||
}}
|
||||
className='w-full focus:outline-none'
|
||||
textareaClassName='focus:outline-none focus:ring-0 bg-transparent resize-none w-full overflow-hidden whitespace-pre-wrap'
|
||||
/>
|
||||
</div>
|
||||
<div className='mt-2 text-[10px] text-muted-foreground'>
|
||||
JavaScript expression that evaluates to true/false. Type "{'<'}" to reference
|
||||
blocks.
|
||||
</div>
|
||||
{showTagDropdown && (
|
||||
<TagDropdown
|
||||
visible={showTagDropdown}
|
||||
onSelect={handleTagSelect}
|
||||
blockId={nodeId}
|
||||
activeSourceBlockId={null}
|
||||
inputValue={conditionString}
|
||||
cursorPosition={cursorPosition}
|
||||
onClose={() => setShowTagDropdown(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// Code editor for collection-based mode
|
||||
<div ref={editorContainerRef} className='relative'>
|
||||
<div className='relative min-h-[80px] rounded-md border border-input bg-background px-3 pt-2 pb-3 font-mono text-sm'>
|
||||
{editorValue === '' && (
|
||||
<div className='pointer-events-none absolute top-[8.5px] left-3 select-none text-muted-foreground/50'>
|
||||
['item1', 'item2', 'item3']
|
||||
</div>
|
||||
)}
|
||||
<Editor
|
||||
value={editorValue}
|
||||
onValueChange={handleEditorChange}
|
||||
highlight={highlightWithReferences}
|
||||
padding={0}
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
lineHeight: '21px',
|
||||
}}
|
||||
className='w-full focus:outline-none'
|
||||
textareaClassName='focus:outline-none focus:ring-0 bg-transparent resize-none w-full overflow-hidden whitespace-pre-wrap'
|
||||
/>
|
||||
</div>
|
||||
<div className='mt-2 text-[10px] text-muted-foreground'>
|
||||
Array or object to iterate over. Type "{'<'}" to reference other blocks.
|
||||
</div>
|
||||
{showTagDropdown && (
|
||||
<TagDropdown
|
||||
visible={showTagDropdown}
|
||||
onSelect={handleTagSelect}
|
||||
blockId={nodeId}
|
||||
activeSourceBlockId={null}
|
||||
inputValue={editorValue}
|
||||
cursorPosition={cursorPosition}
|
||||
onClose={() => setShowTagDropdown(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCountMode && (
|
||||
<div className='text-[10px] text-muted-foreground'>
|
||||
Enter a number between 1 and {config.maxIterations}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
)}
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,31 +1,12 @@
|
||||
import { RepeatIcon } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Loop tool configuration for the toolbar.
|
||||
* Defines the visual appearance of the Loop subflow container in the toolbar.
|
||||
*/
|
||||
export const LoopTool = {
|
||||
id: 'loop',
|
||||
type: 'loop',
|
||||
name: 'Loop',
|
||||
description: 'Create a Loop',
|
||||
icon: RepeatIcon,
|
||||
bgColor: '#2FB3FF',
|
||||
data: {
|
||||
label: 'Loop',
|
||||
loopType: 'for',
|
||||
count: 5,
|
||||
collection: '',
|
||||
width: 500,
|
||||
height: 300,
|
||||
extent: 'parent',
|
||||
executionState: {
|
||||
currentIteration: 0,
|
||||
isExecuting: false,
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
},
|
||||
},
|
||||
style: {
|
||||
width: 500,
|
||||
height: 300,
|
||||
},
|
||||
// Specify that this should be rendered as a ReactFlow group node
|
||||
isResizable: true,
|
||||
}
|
||||
} as const
|
||||
|
||||
@@ -1,29 +1,12 @@
|
||||
import { SplitIcon } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Parallel tool configuration for the toolbar.
|
||||
* Defines the visual appearance of the Parallel subflow container in the toolbar.
|
||||
*/
|
||||
export const ParallelTool = {
|
||||
id: 'parallel',
|
||||
type: 'parallel',
|
||||
name: 'Parallel',
|
||||
description: 'Parallel Execution',
|
||||
icon: SplitIcon,
|
||||
bgColor: '#FEE12B',
|
||||
data: {
|
||||
label: 'Parallel',
|
||||
parallelType: 'count' as 'collection' | 'count',
|
||||
count: 5,
|
||||
collection: '',
|
||||
extent: 'parent',
|
||||
executionState: {
|
||||
currentExecution: 0,
|
||||
isExecuting: false,
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
},
|
||||
},
|
||||
style: {
|
||||
width: 500,
|
||||
height: 300,
|
||||
},
|
||||
// Specify that this should be rendered as a ReactFlow group node
|
||||
isResizable: true,
|
||||
}
|
||||
} as const
|
||||
|
||||
@@ -1,579 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SubflowNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
|
||||
|
||||
// Shared spies used across mocks
|
||||
const mockRemoveBlock = vi.fn()
|
||||
const mockGetNodes = vi.fn()
|
||||
|
||||
// Mocks
|
||||
vi.mock('@/hooks/use-collaborative-workflow', () => ({
|
||||
useCollaborativeWorkflow: vi.fn(() => ({
|
||||
collaborativeRemoveBlock: mockRemoveBlock,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/logs/console/logger', () => ({
|
||||
createLogger: vi.fn(() => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
Handle: ({ id, type, position }: any) => ({ id, type, position }),
|
||||
Position: {
|
||||
Top: 'top',
|
||||
Bottom: 'bottom',
|
||||
Left: 'left',
|
||||
Right: 'right',
|
||||
},
|
||||
useReactFlow: () => ({
|
||||
getNodes: mockGetNodes,
|
||||
}),
|
||||
memo: (component: any) => component,
|
||||
}))
|
||||
|
||||
vi.mock('react', async () => {
|
||||
const actual = await vi.importActual<any>('react')
|
||||
return {
|
||||
...actual,
|
||||
memo: (component: any) => component,
|
||||
useMemo: (fn: any) => fn(),
|
||||
useRef: () => ({ current: null }),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/button', () => ({
|
||||
Button: ({ children, onClick, ...props }: any) => ({ children, onClick, ...props }),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/card', () => ({
|
||||
Card: ({ children, ...props }: any) => ({ children, ...props }),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/icons', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as any
|
||||
return {
|
||||
...actual,
|
||||
StartIcon: ({ className }: any) => ({ className }),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/utils', () => ({
|
||||
cn: (...classes: any[]) => classes.filter(Boolean).join(' '),
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/components/iteration-badges/iteration-badges',
|
||||
() => ({
|
||||
IterationBadges: ({ nodeId, iterationType }: any) => ({ nodeId, iterationType }),
|
||||
})
|
||||
)
|
||||
|
||||
describe('SubflowNodeComponent', () => {
|
||||
const defaultProps = {
|
||||
id: 'subflow-1',
|
||||
type: 'subflowNode',
|
||||
data: {
|
||||
width: 500,
|
||||
height: 300,
|
||||
isPreview: false,
|
||||
kind: 'loop' as const,
|
||||
},
|
||||
selected: false,
|
||||
zIndex: 1,
|
||||
isConnectable: true,
|
||||
xPos: 0,
|
||||
yPos: 0,
|
||||
dragging: false,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetNodes.mockReturnValue([])
|
||||
})
|
||||
|
||||
describe('Component Definition and Structure', () => {
|
||||
it.concurrent('should be defined as a function component', () => {
|
||||
expect(SubflowNodeComponent).toBeDefined()
|
||||
expect(typeof SubflowNodeComponent).toBe('function')
|
||||
})
|
||||
|
||||
it.concurrent('should have correct display name', () => {
|
||||
expect(SubflowNodeComponent.displayName).toBe('SubflowNodeComponent')
|
||||
})
|
||||
|
||||
it.concurrent('should be a memoized component', () => {
|
||||
expect(SubflowNodeComponent).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props Validation and Type Safety', () => {
|
||||
it.concurrent('should accept NodeProps interface', () => {
|
||||
const validProps = {
|
||||
id: 'test-id',
|
||||
type: 'subflowNode' as const,
|
||||
data: {
|
||||
width: 400,
|
||||
height: 300,
|
||||
isPreview: true,
|
||||
kind: 'parallel' as const,
|
||||
},
|
||||
selected: false,
|
||||
zIndex: 1,
|
||||
isConnectable: true,
|
||||
xPos: 0,
|
||||
yPos: 0,
|
||||
dragging: false,
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
const _component: typeof SubflowNodeComponent = SubflowNodeComponent
|
||||
expect(_component).toBeDefined()
|
||||
expect(validProps.type).toBe('subflowNode')
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it.concurrent('should handle different data configurations', () => {
|
||||
const configurations = [
|
||||
{ width: 500, height: 300, isPreview: false, kind: 'loop' as const },
|
||||
{ width: 800, height: 600, isPreview: true, kind: 'parallel' as const },
|
||||
{ width: 0, height: 0, isPreview: false, kind: 'loop' as const },
|
||||
{ kind: 'loop' as const },
|
||||
]
|
||||
|
||||
configurations.forEach((data) => {
|
||||
const props = { ...defaultProps, data }
|
||||
expect(() => {
|
||||
const _component: typeof SubflowNodeComponent = SubflowNodeComponent
|
||||
expect(_component).toBeDefined()
|
||||
expect(props.data).toBeDefined()
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Hook Integration', () => {
|
||||
it.concurrent('should provide collaborativeRemoveBlock', () => {
|
||||
expect(mockRemoveBlock).toBeDefined()
|
||||
expect(typeof mockRemoveBlock).toBe('function')
|
||||
mockRemoveBlock('test-id')
|
||||
expect(mockRemoveBlock).toHaveBeenCalledWith('test-id')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Logic Tests', () => {
|
||||
it.concurrent('should handle nesting level calculation logic', () => {
|
||||
const testCases = [
|
||||
{ nodes: [], parentId: undefined, expectedLevel: 0 },
|
||||
{ nodes: [{ id: 'parent', data: {} }], parentId: 'parent', expectedLevel: 1 },
|
||||
{
|
||||
nodes: [
|
||||
{ id: 'parent', data: { parentId: 'grandparent' } },
|
||||
{ id: 'grandparent', data: {} },
|
||||
],
|
||||
parentId: 'parent',
|
||||
expectedLevel: 2,
|
||||
},
|
||||
]
|
||||
|
||||
testCases.forEach(({ nodes, parentId, expectedLevel }) => {
|
||||
mockGetNodes.mockReturnValue(nodes)
|
||||
|
||||
// Simulate the nesting level calculation logic
|
||||
let level = 0
|
||||
let currentParentId = parentId
|
||||
|
||||
while (currentParentId) {
|
||||
level++
|
||||
const parentNode = nodes.find((n) => n.id === currentParentId)
|
||||
if (!parentNode) break
|
||||
currentParentId = parentNode.data?.parentId
|
||||
}
|
||||
|
||||
expect(level).toBe(expectedLevel)
|
||||
})
|
||||
})
|
||||
|
||||
it.concurrent('should handle nested styles generation', () => {
|
||||
// Test the nested styles logic
|
||||
const testCases = [
|
||||
{ nestingLevel: 0, expectedBg: 'rgba(34,197,94,0.05)' },
|
||||
{ nestingLevel: 1, expectedBg: '#e2e8f030' },
|
||||
{ nestingLevel: 2, expectedBg: '#cbd5e130' },
|
||||
]
|
||||
|
||||
testCases.forEach(({ nestingLevel, expectedBg }) => {
|
||||
// Simulate the getNestedStyles logic
|
||||
const styles: Record<string, string> = {
|
||||
backgroundColor: 'rgba(34,197,94,0.05)',
|
||||
}
|
||||
|
||||
if (nestingLevel > 0) {
|
||||
const colors = ['#e2e8f0', '#cbd5e1', '#94a3b8', '#64748b', '#475569']
|
||||
const colorIndex = (nestingLevel - 1) % colors.length
|
||||
styles.backgroundColor = `${colors[colorIndex]}30`
|
||||
}
|
||||
|
||||
expect(styles.backgroundColor).toBe(expectedBg)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Configuration', () => {
|
||||
it.concurrent('should handle different dimensions', () => {
|
||||
const dimensionTests = [
|
||||
{ width: 500, height: 300 },
|
||||
{ width: 800, height: 600 },
|
||||
{ width: 0, height: 0 },
|
||||
{ width: 10000, height: 10000 },
|
||||
]
|
||||
|
||||
dimensionTests.forEach(({ width, height }) => {
|
||||
const data = { width, height }
|
||||
expect(data.width).toBe(width)
|
||||
expect(data.height).toBe(height)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Event Handling Logic', () => {
|
||||
it.concurrent('should handle delete button click logic (simulated)', () => {
|
||||
const mockEvent = { stopPropagation: vi.fn() }
|
||||
|
||||
const handleDelete = (e: any, nodeId: string) => {
|
||||
e.stopPropagation()
|
||||
mockRemoveBlock(nodeId)
|
||||
}
|
||||
|
||||
handleDelete(mockEvent, 'test-id')
|
||||
|
||||
expect(mockEvent.stopPropagation).toHaveBeenCalled()
|
||||
expect(mockRemoveBlock).toHaveBeenCalledWith('test-id')
|
||||
})
|
||||
|
||||
it.concurrent('should handle event propagation prevention', () => {
|
||||
const mockEvent = { stopPropagation: vi.fn() }
|
||||
mockEvent.stopPropagation()
|
||||
expect(mockEvent.stopPropagation).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Data Handling', () => {
|
||||
it.concurrent('should handle missing data properties gracefully', () => {
|
||||
const testCases = [
|
||||
undefined,
|
||||
{},
|
||||
{ width: 500 },
|
||||
{ height: 300 },
|
||||
{ width: 500, height: 300 },
|
||||
]
|
||||
|
||||
testCases.forEach((data: any) => {
|
||||
const props = { ...defaultProps, data }
|
||||
const width = Math.max(0, data?.width || 500)
|
||||
const height = Math.max(0, data?.height || 300)
|
||||
expect(width).toBeGreaterThanOrEqual(0)
|
||||
expect(height).toBeGreaterThanOrEqual(0)
|
||||
expect(props.type).toBe('subflowNode')
|
||||
})
|
||||
})
|
||||
|
||||
it.concurrent('should handle parent ID relationships', () => {
|
||||
const testCases = [
|
||||
{ parentId: undefined, hasParent: false },
|
||||
{ parentId: 'parent-1', hasParent: true },
|
||||
{ parentId: '', hasParent: false },
|
||||
]
|
||||
|
||||
testCases.forEach(({ parentId, hasParent }) => {
|
||||
const data = { ...defaultProps.data, parentId }
|
||||
expect(Boolean(data.parentId)).toBe(hasParent)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Loop vs Parallel Kind Specific Tests', () => {
|
||||
it.concurrent('should generate correct handle IDs for loop kind', () => {
|
||||
const loopData = { ...defaultProps.data, kind: 'loop' as const }
|
||||
const startHandleId = loopData.kind === 'loop' ? 'loop-start-source' : 'parallel-start-source'
|
||||
const endHandleId = loopData.kind === 'loop' ? 'loop-end-source' : 'parallel-end-source'
|
||||
|
||||
expect(startHandleId).toBe('loop-start-source')
|
||||
expect(endHandleId).toBe('loop-end-source')
|
||||
})
|
||||
|
||||
it.concurrent('should generate correct handle IDs for parallel kind', () => {
|
||||
type SubflowKind = 'loop' | 'parallel'
|
||||
const testHandleGeneration = (kind: SubflowKind) => {
|
||||
const startHandleId = kind === 'loop' ? 'loop-start-source' : 'parallel-start-source'
|
||||
const endHandleId = kind === 'loop' ? 'loop-end-source' : 'parallel-end-source'
|
||||
return { startHandleId, endHandleId }
|
||||
}
|
||||
|
||||
const result = testHandleGeneration('parallel')
|
||||
expect(result.startHandleId).toBe('parallel-start-source')
|
||||
expect(result.endHandleId).toBe('parallel-end-source')
|
||||
})
|
||||
|
||||
it.concurrent('should generate correct background colors for loop kind', () => {
|
||||
const loopData = { ...defaultProps.data, kind: 'loop' as const }
|
||||
const startBg = loopData.kind === 'loop' ? '#2FB3FF' : '#FEE12B'
|
||||
|
||||
expect(startBg).toBe('#2FB3FF')
|
||||
})
|
||||
|
||||
it.concurrent('should generate correct background colors for parallel kind', () => {
|
||||
type SubflowKind = 'loop' | 'parallel'
|
||||
const testBgGeneration = (kind: SubflowKind) => {
|
||||
return kind === 'loop' ? '#2FB3FF' : '#FEE12B'
|
||||
}
|
||||
|
||||
const startBg = testBgGeneration('parallel')
|
||||
expect(startBg).toBe('#FEE12B')
|
||||
})
|
||||
|
||||
it.concurrent('should demonstrate handle ID generation for any kind', () => {
|
||||
type SubflowKind = 'loop' | 'parallel'
|
||||
const testKind = (kind: SubflowKind) => {
|
||||
const data = { kind }
|
||||
const startHandleId = data.kind === 'loop' ? 'loop-start-source' : 'parallel-start-source'
|
||||
const endHandleId = data.kind === 'loop' ? 'loop-end-source' : 'parallel-end-source'
|
||||
return { startHandleId, endHandleId }
|
||||
}
|
||||
|
||||
const loopResult = testKind('loop')
|
||||
expect(loopResult.startHandleId).toBe('loop-start-source')
|
||||
expect(loopResult.endHandleId).toBe('loop-end-source')
|
||||
|
||||
const parallelResult = testKind('parallel')
|
||||
expect(parallelResult.startHandleId).toBe('parallel-start-source')
|
||||
expect(parallelResult.endHandleId).toBe('parallel-end-source')
|
||||
})
|
||||
|
||||
it.concurrent('should pass correct iterationType to IterationBadges for loop', () => {
|
||||
const loopProps = { ...defaultProps, data: { ...defaultProps.data, kind: 'loop' as const } }
|
||||
// Mock IterationBadges should receive the kind as iterationType
|
||||
expect(loopProps.data.kind).toBe('loop')
|
||||
})
|
||||
|
||||
it.concurrent('should pass correct iterationType to IterationBadges for parallel', () => {
|
||||
const parallelProps = {
|
||||
...defaultProps,
|
||||
data: { ...defaultProps.data, kind: 'parallel' as const },
|
||||
}
|
||||
// Mock IterationBadges should receive the kind as iterationType
|
||||
expect(parallelProps.data.kind).toBe('parallel')
|
||||
})
|
||||
|
||||
it.concurrent('should handle both kinds in configuration arrays', () => {
|
||||
const bothKinds = ['loop', 'parallel'] as const
|
||||
bothKinds.forEach((kind) => {
|
||||
const data = { ...defaultProps.data, kind }
|
||||
expect(['loop', 'parallel']).toContain(data.kind)
|
||||
|
||||
// Test handle ID generation for both kinds
|
||||
const startHandleId = data.kind === 'loop' ? 'loop-start-source' : 'parallel-start-source'
|
||||
const endHandleId = data.kind === 'loop' ? 'loop-end-source' : 'parallel-end-source'
|
||||
const startBg = data.kind === 'loop' ? '#2FB3FF' : '#FEE12B'
|
||||
|
||||
if (kind === 'loop') {
|
||||
expect(startHandleId).toBe('loop-start-source')
|
||||
expect(endHandleId).toBe('loop-end-source')
|
||||
expect(startBg).toBe('#2FB3FF')
|
||||
} else {
|
||||
expect(startHandleId).toBe('parallel-start-source')
|
||||
expect(endHandleId).toBe('parallel-end-source')
|
||||
expect(startBg).toBe('#FEE12B')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it.concurrent('should maintain consistent styling behavior across both kinds', () => {
|
||||
const loopProps = { ...defaultProps, data: { ...defaultProps.data, kind: 'loop' as const } }
|
||||
const parallelProps = {
|
||||
...defaultProps,
|
||||
data: { ...defaultProps.data, kind: 'parallel' as const },
|
||||
}
|
||||
|
||||
// Both should have same base properties except kind-specific ones
|
||||
expect(loopProps.data.width).toBe(parallelProps.data.width)
|
||||
expect(loopProps.data.height).toBe(parallelProps.data.height)
|
||||
expect(loopProps.data.isPreview).toBe(parallelProps.data.isPreview)
|
||||
|
||||
// But different kinds
|
||||
expect(loopProps.data.kind).toBe('loop')
|
||||
expect(parallelProps.data.kind).toBe('parallel')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Integration with IterationBadges', () => {
|
||||
it.concurrent('should pass nodeId to IterationBadges', () => {
|
||||
const testId = 'test-subflow-123'
|
||||
const props = { ...defaultProps, id: testId }
|
||||
|
||||
// Verify the props would be passed correctly
|
||||
expect(props.id).toBe(testId)
|
||||
})
|
||||
|
||||
it.concurrent('should pass data object to IterationBadges', () => {
|
||||
const testData = { ...defaultProps.data, customProperty: 'test' }
|
||||
const props = { ...defaultProps, data: testData }
|
||||
|
||||
// Verify the data object structure
|
||||
expect(props.data).toEqual(testData)
|
||||
expect(props.data.kind).toBeDefined()
|
||||
})
|
||||
|
||||
it.concurrent('should pass iterationType matching the kind', () => {
|
||||
const loopProps = { ...defaultProps, data: { ...defaultProps.data, kind: 'loop' as const } }
|
||||
const parallelProps = {
|
||||
...defaultProps,
|
||||
data: { ...defaultProps.data, kind: 'parallel' as const },
|
||||
}
|
||||
|
||||
// The iterationType should match the kind
|
||||
expect(loopProps.data.kind).toBe('loop')
|
||||
expect(parallelProps.data.kind).toBe('parallel')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CSS Class Generation', () => {
|
||||
it.concurrent('should generate proper CSS classes for nested loops', () => {
|
||||
const nestingLevel = 2
|
||||
const expectedBorderClass =
|
||||
nestingLevel > 0 &&
|
||||
`border border-[0.5px] ${nestingLevel % 2 === 0 ? 'border-slate-300/60' : 'border-slate-400/60'}`
|
||||
|
||||
expect(expectedBorderClass).toBeTruthy()
|
||||
expect(expectedBorderClass).toContain('border-slate-300/60') // even nesting level
|
||||
})
|
||||
|
||||
it.concurrent('should generate proper CSS classes for odd nested levels', () => {
|
||||
const nestingLevel = 3
|
||||
const expectedBorderClass =
|
||||
nestingLevel > 0 &&
|
||||
`border border-[0.5px] ${nestingLevel % 2 === 0 ? 'border-slate-300/60' : 'border-slate-400/60'}`
|
||||
|
||||
expect(expectedBorderClass).toBeTruthy()
|
||||
expect(expectedBorderClass).toContain('border-slate-400/60') // odd nesting level
|
||||
})
|
||||
|
||||
it.concurrent('should handle error state styling', () => {
|
||||
const hasNestedError = true
|
||||
const errorClasses = hasNestedError && 'border-2 border-red-500 bg-red-50/50'
|
||||
|
||||
expect(errorClasses).toBe('border-2 border-red-500 bg-red-50/50')
|
||||
})
|
||||
|
||||
it.concurrent('should handle diff status styling', () => {
|
||||
const diffStatuses = ['new', 'edited'] as const
|
||||
|
||||
diffStatuses.forEach((status) => {
|
||||
let diffClass = ''
|
||||
if (status === 'new') {
|
||||
diffClass = 'bg-green-50/50 ring-2 ring-green-500 dark:bg-green-900/10'
|
||||
} else if (status === 'edited') {
|
||||
diffClass = 'bg-orange-50/50 ring-2 ring-orange-500 dark:bg-orange-900/10'
|
||||
}
|
||||
|
||||
expect(diffClass).toBeTruthy()
|
||||
if (status === 'new') {
|
||||
expect(diffClass).toContain('ring-green-500')
|
||||
} else {
|
||||
expect(diffClass).toContain('ring-orange-500')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases and Error Handling', () => {
|
||||
it.concurrent('should handle circular parent references', () => {
|
||||
const nodes = [
|
||||
{ id: 'node1', data: { parentId: 'node2' } },
|
||||
{ id: 'node2', data: { parentId: 'node1' } },
|
||||
]
|
||||
|
||||
mockGetNodes.mockReturnValue(nodes)
|
||||
|
||||
let level = 0
|
||||
let currentParentId = 'node1'
|
||||
const visited = new Set<string>()
|
||||
|
||||
while (currentParentId) {
|
||||
if (visited.has(currentParentId)) {
|
||||
break
|
||||
}
|
||||
|
||||
visited.add(currentParentId)
|
||||
level++
|
||||
|
||||
const parentNode = nodes.find((n) => n.id === currentParentId)
|
||||
if (!parentNode) break
|
||||
currentParentId = parentNode.data?.parentId
|
||||
}
|
||||
|
||||
expect(level).toBe(2)
|
||||
expect(visited.has('node1')).toBe(true)
|
||||
expect(visited.has('node2')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should handle complex circular reference chains', () => {
|
||||
const nodes = [
|
||||
{ id: 'node1', data: { parentId: 'node2' } },
|
||||
{ id: 'node2', data: { parentId: 'node3' } },
|
||||
{ id: 'node3', data: { parentId: 'node1' } },
|
||||
]
|
||||
|
||||
mockGetNodes.mockReturnValue(nodes)
|
||||
|
||||
let level = 0
|
||||
let currentParentId = 'node1'
|
||||
const visited = new Set<string>()
|
||||
|
||||
while (currentParentId) {
|
||||
if (visited.has(currentParentId)) {
|
||||
break
|
||||
}
|
||||
|
||||
visited.add(currentParentId)
|
||||
level++
|
||||
|
||||
const parentNode = nodes.find((n) => n.id === currentParentId)
|
||||
if (!parentNode) break
|
||||
currentParentId = parentNode.data?.parentId
|
||||
}
|
||||
|
||||
expect(level).toBe(3)
|
||||
expect(visited.size).toBe(3)
|
||||
})
|
||||
|
||||
it.concurrent('should handle self-referencing nodes', () => {
|
||||
const nodes = [{ id: 'node1', data: { parentId: 'node1' } }]
|
||||
|
||||
mockGetNodes.mockReturnValue(nodes)
|
||||
|
||||
let level = 0
|
||||
let currentParentId = 'node1'
|
||||
const visited = new Set<string>()
|
||||
|
||||
while (currentParentId) {
|
||||
if (visited.has(currentParentId)) {
|
||||
break
|
||||
}
|
||||
|
||||
visited.add(currentParentId)
|
||||
level++
|
||||
|
||||
const parentNode = nodes.find((n) => n.id === currentParentId)
|
||||
if (!parentNode) break
|
||||
currentParentId = parentNode.data?.parentId
|
||||
}
|
||||
|
||||
expect(level).toBe(1)
|
||||
expect(visited.has('node1')).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,77 +1,63 @@
|
||||
import type React from 'react'
|
||||
import { memo, useMemo, useRef } from 'react'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { RepeatIcon, SplitIcon } from 'lucide-react'
|
||||
import { Handle, type NodeProps, Position, useReactFlow } from 'reactflow'
|
||||
import { StartIcon } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Button, Trash } from '@/components/emcn'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { type DiffStatus, hasDiffStatus } from '@/lib/workflows/diff/types'
|
||||
import { IterationBadges } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/components/iteration-badges/iteration-badges'
|
||||
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { usePanelEditorStore } from '@/stores/panel-new/editor/store'
|
||||
|
||||
/**
|
||||
* Global styles for subflow nodes (loop and parallel containers).
|
||||
* Includes animations for drag-over states and hover effects.
|
||||
*
|
||||
* @returns Style component with global CSS
|
||||
*/
|
||||
const SubflowNodeStyles: React.FC = () => {
|
||||
return (
|
||||
<style jsx global>{`
|
||||
@keyframes loop-node-pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(47, 179, 255, 0.3); }
|
||||
70% { box-shadow: 0 0 0 6px rgba(47, 179, 255, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(47, 179, 255, 0); }
|
||||
}
|
||||
|
||||
@keyframes parallel-node-pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(139, 195, 74, 0.3); }
|
||||
70% { box-shadow: 0 0 0 6px rgba(139, 195, 74, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(139, 195, 74, 0); }
|
||||
}
|
||||
|
||||
.loop-node-drag-over {
|
||||
animation: loop-node-pulse 1.2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
border-style: solid !important;
|
||||
background-color: rgba(47, 179, 255, 0.08) !important;
|
||||
box-shadow: 0 0 0 8px rgba(47, 179, 255, 0.1);
|
||||
/* Z-index management for subflow nodes */
|
||||
.workflow-container .react-flow__node-subflowNode {
|
||||
z-index: -1 !important;
|
||||
}
|
||||
|
||||
/* Drag-over states */
|
||||
.loop-node-drag-over,
|
||||
.parallel-node-drag-over {
|
||||
animation: parallel-node-pulse 1.2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
border-style: solid !important;
|
||||
background-color: rgba(139, 195, 74, 0.08) !important;
|
||||
box-shadow: 0 0 0 8px rgba(139, 195, 74, 0.1);
|
||||
}
|
||||
|
||||
.react-flow__node-group:hover,
|
||||
.hover-highlight {
|
||||
border-color: #1e293b !important;
|
||||
}
|
||||
|
||||
.group-node-container:hover .react-flow__resize-control.bottom-right {
|
||||
opacity: 1 !important;
|
||||
visibility: visible !important;
|
||||
box-shadow: 0 0 0 1.75px #33B4FF !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
/* Handle z-index for nested nodes */
|
||||
.react-flow__node[data-parent-node-id] .react-flow__handle {
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.react-flow__node-group.dragging-over {
|
||||
background-color: rgba(34,197,94,0.05);
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
`}</style>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Data structure for subflow nodes (loop and parallel containers)
|
||||
*/
|
||||
export interface SubflowNodeData {
|
||||
width?: number
|
||||
height?: number
|
||||
parentId?: string
|
||||
extent?: 'parent'
|
||||
hasNestedError?: boolean
|
||||
isPreview?: boolean
|
||||
kind: 'loop' | 'parallel'
|
||||
name?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Subflow node component for loop and parallel execution containers.
|
||||
* Renders a resizable container with a header displaying the block name and icon,
|
||||
* handles for connections, and supports nested execution contexts.
|
||||
*
|
||||
* @param props - Node properties containing data and id
|
||||
* @returns Rendered subflow node component
|
||||
*/
|
||||
export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeData>) => {
|
||||
const { getNodes } = useReactFlow()
|
||||
const { collaborativeRemoveBlock } = useCollaborativeWorkflow()
|
||||
@@ -86,6 +72,15 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
|
||||
|
||||
const isPreview = data?.isPreview || false
|
||||
|
||||
// Focus state
|
||||
const setCurrentBlockId = usePanelEditorStore((state) => state.setCurrentBlockId)
|
||||
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
|
||||
const isFocused = currentBlockId === id
|
||||
|
||||
/**
|
||||
* Calculate the nesting level of this subflow node based on its parent hierarchy.
|
||||
* Used to apply appropriate styling for nested containers.
|
||||
*/
|
||||
const nestingLevel = useMemo(() => {
|
||||
let level = 0
|
||||
let currentParentId = data?.parentId
|
||||
@@ -100,69 +95,112 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
|
||||
return level
|
||||
}, [id, data?.parentId, getNodes])
|
||||
|
||||
const getNestedStyles = () => {
|
||||
const styles: Record<string, string> = {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.02)',
|
||||
}
|
||||
if (nestingLevel > 0) {
|
||||
const colors = ['#e2e8f0', '#cbd5e1', '#94a3b8', '#64748b', '#475569']
|
||||
const colorIndex = (nestingLevel - 1) % colors.length
|
||||
styles.backgroundColor = `${colors[colorIndex]}30`
|
||||
}
|
||||
return styles
|
||||
}
|
||||
|
||||
const nestedStyles = getNestedStyles()
|
||||
|
||||
const startHandleId = data.kind === 'loop' ? 'loop-start-source' : 'parallel-start-source'
|
||||
const endHandleId = data.kind === 'loop' ? 'loop-end-source' : 'parallel-end-source'
|
||||
const startBg = data.kind === 'loop' ? '#2FB3FF' : '#FEE12B'
|
||||
const BlockIcon = data.kind === 'loop' ? RepeatIcon : SplitIcon
|
||||
const blockIconBg = data.kind === 'loop' ? '#2FB3FF' : '#FEE12B'
|
||||
const blockName = data.name || (data.kind === 'loop' ? 'Loop' : 'Parallel')
|
||||
|
||||
/**
|
||||
* Reusable styles and positioning for Handle components.
|
||||
* Matches the styling pattern from workflow-block.tsx.
|
||||
*/
|
||||
const getHandleClasses = (position: 'left' | 'right') => {
|
||||
const baseClasses = '!z-[10] !cursor-crosshair !border-none !transition-[colors] !duration-150'
|
||||
const colorClasses = '!bg-[#434343]'
|
||||
|
||||
const positionClasses = {
|
||||
left: '!left-[-7px] !h-5 !w-[7px] !rounded-l-[2px] !rounded-r-none hover:!left-[-10px] hover:!w-[10px] hover:!rounded-l-full',
|
||||
right:
|
||||
'!right-[-7px] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none hover:!right-[-10px] hover:!w-[10px] hover:!rounded-r-full',
|
||||
}
|
||||
|
||||
return cn(baseClasses, colorClasses, positionClasses[position])
|
||||
}
|
||||
|
||||
const getHandleStyle = () => {
|
||||
return { top: '20px', transform: 'translateY(-50%)' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the ring styling based on subflow state priority:
|
||||
* 1. Focused (selected in editor) - blue ring
|
||||
* 2. Diff status (version comparison) - green/orange ring
|
||||
*/
|
||||
const hasRing = isFocused || diffStatus === 'new' || diffStatus === 'edited'
|
||||
const ringStyles = cn(
|
||||
hasRing && 'ring-[1.75px]',
|
||||
isFocused && 'ring-[#33B4FF]',
|
||||
diffStatus === 'new' && 'ring-[#22C55F]',
|
||||
diffStatus === 'edited' && 'ring-[#FF6600]'
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<SubflowNodeStyles />
|
||||
<div className='group relative'>
|
||||
<Card
|
||||
<div
|
||||
ref={blockRef}
|
||||
onClick={() => setCurrentBlockId(id)}
|
||||
className={cn(
|
||||
'relative cursor-default select-none',
|
||||
'relative cursor-default select-none rounded-[8px] border border-[#393939]',
|
||||
'transition-block-bg transition-ring',
|
||||
'z-[20]',
|
||||
nestingLevel > 0 &&
|
||||
`border border-[0.5px] ${nestingLevel % 2 === 0 ? 'border-slate-300/60' : 'border-slate-400/60'}`,
|
||||
data?.hasNestedError && 'border-2 border-red-500 bg-red-50/50',
|
||||
diffStatus === 'new' && 'bg-green-50/50 ring-2 ring-green-500 dark:bg-green-900/10',
|
||||
diffStatus === 'edited' &&
|
||||
'bg-orange-50/50 ring-2 ring-orange-500 dark:bg-orange-900/10'
|
||||
'z-[20]'
|
||||
)}
|
||||
style={{
|
||||
width: data.width || 500,
|
||||
height: data.height || 300,
|
||||
position: 'relative',
|
||||
overflow: 'visible',
|
||||
...nestedStyles,
|
||||
pointerEvents: isPreview ? 'none' : 'all',
|
||||
}}
|
||||
data-node-id={id}
|
||||
data-type='subflowNode'
|
||||
data-nesting-level={nestingLevel}
|
||||
>
|
||||
{!isPreview && (
|
||||
<div
|
||||
className='workflow-drag-handle absolute top-0 right-0 left-0 z-10 h-10 cursor-move'
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
/>
|
||||
)}
|
||||
{/* Header Section */}
|
||||
<div
|
||||
className={cn(
|
||||
'workflow-drag-handle flex cursor-grab items-center justify-between rounded-t-[8px] border-[#393939] border-b bg-[#232323] py-[8px] pr-[12px] pl-[8px] dark:bg-[#232323] [&:active]:cursor-grabbing'
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[10px]'>
|
||||
<div
|
||||
className='flex h-[24px] w-[24px] flex-shrink-0 items-center justify-center rounded-[6px]'
|
||||
style={{ backgroundColor: blockIconBg }}
|
||||
>
|
||||
<BlockIcon className='h-[16px] w-[16px] text-white' />
|
||||
</div>
|
||||
<span className='font-medium text-[16px]' title={blockName}>
|
||||
{blockName}
|
||||
</span>
|
||||
</div>
|
||||
{!isPreview && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
collaborativeRemoveBlock(id)
|
||||
}}
|
||||
className='h-[14px] w-[14px] p-0 opacity-0 transition-opacity duration-100 group-hover:opacity-100'
|
||||
>
|
||||
<Trash className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isPreview && (
|
||||
<div
|
||||
className='absolute right-2 bottom-2 z-20 flex h-8 w-8 cursor-se-resize items-center justify-center text-muted-foreground'
|
||||
className='absolute right-[8px] bottom-[8px] z-20 flex h-[32px] w-[32px] cursor-se-resize items-center justify-center text-muted-foreground'
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className='h-[calc(100%-10px)] p-4'
|
||||
className='h-[calc(100%-50px)] pt-[16px] pr-[80px] pb-[16px] pl-[16px]'
|
||||
data-dragarea='true'
|
||||
style={{
|
||||
position: 'relative',
|
||||
@@ -170,38 +208,22 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
|
||||
pointerEvents: isPreview ? 'none' : 'auto',
|
||||
}}
|
||||
>
|
||||
{!isPreview && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
collaborativeRemoveBlock(id)
|
||||
}}
|
||||
className='absolute top-2 right-2 z-20 text-gray-500 opacity-0 transition-opacity duration-200 hover:text-red-600 group-hover:opacity-100'
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Subflow Start */}
|
||||
<div
|
||||
className='-translate-y-1/2 absolute top-1/2 left-8 flex h-10 w-10 transform items-center justify-center rounded-md p-2'
|
||||
style={{ pointerEvents: isPreview ? 'none' : 'auto', backgroundColor: startBg }}
|
||||
className='absolute top-[16px] left-[16px] flex items-center justify-center rounded-[8px] bg-[#232323] px-[12px] py-[6px]'
|
||||
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
|
||||
data-parent-id={id}
|
||||
data-node-role={`${data.kind}-start`}
|
||||
data-extent='parent'
|
||||
>
|
||||
<StartIcon className='h-6 w-6 text-white' />
|
||||
<span className='font-medium text-[14px] text-white'>Start</span>
|
||||
|
||||
<Handle
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
id={startHandleId}
|
||||
className='!w-[6px] !h-4 !bg-slate-300 dark:!bg-slate-500 !rounded-[2px] !border-none !z-[30] hover:!w-[10px] hover:!right-[-10px] hover:!rounded-r-full hover:!rounded-l-none !cursor-crosshair transition-[colors] duration-150'
|
||||
className={getHandleClasses('right')}
|
||||
style={{
|
||||
right: '-6px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
pointerEvents: 'auto',
|
||||
@@ -215,11 +237,9 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
|
||||
<Handle
|
||||
type='target'
|
||||
position={Position.Left}
|
||||
className='!w-[7px] !h-5 !bg-slate-300 dark:!bg-slate-500 !rounded-[2px] !border-none !z-[30] hover:!w-[10px] hover:!left-[-10px] hover:!rounded-l-full hover:!rounded-r-none !cursor-crosshair transition-[colors] duration-150'
|
||||
className={getHandleClasses('left')}
|
||||
style={{
|
||||
left: '-7px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
...getHandleStyle(),
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
/>
|
||||
@@ -228,18 +248,20 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
|
||||
<Handle
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
className='!w-[7px] !h-5 !bg-slate-300 dark:!bg-slate-500 !rounded-[2px] !border-none !z-[30] hover:!w-[10px] hover:!right-[-10px] hover:!rounded-r-full hover:!rounded-l-none !cursor-crosshair transition-[colors] duration-150'
|
||||
className={getHandleClasses('right')}
|
||||
style={{
|
||||
right: '-7px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
...getHandleStyle(),
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
id={endHandleId}
|
||||
/>
|
||||
|
||||
<IterationBadges nodeId={id} data={data} iterationType={data.kind} />
|
||||
</Card>
|
||||
{hasRing && (
|
||||
<div
|
||||
className={cn('pointer-events-none absolute inset-0 z-40 rounded-[8px]', ringStyles)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
useWebhookInfo,
|
||||
} from './hooks'
|
||||
import type { WorkflowBlockProps } from './types'
|
||||
import { debounce, getProviderName, shouldSkipBlockRender } from './utils'
|
||||
import { getProviderName, shouldSkipBlockRender } from './utils'
|
||||
|
||||
const logger = createLogger('WorkflowBlock')
|
||||
|
||||
@@ -287,6 +287,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
const setCurrentBlockId = usePanelEditorStore((state) => state.setCurrentBlockId)
|
||||
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
|
||||
const isFocused = currentBlockId === id
|
||||
const currentStoreBlock = currentWorkflow.getBlockById(id)
|
||||
|
||||
const isStarterBlock = type === 'starter'
|
||||
const isWebhookTriggerBlock = type === 'webhook' || type === 'generic_webhook'
|
||||
@@ -299,55 +300,6 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
updateNodeInternals(id)
|
||||
}, [id, horizontalHandles, updateNodeInternals])
|
||||
|
||||
/**
|
||||
* Debounced layout update function that only triggers when dimensions
|
||||
* actually change to avoid unnecessary re-renders.
|
||||
*/
|
||||
const debouncedLayoutUpdate = useMemo(
|
||||
() =>
|
||||
debounce((dimensions: { width: number; height: number }) => {
|
||||
if (dimensions.height !== blockHeight || dimensions.width !== blockWidth) {
|
||||
updateBlockLayoutMetrics(id, dimensions)
|
||||
updateNodeInternals(id)
|
||||
}
|
||||
}, 100),
|
||||
[blockHeight, blockWidth, updateBlockLayoutMetrics, updateNodeInternals, id]
|
||||
)
|
||||
|
||||
/**
|
||||
* Use ResizeObserver to track block size changes and update layout metrics.
|
||||
* Schedules updates on animation frames for better performance.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!contentRef.current) return
|
||||
|
||||
let rafId: number
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
if (rafId) {
|
||||
cancelAnimationFrame(rafId)
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(() => {
|
||||
for (const entry of entries) {
|
||||
const rect = entry.target.getBoundingClientRect()
|
||||
const height = entry.borderBoxSize[0]?.blockSize ?? rect.height
|
||||
const width = entry.borderBoxSize[0]?.inlineSize ?? rect.width
|
||||
debouncedLayoutUpdate({ width, height })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
resizeObserver.observe(contentRef.current)
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
if (rafId) {
|
||||
cancelAnimationFrame(rafId)
|
||||
}
|
||||
}
|
||||
}, [debouncedLayoutUpdate])
|
||||
|
||||
/**
|
||||
* Subscribe to this block's subblock values to track changes for conditional rendering
|
||||
* of subblocks based on their conditions.
|
||||
@@ -555,6 +507,64 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
]
|
||||
}, [type, subBlockState, id])
|
||||
|
||||
/**
|
||||
* Compute and publish deterministic layout metrics for workflow blocks.
|
||||
* This avoids ResizeObserver/animation-frame jitter and prevents initial "jump".
|
||||
*
|
||||
* Height model:
|
||||
* - Fixed header height: 40px
|
||||
* - Content padding when present: 16px (8 top + 8 bottom)
|
||||
* - Row height: 29px per rendered row (subblock rows, condition rows, plus error row if present)
|
||||
*
|
||||
* Width is a fixed 250px for workflow blocks.
|
||||
*/
|
||||
useEffect(() => {
|
||||
// Only workflow blocks (non-subflow) render here, width is constant
|
||||
const FIXED_WIDTH = 250
|
||||
const HEADER_HEIGHT = 40
|
||||
const CONTENT_PADDING = 16
|
||||
const ROW_HEIGHT = 29
|
||||
|
||||
const shouldShowDefaultHandles =
|
||||
config.category !== 'triggers' && type !== 'starter' && !displayTriggerMode
|
||||
const hasContentBelowHeader = subBlockRows.length > 0 || shouldShowDefaultHandles
|
||||
|
||||
// Count rows based on block type and whether default handles section is shown
|
||||
const defaultHandlesRow = shouldShowDefaultHandles ? 1 : 0
|
||||
|
||||
let rowsCount = 0
|
||||
if (type === 'condition') {
|
||||
rowsCount = conditionRows.length + defaultHandlesRow
|
||||
} else {
|
||||
const subblockRowCount = subBlockRows.reduce((acc, row) => acc + row.length, 0)
|
||||
rowsCount = subblockRowCount + defaultHandlesRow
|
||||
}
|
||||
|
||||
const contentHeight = hasContentBelowHeader ? CONTENT_PADDING + rowsCount * ROW_HEIGHT : 0
|
||||
const calculatedHeight = Math.max(HEADER_HEIGHT + contentHeight, 100)
|
||||
|
||||
const prevHeight =
|
||||
typeof currentStoreBlock?.height === 'number' ? currentStoreBlock.height : undefined
|
||||
const prevWidth = 250 // fixed across the app for workflow blocks
|
||||
|
||||
// Only update store if something actually changed to prevent unnecessary reflows
|
||||
if (prevHeight !== calculatedHeight || prevWidth !== FIXED_WIDTH) {
|
||||
updateBlockLayoutMetrics(id, { width: FIXED_WIDTH, height: calculatedHeight })
|
||||
updateNodeInternals(id)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
id,
|
||||
type,
|
||||
config.category,
|
||||
displayTriggerMode,
|
||||
subBlockRows.length,
|
||||
conditionRows.length,
|
||||
currentStoreBlock?.height,
|
||||
updateBlockLayoutMetrics,
|
||||
updateNodeInternals,
|
||||
])
|
||||
|
||||
const showWebhookIndicator = (isStarterBlock || isWebhookTriggerBlock) && isWebhookConfigured
|
||||
const shouldShowScheduleBadge =
|
||||
type === 'schedule' && !isLoadingScheduleInfo && scheduleInfo !== null
|
||||
@@ -623,7 +633,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'workflow-drag-handle flex cursor-grab items-center justify-between px-[9px] py-[8px] [&:active]:cursor-grabbing',
|
||||
'workflow-drag-handle flex cursor-grab items-center justify-between p-[8px] [&:active]:cursor-grabbing',
|
||||
hasContentBelowHeader && 'border-[#393939] border-b'
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
|
||||
@@ -14,25 +14,20 @@ export function useNodeUtilities(blocks: Record<string, any>) {
|
||||
const { getNodes, project } = useReactFlow()
|
||||
|
||||
/**
|
||||
* Check if a block is a container type
|
||||
* Check if a block is a container type (loop, parallel, or subflow)
|
||||
*/
|
||||
const isContainerType = useCallback((blockType: string): boolean => {
|
||||
return (
|
||||
blockType === 'loop' ||
|
||||
blockType === 'parallel' ||
|
||||
blockType === 'loopNode' ||
|
||||
blockType === 'parallelNode' ||
|
||||
blockType === 'subflowNode'
|
||||
)
|
||||
return blockType === 'loop' || blockType === 'parallel' || blockType === 'subflowNode'
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Get the dimensions of a block
|
||||
* Get the dimensions of a block.
|
||||
* For regular blocks, estimates height if not yet measured by ResizeObserver.
|
||||
*/
|
||||
const getBlockDimensions = useCallback(
|
||||
(blockId: string): { width: number; height: number } => {
|
||||
const block = blocks[blockId]
|
||||
if (!block) return { width: 350, height: 150 }
|
||||
if (!block) return { width: 250, height: 100 }
|
||||
|
||||
if (isContainerType(block.type)) {
|
||||
return {
|
||||
@@ -41,12 +36,31 @@ export function useNodeUtilities(blocks: Record<string, any>) {
|
||||
}
|
||||
}
|
||||
|
||||
// Workflow block nodes have fixed visual width
|
||||
const width = 250
|
||||
|
||||
// Prefer deterministic height published by the block component; fallback to estimate
|
||||
let height = block.height
|
||||
|
||||
if (!height) {
|
||||
// Estimate height for workflow blocks before ResizeObserver measures them
|
||||
// Block structure: header (40px) + content area with subblocks
|
||||
// Each subblock row is approximately 29px (14px text + 8px gap + padding)
|
||||
const headerHeight = 40
|
||||
const subblockRowHeight = 29
|
||||
const contentPadding = 16 // p-[8px] top and bottom = 16px total
|
||||
|
||||
// Estimate number of visible subblock rows
|
||||
// This is a rough estimate - actual rendering may vary
|
||||
const estimatedRows = 3 // Conservative estimate for typical blocks
|
||||
const hasErrorRow = block.type !== 'starter' && block.type !== 'response' ? 1 : 0
|
||||
|
||||
height = headerHeight + contentPadding + (estimatedRows + hasErrorRow) * subblockRowHeight
|
||||
}
|
||||
|
||||
return {
|
||||
width: block.layout?.measuredWidth || block.data?.width || 350,
|
||||
height: Math.max(
|
||||
block.layout?.measuredHeight || block.height || block.data?.height || 150,
|
||||
100
|
||||
),
|
||||
width,
|
||||
height: Math.max(height, 100),
|
||||
}
|
||||
},
|
||||
[blocks, isContainerType]
|
||||
@@ -86,7 +100,8 @@ export function useNodeUtilities(blocks: Record<string, any>) {
|
||||
)
|
||||
|
||||
/**
|
||||
* Gets the absolute position of a node (accounting for nested parents)
|
||||
* Gets the absolute position of a node (accounting for nested parents).
|
||||
* For nodes inside containers, accounts for header and padding offsets.
|
||||
* @param nodeId ID of the node to check
|
||||
* @returns Absolute position coordinates {x, y}
|
||||
*/
|
||||
@@ -129,28 +144,41 @@ export function useNodeUtilities(blocks: Record<string, any>) {
|
||||
|
||||
const parentPos = getNodeAbsolutePosition(parentId)
|
||||
|
||||
// Child positions are stored relative to the content area (after header and padding)
|
||||
// Add these offsets when calculating absolute position
|
||||
const headerHeight = 50
|
||||
const leftPadding = 16
|
||||
const topPadding = 16
|
||||
|
||||
return {
|
||||
x: parentPos.x + node.position.x,
|
||||
y: parentPos.y + node.position.y,
|
||||
x: parentPos.x + leftPadding + node.position.x,
|
||||
y: parentPos.y + headerHeight + topPadding + node.position.y,
|
||||
}
|
||||
},
|
||||
[getNodes, blocks]
|
||||
)
|
||||
|
||||
/**
|
||||
* Calculates the relative position of a node to a new parent
|
||||
* Calculates the relative position of a node to a new parent's content area.
|
||||
* Accounts for header height and padding offsets in container nodes.
|
||||
* @param nodeId ID of the node being repositioned
|
||||
* @param newParentId ID of the new parent
|
||||
* @returns Relative position coordinates {x, y}
|
||||
* @returns Relative position coordinates {x, y} within the parent's content area
|
||||
*/
|
||||
const calculateRelativePosition = useCallback(
|
||||
(nodeId: string, newParentId: string): { x: number; y: number } => {
|
||||
const nodeAbsPos = getNodeAbsolutePosition(nodeId)
|
||||
const parentAbsPos = getNodeAbsolutePosition(newParentId)
|
||||
|
||||
// Account for container's header and padding
|
||||
// Children are positioned relative to content area, not container origin
|
||||
const headerHeight = 50
|
||||
const leftPadding = 16
|
||||
const topPadding = 16
|
||||
|
||||
return {
|
||||
x: nodeAbsPos.x - parentAbsPos.x,
|
||||
y: nodeAbsPos.y - parentAbsPos.y,
|
||||
x: nodeAbsPos.x - parentAbsPos.x - leftPadding,
|
||||
y: nodeAbsPos.y - parentAbsPos.y - headerHeight - topPadding,
|
||||
}
|
||||
},
|
||||
[getNodeAbsolutePosition]
|
||||
@@ -222,37 +250,46 @@ export function useNodeUtilities(blocks: Record<string, any>) {
|
||||
const minWidth = DEFAULT_CONTAINER_WIDTH
|
||||
const minHeight = DEFAULT_CONTAINER_HEIGHT
|
||||
|
||||
// Match styling in subflow-node.tsx:
|
||||
// - Header section: 50px total height
|
||||
// - Content area: px-[16px] pb-[0px] pt-[16px] pr-[70px]
|
||||
// Left padding: 16px, Right padding: 64px, Top padding: 16px, Bottom padding: -6px (reduced by additional 6px from 0 to achieve 14px total reduction from original 8px)
|
||||
// - Children are positioned relative to the content area (after header, inside padding)
|
||||
const headerHeight = 50
|
||||
const leftPadding = 16
|
||||
const rightPadding = 80
|
||||
const topPadding = 16
|
||||
const bottomPadding = 16
|
||||
|
||||
const childNodes = getNodes().filter((node) => node.parentId === nodeId)
|
||||
if (childNodes.length === 0) {
|
||||
return { width: minWidth, height: minHeight }
|
||||
}
|
||||
|
||||
let minX = Number.POSITIVE_INFINITY
|
||||
let minY = Number.POSITIVE_INFINITY
|
||||
let maxX = Number.NEGATIVE_INFINITY
|
||||
let maxY = Number.NEGATIVE_INFINITY
|
||||
let maxRight = 0
|
||||
let maxBottom = 0
|
||||
|
||||
childNodes.forEach((node) => {
|
||||
const { width: nodeWidth, height: nodeHeight } = getBlockDimensions(node.id)
|
||||
|
||||
minX = Math.min(minX, node.position.x + nodeWidth)
|
||||
minY = Math.min(minY, node.position.y + nodeHeight)
|
||||
maxX = Math.max(maxX, node.position.x + nodeWidth)
|
||||
maxY = Math.max(maxY, node.position.y + nodeHeight + 50)
|
||||
// Child positions are relative to content area's inner top-left (inside padding)
|
||||
// Calculate the rightmost and bottommost edges of children
|
||||
const rightEdge = node.position.x + nodeWidth
|
||||
const bottomEdge = node.position.y + nodeHeight
|
||||
|
||||
maxRight = Math.max(maxRight, rightEdge)
|
||||
maxBottom = Math.max(maxBottom, bottomEdge)
|
||||
})
|
||||
|
||||
const hasNestedContainers = childNodes.some((node) => node.type && isContainerType(node.type))
|
||||
|
||||
const sidePadding = hasNestedContainers ? 150 : 120
|
||||
|
||||
const extraPadding = 50
|
||||
|
||||
const width = Math.max(minWidth, maxX + sidePadding + extraPadding)
|
||||
const height = Math.max(minHeight, maxY + sidePadding)
|
||||
// Container dimensions = header + padding + children bounds + padding
|
||||
// Width: left padding + max child right edge + right padding (64px)
|
||||
const width = Math.max(minWidth, leftPadding + maxRight + rightPadding)
|
||||
// Height: header + top padding + max child bottom edge + bottom padding (8px)
|
||||
const height = Math.max(minHeight, headerHeight + topPadding + maxBottom + bottomPadding)
|
||||
|
||||
return { width, height }
|
||||
},
|
||||
[getNodes, getBlockDimensions, isContainerType]
|
||||
[getNodes, getBlockDimensions]
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -343,7 +380,7 @@ export function useNodeUtilities(blocks: Record<string, any>) {
|
||||
: 500
|
||||
: typeof node.width === 'number'
|
||||
? node.width
|
||||
: 350
|
||||
: 250
|
||||
const height = isSubflow
|
||||
? typeof node.data?.height === 'number'
|
||||
? node.data.height
|
||||
|
||||
@@ -86,9 +86,6 @@ const WorkflowContent = React.memo(() => {
|
||||
// State for tracking node dragging
|
||||
const [draggedNodeId, setDraggedNodeId] = useState<string | null>(null)
|
||||
const [potentialParentId, setPotentialParentId] = useState<string | null>(null)
|
||||
// State for tracking validation errors
|
||||
// Use a function initializer to ensure the Set is only created once
|
||||
const [nestedSubflowErrors, setNestedSubflowErrors] = useState<Set<string>>(() => new Set())
|
||||
// Enhanced edge selection with parent context and unique identifier
|
||||
const [selectedEdgeInfo, setSelectedEdgeInfo] = useState<SelectedEdgeInfo | null>(null)
|
||||
|
||||
@@ -128,6 +125,8 @@ const WorkflowContent = React.memo(() => {
|
||||
getDragStartPosition,
|
||||
} = useWorkflowStore()
|
||||
|
||||
// (moved) resizeLoopNodesWrapper defined after hooks that provide resizeLoopNodes
|
||||
|
||||
// Get copilot cleanup function
|
||||
const copilotCleanup = useCopilotStore((state) => state.cleanup)
|
||||
|
||||
@@ -148,6 +147,14 @@ const WorkflowContent = React.memo(() => {
|
||||
getNodeAnchorPosition,
|
||||
} = useNodeUtilities(blocks)
|
||||
|
||||
/**
|
||||
* Wrapper to call resizeLoopNodes with immediate execution.
|
||||
* No delays for responsive subflow resizing.
|
||||
*/
|
||||
const resizeLoopNodesWrapper = useCallback(() => {
|
||||
return resizeLoopNodes(updateNodeDimensions)
|
||||
}, [resizeLoopNodes, updateNodeDimensions])
|
||||
|
||||
// Auto-layout hook
|
||||
const { applyAutoLayoutAndUpdateStore } = useAutoLayout(activeWorkflowId || null)
|
||||
|
||||
@@ -279,29 +286,6 @@ const WorkflowContent = React.memo(() => {
|
||||
}
|
||||
}, [isErrorConnectionDrag])
|
||||
|
||||
// Helper function to validate workflow for nested subflows
|
||||
const validateNestedSubflows = useCallback(() => {
|
||||
const errors = new Set<string>()
|
||||
|
||||
Object.entries(blocks).forEach(([blockId, block]) => {
|
||||
// Check if this is a subflow block (loop or parallel)
|
||||
if (block.type === 'loop' || block.type === 'parallel') {
|
||||
// Check if it has a parent that is also a subflow block
|
||||
const parentId = block.data?.parentId
|
||||
if (parentId) {
|
||||
const parentBlock = blocks[parentId]
|
||||
if (parentBlock && (parentBlock.type === 'loop' || parentBlock.type === 'parallel')) {
|
||||
// This is a nested subflow - mark as error
|
||||
errors.add(blockId)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
setNestedSubflowErrors(errors)
|
||||
return errors.size === 0
|
||||
}, [blocks])
|
||||
|
||||
// Log permissions when they load
|
||||
useEffect(() => {
|
||||
if (workspacePermissions) {
|
||||
@@ -347,9 +331,13 @@ const WorkflowContent = React.memo(() => {
|
||||
if (newParentId) {
|
||||
const nodeAbsPos = getNodeAbsolutePosition(nodeId)
|
||||
const parentAbsPos = getNodeAbsolutePosition(newParentId)
|
||||
// Account for header (50px), left padding (16px), and top padding (16px)
|
||||
const headerHeight = 50
|
||||
const leftPadding = 16
|
||||
const topPadding = 16
|
||||
newPosition = {
|
||||
x: nodeAbsPos.x - parentAbsPos.x,
|
||||
y: nodeAbsPos.y - parentAbsPos.y,
|
||||
x: nodeAbsPos.x - parentAbsPos.x - leftPadding,
|
||||
y: nodeAbsPos.y - parentAbsPos.y - headerHeight - topPadding,
|
||||
}
|
||||
} else if (oldParentId) {
|
||||
newPosition = getNodeAbsolutePosition(nodeId)
|
||||
@@ -360,7 +348,7 @@ const WorkflowContent = React.memo(() => {
|
||||
newParentId,
|
||||
collaborativeUpdateBlockPosition,
|
||||
updateParentId,
|
||||
() => resizeLoopNodes(updateNodeDimensions)
|
||||
() => resizeLoopNodesWrapper()
|
||||
)
|
||||
|
||||
if (oldParentId !== newParentId) {
|
||||
@@ -389,15 +377,10 @@ const WorkflowContent = React.memo(() => {
|
||||
edgesForDisplay,
|
||||
getNodeAbsolutePosition,
|
||||
updateNodeParentUtil,
|
||||
resizeLoopNodes,
|
||||
resizeLoopNodesWrapper,
|
||||
]
|
||||
)
|
||||
|
||||
// Wrapper to call resizeLoopNodes with updateNodeDimensions
|
||||
const resizeLoopNodesWrapper = useCallback(() => {
|
||||
return resizeLoopNodes(updateNodeDimensions)
|
||||
}, [resizeLoopNodes, updateNodeDimensions])
|
||||
|
||||
// Auto-layout handler - uses the hook for immediate frontend updates
|
||||
const handleAutoLayout = useCallback(async () => {
|
||||
if (Object.keys(blocks).length === 0) return
|
||||
@@ -581,13 +564,10 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
// Special handling for container nodes (loop or parallel)
|
||||
if (type === 'loop' || type === 'parallel') {
|
||||
// Create a unique ID and name for the container
|
||||
const id = crypto.randomUUID()
|
||||
|
||||
const baseName = type === 'loop' ? 'Loop' : 'Parallel'
|
||||
const name = getUniqueBlockName(baseName, blocks)
|
||||
|
||||
// Calculate the center position of the viewport
|
||||
const centerPosition = project({
|
||||
x: window.innerWidth / 2,
|
||||
y: window.innerHeight / 2,
|
||||
@@ -599,15 +579,7 @@ const WorkflowContent = React.memo(() => {
|
||||
if (isAutoConnectEnabled) {
|
||||
const closestBlock = findClosestOutput(centerPosition)
|
||||
if (closestBlock) {
|
||||
// Container nodes are never triggers, but check if source is a trigger
|
||||
const sourceBlockConfig = getBlock(closestBlock.type)
|
||||
const isSourceTrigger =
|
||||
sourceBlockConfig?.category === 'triggers' || sourceBlockConfig?.triggers?.enabled
|
||||
|
||||
// Container nodes can connect from triggers (they're not triggers themselves)
|
||||
// Get appropriate source handle
|
||||
const sourceHandle = determineSourceHandle(closestBlock)
|
||||
|
||||
autoConnectEdge = {
|
||||
id: crypto.randomUUID(),
|
||||
source: closestBlock.id,
|
||||
@@ -619,7 +591,7 @@ const WorkflowContent = React.memo(() => {
|
||||
}
|
||||
}
|
||||
|
||||
// Add the container node directly to canvas with default dimensions and auto-connect edge
|
||||
// Add the container node with default dimensions and auto-connect edge
|
||||
addBlock(
|
||||
id,
|
||||
type,
|
||||
@@ -810,77 +782,56 @@ const WorkflowContent = React.memo(() => {
|
||||
el.classList.remove('loop-node-drag-over', 'parallel-node-drag-over')
|
||||
})
|
||||
document.body.style.cursor = ''
|
||||
// Ensure any toolbar drag flags are cleared on drop
|
||||
document.body.classList.remove('sim-drag-subflow')
|
||||
|
||||
// Special handling for container nodes (loop or parallel)
|
||||
if (data.type === 'loop' || data.type === 'parallel') {
|
||||
// Create a unique ID and name for the container
|
||||
const id = crypto.randomUUID()
|
||||
|
||||
const baseName = data.type === 'loop' ? 'Loop' : 'Parallel'
|
||||
const name = getUniqueBlockName(baseName, blocks)
|
||||
|
||||
// Check if we're dropping inside another container
|
||||
if (containerInfo) {
|
||||
// Calculate position relative to the parent container
|
||||
const relativePosition = {
|
||||
x: position.x - containerInfo.loopPosition.x,
|
||||
y: position.y - containerInfo.loopPosition.y,
|
||||
// Subflows cannot be dropped inside other subflows - always add to main canvas
|
||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
||||
let autoConnectEdge
|
||||
if (isAutoConnectEnabled) {
|
||||
const closestBlock = findClosestOutput(position)
|
||||
if (closestBlock) {
|
||||
const sourceHandle = determineSourceHandle(closestBlock)
|
||||
autoConnectEdge = {
|
||||
id: crypto.randomUUID(),
|
||||
source: closestBlock.id,
|
||||
target: id,
|
||||
sourceHandle,
|
||||
targetHandle: 'target',
|
||||
type: 'workflowEdge',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add the container as a child of the parent container (will be marked as error)
|
||||
addBlock(id, data.type, name, relativePosition, {
|
||||
// Add the container node with default dimensions and auto-connect edge
|
||||
addBlock(
|
||||
id,
|
||||
data.type,
|
||||
name,
|
||||
position,
|
||||
{
|
||||
width: 500,
|
||||
height: 300,
|
||||
type: 'subflowNode',
|
||||
parentId: containerInfo.loopId,
|
||||
extent: 'parent',
|
||||
})
|
||||
|
||||
// Resize the parent container to fit the new child container
|
||||
resizeLoopNodesWrapper()
|
||||
} else {
|
||||
// Auto-connect the container to the closest node on the canvas
|
||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
||||
let autoConnectEdge
|
||||
if (isAutoConnectEnabled) {
|
||||
const closestBlock = findClosestOutput(position)
|
||||
if (closestBlock) {
|
||||
// Container nodes can connect from any block (they're never triggers)
|
||||
const sourceHandle = determineSourceHandle(closestBlock)
|
||||
|
||||
autoConnectEdge = {
|
||||
id: crypto.randomUUID(),
|
||||
source: closestBlock.id,
|
||||
target: id,
|
||||
sourceHandle,
|
||||
targetHandle: 'target',
|
||||
type: 'workflowEdge',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add the container node directly to canvas with default dimensions and auto-connect edge
|
||||
addBlock(
|
||||
id,
|
||||
data.type,
|
||||
name,
|
||||
position,
|
||||
{
|
||||
width: 500,
|
||||
height: 300,
|
||||
type: 'subflowNode',
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
autoConnectEdge
|
||||
)
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
autoConnectEdge
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Validate block config for regular blocks
|
||||
const blockConfig = getBlock(data.type)
|
||||
if (!blockConfig && data.type !== 'loop' && data.type !== 'parallel') {
|
||||
if (!blockConfig) {
|
||||
logger.error('Invalid block type:', { data })
|
||||
return
|
||||
}
|
||||
@@ -898,10 +849,15 @@ const WorkflowContent = React.memo(() => {
|
||||
const name = getUniqueBlockName(baseName, blocks)
|
||||
|
||||
if (containerInfo) {
|
||||
// Calculate position relative to the container node
|
||||
// Calculate position relative to the container's content area
|
||||
// Account for header (50px), left padding (16px), and top padding (16px)
|
||||
const headerHeight = 50
|
||||
const leftPadding = 16
|
||||
const topPadding = 16
|
||||
|
||||
const relativePosition = {
|
||||
x: position.x - containerInfo.loopPosition.x,
|
||||
y: position.y - containerInfo.loopPosition.y,
|
||||
x: position.x - containerInfo.loopPosition.x - leftPadding,
|
||||
y: position.y - containerInfo.loopPosition.y - headerHeight - topPadding,
|
||||
}
|
||||
|
||||
// Capture existing child blocks before adding the new one
|
||||
@@ -1106,8 +1062,11 @@ const WorkflowContent = React.memo(() => {
|
||||
el.classList.remove('loop-node-drag-over', 'parallel-node-drag-over')
|
||||
})
|
||||
|
||||
// If hovering over a container node, highlight it
|
||||
if (containerInfo) {
|
||||
// Highlight container if hovering over it and not dragging a subflow
|
||||
// Subflow drag is marked by body class flag set by toolbar
|
||||
const isSubflowDrag = document.body.classList.contains('sim-drag-subflow')
|
||||
|
||||
if (containerInfo && !isSubflowDrag) {
|
||||
const containerElement = document.querySelector(`[data-id="${containerInfo.loopId}"]`)
|
||||
if (containerElement) {
|
||||
// Determine the type of container node for appropriate styling
|
||||
@@ -1251,10 +1210,11 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
prevBlocksRef.current = blocks
|
||||
const hash = Object.values(blocks)
|
||||
.map(
|
||||
(b) =>
|
||||
`${b.id}:${b.type}:${b.name}:${b.position.x.toFixed(0)}:${b.position.y.toFixed(0)}:${b.height}:${b.data?.parentId || ''}`
|
||||
)
|
||||
.map((b) => {
|
||||
const width = typeof b.data?.width === 'number' ? b.data.width : ''
|
||||
const height = typeof b.data?.height === 'number' ? b.data.height : ''
|
||||
return `${b.id}:${b.type}:${b.name}:${b.position.x.toFixed(0)}:${b.position.y.toFixed(0)}:${b.height}:${b.data?.parentId || ''}:${width}:${height}`
|
||||
})
|
||||
.join('|')
|
||||
|
||||
prevBlocksHashRef.current = hash
|
||||
@@ -1273,7 +1233,6 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
// Handle container nodes differently
|
||||
if (block.type === 'loop' || block.type === 'parallel') {
|
||||
const hasNestedError = nestedSubflowErrors.has(block.id)
|
||||
nodeArray.push({
|
||||
id: block.id,
|
||||
type: 'subflowNode',
|
||||
@@ -1283,9 +1242,9 @@ const WorkflowContent = React.memo(() => {
|
||||
dragHandle: '.workflow-drag-handle',
|
||||
data: {
|
||||
...block.data,
|
||||
name: block.name,
|
||||
width: block.data?.width || 500,
|
||||
height: block.data?.height || 300,
|
||||
hasNestedError,
|
||||
kind: block.type === 'loop' ? 'loop' : 'parallel',
|
||||
},
|
||||
})
|
||||
@@ -1312,7 +1271,25 @@ const WorkflowContent = React.memo(() => {
|
||||
position,
|
||||
parentId: block.data?.parentId,
|
||||
dragHandle: '.workflow-drag-handle',
|
||||
extent: block.data?.extent || undefined,
|
||||
extent: (() => {
|
||||
// Clamp children to subflow body (exclude header)
|
||||
const parentId = block.data?.parentId as string | undefined
|
||||
if (!parentId) return block.data?.extent || undefined
|
||||
|
||||
// Constrain ONLY the top by header height (42px) and keep a small left padding.
|
||||
// Do not clamp right/bottom so blocks can move freely within the body.
|
||||
const headerHeight = 42
|
||||
const leftPadding = 16
|
||||
const minX = leftPadding
|
||||
const minY = headerHeight
|
||||
const maxX = Number.POSITIVE_INFINITY
|
||||
const maxY = Number.POSITIVE_INFINITY
|
||||
|
||||
return [
|
||||
[minX, minY],
|
||||
[maxX, maxY],
|
||||
] as [[number, number], [number, number]]
|
||||
})(),
|
||||
data: {
|
||||
type: block.type,
|
||||
config: blockConfig, // Cached config reference
|
||||
@@ -1320,22 +1297,14 @@ const WorkflowContent = React.memo(() => {
|
||||
isActive,
|
||||
isPending,
|
||||
},
|
||||
// Include dynamic dimensions for container resizing calculations
|
||||
width: 350, // Standard width
|
||||
// Include dynamic dimensions for container resizing calculations (must match rendered size)
|
||||
width: 250, // Standard width - matches w-[250px] in workflow-block.tsx
|
||||
height: Math.max(block.height || 100, 100), // Use actual height with minimum
|
||||
})
|
||||
})
|
||||
|
||||
return nodeArray
|
||||
}, [
|
||||
blocksHash,
|
||||
blocks,
|
||||
activeBlockIds,
|
||||
pendingBlocks,
|
||||
isDebugging,
|
||||
nestedSubflowErrors,
|
||||
getBlockConfig,
|
||||
])
|
||||
}, [blocksHash, blocks, activeBlockIds, pendingBlocks, isDebugging, getBlockConfig])
|
||||
|
||||
// Update nodes - use store version to avoid collaborative feedback loops
|
||||
const onNodesChange = useCallback(
|
||||
@@ -1353,7 +1322,10 @@ const WorkflowContent = React.memo(() => {
|
||||
[nodes, storeUpdateBlockPosition]
|
||||
)
|
||||
|
||||
// Effect to resize loops when nodes change (add/remove/position change)
|
||||
/**
|
||||
* Effect to resize loops when nodes change (add/remove/position change).
|
||||
* Runs on every node change for immediate responsiveness.
|
||||
*/
|
||||
useEffect(() => {
|
||||
// Skip during initial render when nodes aren't loaded yet
|
||||
if (nodes.length === 0) return
|
||||
@@ -1391,11 +1363,6 @@ const WorkflowContent = React.memo(() => {
|
||||
})
|
||||
}, [blocks, collaborativeUpdateBlockPosition, updateParentId, getNodeAbsolutePosition])
|
||||
|
||||
// Validate nested subflows whenever blocks change
|
||||
useEffect(() => {
|
||||
validateNestedSubflows()
|
||||
}, [blocks, validateNestedSubflows])
|
||||
|
||||
// Update edges
|
||||
const onEdgesChange = useCallback(
|
||||
(changes: any) => {
|
||||
@@ -1542,6 +1509,20 @@ const WorkflowContent = React.memo(() => {
|
||||
// Get the node's absolute position to properly calculate intersections
|
||||
const nodeAbsolutePos = getNodeAbsolutePosition(node.id)
|
||||
|
||||
// Prevent subflows from being dragged into other subflows
|
||||
if (node.type === 'subflowNode') {
|
||||
// Clear any highlighting for subflow nodes
|
||||
if (potentialParentId) {
|
||||
const prevElement = document.querySelector(`[data-id="${potentialParentId}"]`)
|
||||
if (prevElement) {
|
||||
prevElement.classList.remove('loop-node-drag-over', 'parallel-node-drag-over')
|
||||
}
|
||||
setPotentialParentId(null)
|
||||
document.body.style.cursor = ''
|
||||
}
|
||||
return // Exit early - subflows cannot be placed inside other subflows
|
||||
}
|
||||
|
||||
// Find intersections with container nodes using absolute coordinates
|
||||
const intersectingNodes = getNodes()
|
||||
.filter((n) => {
|
||||
@@ -1551,34 +1532,16 @@ const WorkflowContent = React.memo(() => {
|
||||
// Skip if this container is already the parent of the node being dragged
|
||||
if (n.id === currentParentId) return false
|
||||
|
||||
// Skip self-nesting: prevent a container from becoming its own descendant
|
||||
if (node.type === 'subflowNode') {
|
||||
// Get the full hierarchy of the potential parent
|
||||
const hierarchy = getNodeHierarchy(n.id)
|
||||
|
||||
// If the dragged node is in the hierarchy, this would create a circular reference
|
||||
if (hierarchy.includes(node.id)) {
|
||||
return false // Avoid circular nesting
|
||||
}
|
||||
}
|
||||
|
||||
// Get the container's absolute position
|
||||
const containerAbsolutePos = getNodeAbsolutePosition(n.id)
|
||||
|
||||
// Get dimensions based on node type
|
||||
const nodeWidth =
|
||||
node.type === 'subflowNode'
|
||||
? node.data?.width || 500
|
||||
: node.type === 'condition'
|
||||
? 250
|
||||
: 350
|
||||
// Get dimensions based on node type (must match actual rendered dimensions)
|
||||
const nodeWidth = node.type === 'subflowNode' ? node.data?.width || 500 : 250 // All workflow blocks use w-[250px] in workflow-block.tsx
|
||||
|
||||
const nodeHeight =
|
||||
node.type === 'subflowNode'
|
||||
? node.data?.height || 300
|
||||
: node.type === 'condition'
|
||||
? 150
|
||||
: 100
|
||||
: Math.max(node.height || 100, 100) // Use actual node height with minimum 100
|
||||
|
||||
// Check intersection using absolute coordinates
|
||||
const nodeRect = {
|
||||
@@ -1626,13 +1589,6 @@ const WorkflowContent = React.memo(() => {
|
||||
// Use the most appropriate container (deepest or smallest at same depth)
|
||||
const bestContainerMatch = sortedContainers[0]
|
||||
|
||||
// Add a check to see if the bestContainerMatch is a part of the hierarchy of the node being dragged
|
||||
const hierarchy = getNodeHierarchy(node.id)
|
||||
if (hierarchy.includes(bestContainerMatch.container.id)) {
|
||||
setPotentialParentId(null)
|
||||
return
|
||||
}
|
||||
|
||||
setPotentialParentId(bestContainerMatch.container.id)
|
||||
|
||||
// Add highlight class and change cursor
|
||||
@@ -1670,7 +1626,6 @@ const WorkflowContent = React.memo(() => {
|
||||
getNodes,
|
||||
potentialParentId,
|
||||
blocks,
|
||||
getNodeHierarchy,
|
||||
getNodeAbsolutePosition,
|
||||
getNodeDepth,
|
||||
collaborativeUpdateBlockPosition,
|
||||
@@ -1746,31 +1701,32 @@ const WorkflowContent = React.memo(() => {
|
||||
return // Exit early - don't allow starter blocks to have parents
|
||||
}
|
||||
|
||||
// If we're dragging a container node, do additional checks to prevent circular references
|
||||
// Subflow nodes cannot be placed inside other subflows
|
||||
// This check is redundant with onNodeDrag but serves as a safety guard
|
||||
if (node.type === 'subflowNode' && potentialParentId) {
|
||||
// Get the hierarchy of the potential parent container
|
||||
const parentHierarchy = getNodeHierarchy(potentialParentId)
|
||||
|
||||
// If the dragged node is in the parent's hierarchy, it would create a circular reference
|
||||
if (parentHierarchy.includes(node.id)) {
|
||||
logger.warn('Prevented circular container nesting', {
|
||||
draggedNodeId: node.id,
|
||||
draggedNodeType: node.type,
|
||||
potentialParentId,
|
||||
parentHierarchy,
|
||||
})
|
||||
return
|
||||
}
|
||||
logger.warn('Prevented subflow node from being placed inside a container', {
|
||||
blockId: node.id,
|
||||
attemptedParentId: potentialParentId,
|
||||
})
|
||||
// Reset state without updating parent
|
||||
setDraggedNodeId(null)
|
||||
setPotentialParentId(null)
|
||||
return
|
||||
}
|
||||
|
||||
// Update the node's parent relationship
|
||||
if (potentialParentId) {
|
||||
// Compute relative position BEFORE updating parent to avoid stale state
|
||||
// Account for header (50px), left padding (16px), and top padding (16px)
|
||||
const containerAbsPosBefore = getNodeAbsolutePosition(potentialParentId)
|
||||
const nodeAbsPosBefore = getNodeAbsolutePosition(node.id)
|
||||
const headerHeight = 50
|
||||
const leftPadding = 16
|
||||
const topPadding = 16
|
||||
|
||||
const relativePositionBefore = {
|
||||
x: nodeAbsPosBefore.x - containerAbsPosBefore.x,
|
||||
y: nodeAbsPosBefore.y - containerAbsPosBefore.y,
|
||||
x: nodeAbsPosBefore.x - containerAbsPosBefore.x - leftPadding,
|
||||
y: nodeAbsPosBefore.y - containerAbsPosBefore.y - headerHeight - topPadding,
|
||||
}
|
||||
|
||||
// Prepare edges that will be added when moving into the container
|
||||
@@ -1850,7 +1806,6 @@ const WorkflowContent = React.memo(() => {
|
||||
dragStartParentId,
|
||||
potentialParentId,
|
||||
updateNodeParent,
|
||||
getNodeHierarchy,
|
||||
collaborativeUpdateBlockPosition,
|
||||
addEdge,
|
||||
determineSourceHandle,
|
||||
@@ -2005,13 +1960,8 @@ const WorkflowContent = React.memo(() => {
|
||||
edgeTypes={edgeTypes}
|
||||
onDrop={effectivePermissions.canEdit ? onDrop : undefined}
|
||||
onDragOver={effectivePermissions.canEdit ? onDragOver : undefined}
|
||||
onInit={(instance) => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
instance.fitView({ padding: 0.3 })
|
||||
})
|
||||
})
|
||||
}}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.6 }}
|
||||
minZoom={0.1}
|
||||
maxZoom={1.3}
|
||||
panOnScroll
|
||||
|
||||
@@ -250,7 +250,7 @@ export function SidebarNew() {
|
||||
className='group -m-1 p-0 p-1'
|
||||
>
|
||||
<ChevronDown
|
||||
className={`h-[8px] w-[12px] transition-transform duration-200 ${
|
||||
className={`h-[8px] w-[12px] transition-transform duration-100 ${
|
||||
isWorkspaceMenuOpen ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
|
||||
@@ -139,6 +139,7 @@ export function WorkflowPreview({
|
||||
draggable: false,
|
||||
data: {
|
||||
...block.data,
|
||||
name: block.name,
|
||||
width: block.data?.width || 500,
|
||||
height: block.data?.height || 300,
|
||||
state: 'valid',
|
||||
@@ -159,6 +160,7 @@ export function WorkflowPreview({
|
||||
draggable: false,
|
||||
data: {
|
||||
...block.data,
|
||||
name: block.name,
|
||||
width: block.data?.width || 500,
|
||||
height: block.data?.height || 300,
|
||||
state: 'valid',
|
||||
|
||||
@@ -198,6 +198,75 @@ export default {
|
||||
transform: 'translateX(0)',
|
||||
},
|
||||
},
|
||||
'dash-animation': {
|
||||
from: {
|
||||
strokeDashoffset: '0',
|
||||
},
|
||||
to: {
|
||||
strokeDashoffset: '-24',
|
||||
},
|
||||
},
|
||||
'pulse-ring': {
|
||||
'0%': {
|
||||
boxShadow: '0 0 0 0 hsl(var(--border))',
|
||||
},
|
||||
'50%': {
|
||||
boxShadow: '0 0 0 8px hsl(var(--border))',
|
||||
},
|
||||
'100%': {
|
||||
boxShadow: '0 0 0 0 hsl(var(--border))',
|
||||
},
|
||||
},
|
||||
'code-shimmer': {
|
||||
'0%': {
|
||||
transform: 'translateX(-100%)',
|
||||
},
|
||||
'100%': {
|
||||
transform: 'translateX(100%)',
|
||||
},
|
||||
},
|
||||
orbit: {
|
||||
'0%': {
|
||||
transform:
|
||||
'rotate(calc(var(--angle) * 1deg)) translateY(calc(var(--radius) * 1px)) rotate(calc(var(--angle) * -1deg))',
|
||||
},
|
||||
'100%': {
|
||||
transform:
|
||||
'rotate(calc(var(--angle) * 1deg + 360deg)) translateY(calc(var(--radius) * 1px)) rotate(calc((var(--angle) * -1deg) - 360deg))',
|
||||
},
|
||||
},
|
||||
marquee: {
|
||||
from: {
|
||||
transform: 'translateX(0)',
|
||||
},
|
||||
to: {
|
||||
transform: 'translateX(calc(-100% - var(--gap)))',
|
||||
},
|
||||
},
|
||||
'marquee-vertical': {
|
||||
from: {
|
||||
transform: 'translateY(0)',
|
||||
},
|
||||
to: {
|
||||
transform: 'translateY(calc(-100% - var(--gap)))',
|
||||
},
|
||||
},
|
||||
'fade-in': {
|
||||
from: {
|
||||
opacity: '0',
|
||||
},
|
||||
to: {
|
||||
opacity: '1',
|
||||
},
|
||||
},
|
||||
'placeholder-pulse': {
|
||||
'0%, 100%': {
|
||||
opacity: '0.5',
|
||||
},
|
||||
'50%': {
|
||||
opacity: '0.8',
|
||||
},
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'slide-down': 'slide-down 0.3s ease-out',
|
||||
@@ -212,6 +281,14 @@ export default {
|
||||
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||
'slide-left': 'slide-left 80s linear infinite',
|
||||
'slide-right': 'slide-right 80s linear infinite',
|
||||
'dash-animation': 'dash-animation 1.5s linear infinite',
|
||||
'pulse-ring': 'pulse-ring 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
'code-shimmer': 'code-shimmer 1.5s infinite',
|
||||
orbit: 'orbit calc(var(--duration, 2) * 1s) linear infinite',
|
||||
marquee: 'marquee var(--duration) infinite linear',
|
||||
'marquee-vertical': 'marquee-vertical var(--duration) linear infinite',
|
||||
'fade-in': 'fade-in 0.3s ease-in-out forwards',
|
||||
'placeholder-pulse': 'placeholder-pulse 1.5s ease-in-out infinite',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user