mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-05 05:04:10 -05:00
v0.5.48: copy-paste workflow blocks, docs updates, mcp tool fixes
This commit is contained in:
63
apps/docs/content/docs/de/keyboard-shortcuts/index.mdx
Normal file
63
apps/docs/content/docs/de/keyboard-shortcuts/index.mdx
Normal file
@@ -0,0 +1,63 @@
|
||||
---
|
||||
title: Tastaturkürzel
|
||||
description: Meistern Sie die Workflow-Arbeitsfläche mit Tastaturkürzeln und Maussteuerung
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
|
||||
Beschleunigen Sie die Erstellung Ihrer Workflows mit diesen Tastaturkürzeln und Maussteuerungen. Alle Tastenkombinationen funktionieren, wenn die Arbeitsfläche fokussiert ist (nicht beim Tippen in einem Eingabefeld).
|
||||
|
||||
<Callout type="info">
|
||||
**Mod** bezieht sich auf `Cmd` unter macOS und `Ctrl` unter Windows/Linux.
|
||||
</Callout>
|
||||
|
||||
## Arbeitsflächen-Steuerung
|
||||
|
||||
### Maussteuerung
|
||||
|
||||
| Aktion | Steuerung |
|
||||
|--------|---------|
|
||||
| Arbeitsfläche verschieben | Linksziehen auf leerer Fläche |
|
||||
| Arbeitsfläche verschieben | Scrollen oder Trackpad |
|
||||
| Mehrere Blöcke auswählen | Rechtsziehen zum Aufziehen eines Auswahlrahmens |
|
||||
| Block ziehen | Linksziehen auf Block-Kopfzeile |
|
||||
| Zur Auswahl hinzufügen | `Mod` + Klick auf Blöcke |
|
||||
|
||||
### Workflow-Aktionen
|
||||
|
||||
| Tastenkombination | Aktion |
|
||||
|----------|--------|
|
||||
| `Mod` + `Enter` | Workflow ausführen (oder abbrechen, falls aktiv) |
|
||||
| `Mod` + `Z` | Rückgängig |
|
||||
| `Mod` + `Shift` + `Z` | Wiederholen |
|
||||
| `Mod` + `C` | Ausgewählte Blöcke kopieren |
|
||||
| `Mod` + `V` | Blöcke einfügen |
|
||||
| `Delete` oder `Backspace` | Ausgewählte Blöcke oder Verbindungen löschen |
|
||||
| `Shift` + `L` | Arbeitsfläche automatisch anordnen |
|
||||
|
||||
## Panel-Navigation
|
||||
|
||||
Diese Tastenkombinationen wechseln zwischen den Panel-Tabs auf der rechten Seite der Arbeitsfläche.
|
||||
|
||||
| Tastenkombination | Aktion |
|
||||
|----------|--------|
|
||||
| `C` | Copilot-Tab fokussieren |
|
||||
| `T` | Toolbar-Tab fokussieren |
|
||||
| `E` | Editor-Tab fokussieren |
|
||||
| `Mod` + `F` | Toolbar-Suche fokussieren |
|
||||
|
||||
## Globale Navigation
|
||||
|
||||
| Tastenkombination | Aktion |
|
||||
|----------|--------|
|
||||
| `Mod` + `K` | Suche öffnen |
|
||||
| `Mod` + `Shift` + `A` | Neuen Agenten-Workflow hinzufügen |
|
||||
| `Mod` + `Y` | Zu Vorlagen gehen |
|
||||
| `Mod` + `L` | Zu Logs gehen |
|
||||
|
||||
## Dienstprogramm
|
||||
|
||||
| Tastenkombination | Aktion |
|
||||
|----------|--------|
|
||||
| `Mod` + `D` | Terminal-Konsole leeren |
|
||||
| `Mod` + `E` | Benachrichtigungen löschen |
|
||||
@@ -273,7 +273,7 @@ Eine neue Organisation in Jira Service Management erstellen
|
||||
| `name` | string | Name der erstellten Organisation |
|
||||
| `success` | boolean | Ob die Operation erfolgreich war |
|
||||
|
||||
### `jsm_add_organization_to_service_desk`
|
||||
### `jsm_add_organization`
|
||||
|
||||
Eine Organisation zu einem Service Desk in Jira Service Management hinzufügen
|
||||
|
||||
|
||||
64
apps/docs/content/docs/en/keyboard-shortcuts/index.mdx
Normal file
64
apps/docs/content/docs/en/keyboard-shortcuts/index.mdx
Normal file
@@ -0,0 +1,64 @@
|
||||
---
|
||||
title: Keyboard Shortcuts
|
||||
description: Master the workflow canvas with keyboard shortcuts and mouse controls
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
|
||||
Speed up your workflow building with these keyboard shortcuts and mouse controls. All shortcuts work when the canvas is focused (not when typing in an input field).
|
||||
|
||||
<Callout type="info">
|
||||
**Mod** refers to `Cmd` on macOS and `Ctrl` on Windows/Linux.
|
||||
</Callout>
|
||||
|
||||
## Canvas Controls
|
||||
|
||||
### Mouse Controls
|
||||
|
||||
| Action | Control |
|
||||
|--------|---------|
|
||||
| Pan/move canvas | Left-drag on empty space |
|
||||
| Pan/move canvas | Scroll or trackpad |
|
||||
| Select multiple blocks | Right-drag to draw selection box |
|
||||
| Drag block | Left-drag on block header |
|
||||
| Add to selection | `Mod` + click on blocks |
|
||||
|
||||
### Workflow Actions
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| `Mod` + `Enter` | Run workflow (or cancel if running) |
|
||||
| `Mod` + `Z` | Undo |
|
||||
| `Mod` + `Shift` + `Z` | Redo |
|
||||
| `Mod` + `C` | Copy selected blocks |
|
||||
| `Mod` + `V` | Paste blocks |
|
||||
| `Delete` or `Backspace` | Delete selected blocks or edges |
|
||||
| `Shift` + `L` | Auto-layout canvas |
|
||||
|
||||
## Panel Navigation
|
||||
|
||||
These shortcuts switch between panel tabs on the right side of the canvas.
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| `C` | Focus Copilot tab |
|
||||
| `T` | Focus Toolbar tab |
|
||||
| `E` | Focus Editor tab |
|
||||
| `Mod` + `F` | Focus Toolbar search |
|
||||
|
||||
## Global Navigation
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| `Mod` + `K` | Open search |
|
||||
| `Mod` + `Shift` + `A` | Add new agent workflow |
|
||||
| `Mod` + `Y` | Go to templates |
|
||||
| `Mod` + `L` | Go to logs |
|
||||
|
||||
## Utility
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| `Mod` + `D` | Clear terminal console |
|
||||
| `Mod` + `E` | Clear notifications |
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"execution",
|
||||
"permissions",
|
||||
"sdks",
|
||||
"self-hosting"
|
||||
"self-hosting",
|
||||
"./keyboard-shortcuts/index"
|
||||
],
|
||||
"defaultOpen": false
|
||||
}
|
||||
|
||||
@@ -275,7 +275,7 @@ Create a new organization in Jira Service Management
|
||||
| `name` | string | Name of the created organization |
|
||||
| `success` | boolean | Whether the operation succeeded |
|
||||
|
||||
### `jsm_add_organization_to_service_desk`
|
||||
### `jsm_add_organization`
|
||||
|
||||
Add an organization to a service desk in Jira Service Management
|
||||
|
||||
|
||||
64
apps/docs/content/docs/es/keyboard-shortcuts/index.mdx
Normal file
64
apps/docs/content/docs/es/keyboard-shortcuts/index.mdx
Normal file
@@ -0,0 +1,64 @@
|
||||
---
|
||||
title: Atajos de teclado
|
||||
description: Domina el lienzo de flujo de trabajo con atajos de teclado y
|
||||
controles del ratón
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
|
||||
Acelera la creación de tus flujos de trabajo con estos atajos de teclado y controles del ratón. Todos los atajos funcionan cuando el lienzo está enfocado (no cuando estás escribiendo en un campo de entrada).
|
||||
|
||||
<Callout type="info">
|
||||
**Mod** se refiere a `Cmd` en macOS y `Ctrl` en Windows/Linux.
|
||||
</Callout>
|
||||
|
||||
## Controles del lienzo
|
||||
|
||||
### Controles del ratón
|
||||
|
||||
| Acción | Control |
|
||||
|--------|---------|
|
||||
| Desplazar/mover lienzo | Arrastrar con botón izquierdo en espacio vacío |
|
||||
| Desplazar/mover lienzo | Desplazamiento o trackpad |
|
||||
| Seleccionar múltiples bloques | Arrastrar con botón derecho para dibujar cuadro de selección |
|
||||
| Arrastrar bloque | Arrastrar con botón izquierdo en encabezado del bloque |
|
||||
| Añadir a la selección | `Mod` + clic en bloques |
|
||||
|
||||
### Acciones de flujo de trabajo
|
||||
|
||||
| Atajo | Acción |
|
||||
|----------|--------|
|
||||
| `Mod` + `Enter` | Ejecutar flujo de trabajo (o cancelar si está en ejecución) |
|
||||
| `Mod` + `Z` | Deshacer |
|
||||
| `Mod` + `Shift` + `Z` | Rehacer |
|
||||
| `Mod` + `C` | Copiar bloques seleccionados |
|
||||
| `Mod` + `V` | Pegar bloques |
|
||||
| `Delete` o `Backspace` | Eliminar bloques o conexiones seleccionados |
|
||||
| `Shift` + `L` | Diseño automático del lienzo |
|
||||
|
||||
## Navegación de paneles
|
||||
|
||||
Estos atajos cambian entre las pestañas del panel en el lado derecho del lienzo.
|
||||
|
||||
| Atajo | Acción |
|
||||
|----------|--------|
|
||||
| `C` | Enfocar pestaña Copilot |
|
||||
| `T` | Enfocar pestaña Barra de herramientas |
|
||||
| `E` | Enfocar pestaña Editor |
|
||||
| `Mod` + `F` | Enfocar búsqueda de Barra de herramientas |
|
||||
|
||||
## Navegación global
|
||||
|
||||
| Atajo | Acción |
|
||||
|----------|--------|
|
||||
| `Mod` + `K` | Abrir búsqueda |
|
||||
| `Mod` + `Shift` + `A` | Añadir nuevo flujo de trabajo de agente |
|
||||
| `Mod` + `Y` | Ir a plantillas |
|
||||
| `Mod` + `L` | Ir a registros |
|
||||
|
||||
## Utilidad
|
||||
|
||||
| Atajo | Acción |
|
||||
|----------|--------|
|
||||
| `Mod` + `D` | Limpiar consola del terminal |
|
||||
| `Mod` + `E` | Limpiar notificaciones |
|
||||
@@ -273,7 +273,7 @@ Crear una nueva organización en Jira Service Management
|
||||
| `name` | string | Nombre de la organización creada |
|
||||
| `success` | boolean | Si la operación tuvo éxito |
|
||||
|
||||
### `jsm_add_organization_to_service_desk`
|
||||
### `jsm_add_organization`
|
||||
|
||||
Añadir una organización a un service desk en Jira Service Management
|
||||
|
||||
|
||||
64
apps/docs/content/docs/fr/keyboard-shortcuts/index.mdx
Normal file
64
apps/docs/content/docs/fr/keyboard-shortcuts/index.mdx
Normal file
@@ -0,0 +1,64 @@
|
||||
---
|
||||
title: Raccourcis clavier
|
||||
description: Maîtrisez le canevas de workflow avec les raccourcis clavier et les
|
||||
contrôles de souris
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
|
||||
Accélérez la création de vos workflows avec ces raccourcis clavier et contrôles de souris. Tous les raccourcis fonctionnent lorsque le canevas est actif (pas lors de la saisie dans un champ de texte).
|
||||
|
||||
<Callout type="info">
|
||||
**Mod** fait référence à `Cmd` sur macOS et `Ctrl` sur Windows/Linux.
|
||||
</Callout>
|
||||
|
||||
## Contrôles du canevas
|
||||
|
||||
### Contrôles de la souris
|
||||
|
||||
| Action | Contrôle |
|
||||
|--------|---------|
|
||||
| Déplacer le canevas | Glisser-gauche sur un espace vide |
|
||||
| Déplacer le canevas | Molette ou trackpad |
|
||||
| Sélectionner plusieurs blocs | Glisser-droit pour tracer une zone de sélection |
|
||||
| Déplacer un bloc | Glisser-gauche sur l'en-tête du bloc |
|
||||
| Ajouter à la sélection | `Mod` + clic sur les blocs |
|
||||
|
||||
### Actions de workflow
|
||||
|
||||
| Raccourci | Action |
|
||||
|----------|--------|
|
||||
| `Mod` + `Enter` | Exécuter le workflow (ou annuler si en cours) |
|
||||
| `Mod` + `Z` | Annuler |
|
||||
| `Mod` + `Shift` + `Z` | Rétablir |
|
||||
| `Mod` + `C` | Copier les blocs sélectionnés |
|
||||
| `Mod` + `V` | Coller les blocs |
|
||||
| `Delete` ou `Backspace` | Supprimer les blocs ou connexions sélectionnés |
|
||||
| `Shift` + `L` | Disposition automatique du canevas |
|
||||
|
||||
## Navigation dans les panneaux
|
||||
|
||||
Ces raccourcis permettent de basculer entre les onglets du panneau sur le côté droit du canevas.
|
||||
|
||||
| Raccourci | Action |
|
||||
|----------|--------|
|
||||
| `C` | Activer l'onglet Copilot |
|
||||
| `T` | Activer l'onglet Barre d'outils |
|
||||
| `E` | Activer l'onglet Éditeur |
|
||||
| `Mod` + `F` | Activer la recherche dans la barre d'outils |
|
||||
|
||||
## Navigation globale
|
||||
|
||||
| Raccourci | Action |
|
||||
|----------|--------|
|
||||
| `Mod` + `K` | Ouvrir la recherche |
|
||||
| `Mod` + `Shift` + `A` | Ajouter un nouveau workflow d'agent |
|
||||
| `Mod` + `Y` | Aller aux modèles |
|
||||
| `Mod` + `L` | Aller aux journaux |
|
||||
|
||||
## Utilitaire
|
||||
|
||||
| Raccourci | Action |
|
||||
|----------|--------|
|
||||
| `Mod` + `D` | Effacer la console du terminal |
|
||||
| `Mod` + `E` | Effacer les notifications |
|
||||
@@ -273,7 +273,7 @@ Créer une nouvelle organisation dans Jira Service Management
|
||||
| `name` | string | Nom de l'organisation créée |
|
||||
| `success` | boolean | Indique si l'opération a réussi |
|
||||
|
||||
### `jsm_add_organization_to_service_desk`
|
||||
### `jsm_add_organization`
|
||||
|
||||
Ajouter une organisation à un service desk dans Jira Service Management
|
||||
|
||||
|
||||
63
apps/docs/content/docs/ja/keyboard-shortcuts/index.mdx
Normal file
63
apps/docs/content/docs/ja/keyboard-shortcuts/index.mdx
Normal file
@@ -0,0 +1,63 @@
|
||||
---
|
||||
title: キーボードショートカット
|
||||
description: キーボードショートカットとマウス操作でワークフローキャンバスをマスターしましょう
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
|
||||
これらのキーボードショートカットとマウス操作でワークフロー構築を高速化できます。すべてのショートカットは、キャンバスにフォーカスがある時に機能します(入力フィールドに入力中は機能しません)。
|
||||
|
||||
<Callout type="info">
|
||||
**Mod**はmacOSでは`Cmd`、Windows/Linuxでは`Ctrl`を指します。
|
||||
</Callout>
|
||||
|
||||
## キャンバス操作
|
||||
|
||||
### マウス操作
|
||||
|
||||
| 操作 | 操作方法 |
|
||||
|--------|---------|
|
||||
| キャンバスの移動 | 空白部分を左ドラッグ |
|
||||
| キャンバスの移動 | スクロールまたはトラックパッド |
|
||||
| 複数ブロックの選択 | 右ドラッグで選択ボックスを描画 |
|
||||
| ブロックのドラッグ | ブロックヘッダーを左ドラッグ |
|
||||
| 選択に追加 | `Mod` + ブロックをクリック |
|
||||
|
||||
### ワークフロー操作
|
||||
|
||||
| ショートカット | 操作 |
|
||||
|----------|--------|
|
||||
| `Mod` + `Enter` | ワークフローを実行(実行中の場合はキャンセル) |
|
||||
| `Mod` + `Z` | 元に戻す |
|
||||
| `Mod` + `Shift` + `Z` | やり直す |
|
||||
| `Mod` + `C` | 選択したブロックをコピー |
|
||||
| `Mod` + `V` | ブロックを貼り付け |
|
||||
| `Delete`または`Backspace` | 選択したブロックまたはエッジを削除 |
|
||||
| `Shift` + `L` | キャンバスを自動レイアウト |
|
||||
|
||||
## パネルナビゲーション
|
||||
|
||||
これらのショートカットは、キャンバス右側のパネルタブを切り替えます。
|
||||
|
||||
| ショートカット | 操作 |
|
||||
|----------|--------|
|
||||
| `C` | Copilotタブにフォーカス |
|
||||
| `T` | Toolbarタブにフォーカス |
|
||||
| `E` | Editorタブにフォーカス |
|
||||
| `Mod` + `F` | Toolbar検索にフォーカス |
|
||||
|
||||
## グローバルナビゲーション
|
||||
|
||||
| ショートカット | 操作 |
|
||||
|----------|--------|
|
||||
| `Mod` + `K` | 検索を開く |
|
||||
| `Mod` + `Shift` + `A` | 新しいエージェントワークフローを追加 |
|
||||
| `Mod` + `Y` | テンプレートに移動 |
|
||||
| `Mod` + `L` | ログに移動 |
|
||||
|
||||
## ユーティリティ
|
||||
|
||||
| ショートカット | アクション |
|
||||
|----------|--------|
|
||||
| `Mod` + `D` | ターミナルコンソールをクリア |
|
||||
| `Mod` + `E` | 通知をクリア |
|
||||
@@ -273,7 +273,7 @@ Jira Service Managementで新しい組織を作成する
|
||||
| `name` | string | 作成された組織の名前 |
|
||||
| `success` | boolean | 操作が成功したかどうか |
|
||||
|
||||
### `jsm_add_organization_to_service_desk`
|
||||
### `jsm_add_organization`
|
||||
|
||||
Jira Service Managementのサービスデスクに組織を追加する
|
||||
|
||||
|
||||
63
apps/docs/content/docs/zh/keyboard-shortcuts/index.mdx
Normal file
63
apps/docs/content/docs/zh/keyboard-shortcuts/index.mdx
Normal file
@@ -0,0 +1,63 @@
|
||||
---
|
||||
title: 键盘快捷键
|
||||
description: 通过键盘快捷键和鼠标操作,掌控工作流画布
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
|
||||
使用这些键盘快捷键和鼠标操作,可以加快你的工作流构建速度。所有快捷键仅在画布聚焦时有效(在输入框中输入时无效)。
|
||||
|
||||
<Callout type="info">
|
||||
**Mod** 指的是在 macOS 上为 `Cmd`,在 Windows/Linux 上为 `Ctrl`。
|
||||
</Callout>
|
||||
|
||||
## 画布操作
|
||||
|
||||
### 鼠标操作
|
||||
|
||||
| 操作 | 控制方式 |
|
||||
|--------|---------|
|
||||
| 平移/移动画布 | 在空白处左键拖动 |
|
||||
| 平移/移动画布 | 滚轮或触控板 |
|
||||
| 多选区块 | 右键拖动绘制选择框 |
|
||||
| 拖动区块 | 在区块标题处左键拖动 |
|
||||
| 添加到选择 | `Mod` + 点击区块 |
|
||||
|
||||
### 工作流操作
|
||||
|
||||
| 快捷键 | 操作 |
|
||||
|----------|--------|
|
||||
| `Mod` + `Enter` | 运行工作流(如正在运行则取消) |
|
||||
| `Mod` + `Z` | 撤销 |
|
||||
| `Mod` + `Shift` + `Z` | 重做 |
|
||||
| `Mod` + `C` | 复制所选区块 |
|
||||
| `Mod` + `V` | 粘贴区块 |
|
||||
| `Delete` 或 `Backspace` | 删除所选区块或连线 |
|
||||
| `Shift` + `L` | 自动布局画布 |
|
||||
|
||||
## 面板导航
|
||||
|
||||
这些快捷键可在画布右侧的面板标签页之间切换。
|
||||
|
||||
| 快捷键 | 操作 |
|
||||
|----------|--------|
|
||||
| `C` | 聚焦 Copilot 标签页 |
|
||||
| `T` | 聚焦 Toolbar 标签页 |
|
||||
| `E` | 聚焦 Editor 标签页 |
|
||||
| `Mod` + `F` | 聚焦 Toolbar 搜索 |
|
||||
|
||||
## 全局导航
|
||||
|
||||
| 快捷键 | 操作 |
|
||||
|----------|--------|
|
||||
| `Mod` + `K` | 打开搜索 |
|
||||
| `Mod` + `Shift` + `A` | 新建 Agent 工作流 |
|
||||
| `Mod` + `Y` | 跳转到模板 |
|
||||
| `Mod` + `L` | 跳转到日志 |
|
||||
|
||||
## 实用工具
|
||||
|
||||
| 快捷键 | 操作 |
|
||||
|----------|--------|
|
||||
| `Mod` + `D` | 清除终端控制台 |
|
||||
| `Mod` + `E` | 清除通知 |
|
||||
@@ -273,7 +273,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
| `name` | string | 已创建组织的名称 |
|
||||
| `success` | boolean | 操作是否成功 |
|
||||
|
||||
### `jsm_add_organization_to_service_desk`
|
||||
### `jsm_add_organization`
|
||||
|
||||
在 Jira Service Management 中将组织添加到服务台
|
||||
|
||||
|
||||
@@ -50077,7 +50077,7 @@ checksums:
|
||||
content/68: b7afc8fa3b22ea9327e336f50b82a27c
|
||||
content/69: bcadfc362b69078beee0088e5936c98b
|
||||
content/70: 0337e5d7f0bad113be176419350a41b6
|
||||
content/71: ef61f2bab8cfd25a5228d9df3ff6cf3c
|
||||
content/71: bb403ace5373d843beffe220c9a8d618
|
||||
content/72: 35a991daf9336e6bba2bd8818dd66594
|
||||
content/73: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/74: 13644af4a2d5aea5061e9945e91f5a4f
|
||||
@@ -50166,3 +50166,21 @@ checksums:
|
||||
content/27: 764eb0e5d025b68f772d45adb7608349
|
||||
content/28: 47eb215a0fc230dc651b7bc05ab25ed0
|
||||
content/29: bf5c6bf1e75c5c5e3a0a5dd1314cb41e
|
||||
ed03212dda9fce53ddf623d1c4587006:
|
||||
meta/title: ef00d7494b69def6841620bd6554d040
|
||||
meta/description: 4b66a56c6ccc3c7e630dfc45eb8bfdf8
|
||||
content/0: 232be69c8f3053a40f695f9c9dcb3f2e
|
||||
content/1: 0628b1e7f70de9f2b5dff99452111de9
|
||||
content/2: fa4a0821069063d96727598f379fb619
|
||||
content/3: a3825edbe4c255e7370624d27b734399
|
||||
content/4: 5be2f96951187cdbf39ed7d879322cef
|
||||
content/5: 4940f2e763be1990113195e4667ff49a
|
||||
content/6: 27c579ade1a1be3e514d880388c58c2b
|
||||
content/7: 125beef2eb1e60a492faa9dc03fca0b4
|
||||
content/8: 62d5214cb3e3ec863bd5b6d74e0df126
|
||||
content/9: 421b088722ccb029a93a2388cf47d9b3
|
||||
content/10: e9ddc04f492fea4fb96bfd7fcd3eb84a
|
||||
content/11: be8e3a9794f70b9c03373db88ffc43ce
|
||||
content/12: 3a322eee25c8bd5d81e7ae92f4239300
|
||||
content/13: a82eb7d47a82c3289a00ccf27a860685
|
||||
content/14: 26b9713de1a21d662c198154b673fd7d
|
||||
|
||||
@@ -1049,7 +1049,7 @@ export function Chat() {
|
||||
onClick={() => document.getElementById('floating-chat-file-input')?.click()}
|
||||
title='Attach file'
|
||||
className={cn(
|
||||
'!bg-transparent cursor-pointer rounded-[6px] p-[0px]',
|
||||
'!bg-transparent !border-0 cursor-pointer rounded-[6px] p-[0px]',
|
||||
(!activeWorkflowId || isExecuting || chatFiles.length >= 15) &&
|
||||
'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
'use client'
|
||||
|
||||
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
|
||||
import type { BlockContextMenuProps } from './types'
|
||||
|
||||
/**
|
||||
* Context menu for workflow block(s).
|
||||
* Displays block-specific actions in a popover at right-click position.
|
||||
* Supports multi-selection - actions apply to all selected blocks.
|
||||
*/
|
||||
export function BlockContextMenu({
|
||||
isOpen,
|
||||
position,
|
||||
menuRef,
|
||||
onClose,
|
||||
selectedBlocks,
|
||||
onCopy,
|
||||
onPaste,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
onToggleEnabled,
|
||||
onToggleHandles,
|
||||
onRemoveFromSubflow,
|
||||
onOpenEditor,
|
||||
onRename,
|
||||
hasClipboard = false,
|
||||
showRemoveFromSubflow = false,
|
||||
disableEdit = false,
|
||||
}: BlockContextMenuProps) {
|
||||
const isSingleBlock = selectedBlocks.length === 1
|
||||
|
||||
const allEnabled = selectedBlocks.every((b) => b.enabled)
|
||||
const allDisabled = selectedBlocks.every((b) => !b.enabled)
|
||||
|
||||
const hasStarterBlock = selectedBlocks.some(
|
||||
(b) => b.type === 'starter' || b.type === 'start_trigger'
|
||||
)
|
||||
const allNoteBlocks = selectedBlocks.every((b) => b.type === 'note')
|
||||
const isSubflow =
|
||||
isSingleBlock && (selectedBlocks[0]?.type === 'loop' || selectedBlocks[0]?.type === 'parallel')
|
||||
|
||||
const canRemoveFromSubflow = showRemoveFromSubflow && !hasStarterBlock
|
||||
|
||||
const getToggleEnabledLabel = () => {
|
||||
if (allEnabled) return 'Disable'
|
||||
if (allDisabled) return 'Enable'
|
||||
return 'Toggle Enabled'
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
}}
|
||||
/>
|
||||
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
|
||||
{/* Copy */}
|
||||
<PopoverItem
|
||||
className='group'
|
||||
onClick={() => {
|
||||
onCopy()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<span>Copy</span>
|
||||
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⌘C</span>
|
||||
</PopoverItem>
|
||||
|
||||
{/* Paste */}
|
||||
<PopoverItem
|
||||
className='group'
|
||||
disabled={disableEdit || !hasClipboard}
|
||||
onClick={() => {
|
||||
onPaste()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<span>Paste</span>
|
||||
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⌘V</span>
|
||||
</PopoverItem>
|
||||
|
||||
{/* Duplicate - hide for starter blocks */}
|
||||
{!hasStarterBlock && (
|
||||
<PopoverItem
|
||||
disabled={disableEdit}
|
||||
onClick={() => {
|
||||
onDuplicate()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Duplicate
|
||||
</PopoverItem>
|
||||
)}
|
||||
|
||||
{/* Delete */}
|
||||
<PopoverItem
|
||||
className='group'
|
||||
disabled={disableEdit}
|
||||
onClick={() => {
|
||||
onDelete()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<span>Delete</span>
|
||||
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⌫</span>
|
||||
</PopoverItem>
|
||||
|
||||
{/* Enable/Disable - hide if all blocks are notes */}
|
||||
{!allNoteBlocks && (
|
||||
<PopoverItem
|
||||
disabled={disableEdit}
|
||||
onClick={() => {
|
||||
onToggleEnabled()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
{getToggleEnabledLabel()}
|
||||
</PopoverItem>
|
||||
)}
|
||||
|
||||
{/* Flip Handles - hide if all blocks are notes */}
|
||||
{!allNoteBlocks && (
|
||||
<PopoverItem
|
||||
disabled={disableEdit}
|
||||
onClick={() => {
|
||||
onToggleHandles()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Flip Handles
|
||||
</PopoverItem>
|
||||
)}
|
||||
|
||||
{/* Remove from Subflow - only show when applicable */}
|
||||
{canRemoveFromSubflow && (
|
||||
<PopoverItem
|
||||
disabled={disableEdit}
|
||||
onClick={() => {
|
||||
onRemoveFromSubflow()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Remove from Subflow
|
||||
</PopoverItem>
|
||||
)}
|
||||
|
||||
{/* Rename - only for single block, not subflows */}
|
||||
{isSingleBlock && !isSubflow && (
|
||||
<PopoverItem
|
||||
disabled={disableEdit}
|
||||
onClick={() => {
|
||||
onRename()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</PopoverItem>
|
||||
)}
|
||||
|
||||
{/* Open Editor - only for single block */}
|
||||
{isSingleBlock && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onOpenEditor()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Open Editor
|
||||
</PopoverItem>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { BlockContextMenu } from './block-context-menu'
|
||||
export { PaneContextMenu } from './pane-context-menu'
|
||||
export type {
|
||||
BlockContextMenuProps,
|
||||
ContextMenuBlockInfo,
|
||||
ContextMenuPosition,
|
||||
PaneContextMenuProps,
|
||||
} from './types'
|
||||
@@ -0,0 +1,152 @@
|
||||
'use client'
|
||||
|
||||
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
|
||||
import type { PaneContextMenuProps } from './types'
|
||||
|
||||
/**
|
||||
* Context menu for workflow canvas pane.
|
||||
* Displays canvas-level actions when right-clicking empty space.
|
||||
*/
|
||||
export function PaneContextMenu({
|
||||
isOpen,
|
||||
position,
|
||||
menuRef,
|
||||
onClose,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onPaste,
|
||||
onAddBlock,
|
||||
onAutoLayout,
|
||||
onOpenLogs,
|
||||
onOpenVariables,
|
||||
onOpenChat,
|
||||
onInvite,
|
||||
hasClipboard = false,
|
||||
disableEdit = false,
|
||||
disableAdmin = false,
|
||||
canUndo = false,
|
||||
canRedo = false,
|
||||
}: PaneContextMenuProps) {
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
}}
|
||||
/>
|
||||
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
|
||||
{/* Undo */}
|
||||
<PopoverItem
|
||||
className='group'
|
||||
disabled={disableEdit || !canUndo}
|
||||
onClick={() => {
|
||||
onUndo()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<span>Undo</span>
|
||||
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⌘Z</span>
|
||||
</PopoverItem>
|
||||
|
||||
{/* Redo */}
|
||||
<PopoverItem
|
||||
className='group'
|
||||
disabled={disableEdit || !canRedo}
|
||||
onClick={() => {
|
||||
onRedo()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<span>Redo</span>
|
||||
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⌘⇧Z</span>
|
||||
</PopoverItem>
|
||||
|
||||
{/* Paste */}
|
||||
<PopoverItem
|
||||
className='group'
|
||||
disabled={disableEdit || !hasClipboard}
|
||||
onClick={() => {
|
||||
onPaste()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<span>Paste</span>
|
||||
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⌘V</span>
|
||||
</PopoverItem>
|
||||
|
||||
{/* Add Block */}
|
||||
<PopoverItem
|
||||
className='group'
|
||||
disabled={disableEdit}
|
||||
onClick={() => {
|
||||
onAddBlock()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<span>Add Block</span>
|
||||
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⌘K</span>
|
||||
</PopoverItem>
|
||||
|
||||
{/* Auto-layout */}
|
||||
<PopoverItem
|
||||
className='group'
|
||||
disabled={disableEdit}
|
||||
onClick={() => {
|
||||
onAutoLayout()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<span>Auto-layout</span>
|
||||
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⇧L</span>
|
||||
</PopoverItem>
|
||||
|
||||
{/* Open Logs */}
|
||||
<PopoverItem
|
||||
className='group'
|
||||
onClick={() => {
|
||||
onOpenLogs()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<span>Open Logs</span>
|
||||
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⌘L</span>
|
||||
</PopoverItem>
|
||||
|
||||
{/* Open Variables */}
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onOpenVariables()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Variables
|
||||
</PopoverItem>
|
||||
|
||||
{/* Open Chat */}
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onOpenChat()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Open Chat
|
||||
</PopoverItem>
|
||||
|
||||
{/* Invite to Workspace - admin only */}
|
||||
<PopoverItem
|
||||
disabled={disableAdmin}
|
||||
onClick={() => {
|
||||
onInvite()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Invite to Workspace
|
||||
</PopoverItem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { RefObject } from 'react'
|
||||
|
||||
/**
|
||||
* Position for context menu placement
|
||||
*/
|
||||
export interface ContextMenuPosition {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Block information passed to context menu for action handling
|
||||
*/
|
||||
export interface ContextMenuBlockInfo {
|
||||
/** Block ID */
|
||||
id: string
|
||||
/** Block type (e.g., 'agent', 'function', 'loop') */
|
||||
type: string
|
||||
/** Whether block is enabled */
|
||||
enabled: boolean
|
||||
/** Whether block uses horizontal handles */
|
||||
horizontalHandles: boolean
|
||||
/** Parent subflow ID if nested in loop/parallel */
|
||||
parentId?: string
|
||||
/** Parent type ('loop' | 'parallel') if nested */
|
||||
parentType?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for BlockContextMenu component
|
||||
*/
|
||||
export interface BlockContextMenuProps {
|
||||
/** Whether the context menu is open */
|
||||
isOpen: boolean
|
||||
/** Position of the context menu */
|
||||
position: ContextMenuPosition
|
||||
/** Ref for the menu element (for click-outside detection) */
|
||||
menuRef: RefObject<HTMLDivElement | null>
|
||||
/** Callback when menu should close */
|
||||
onClose: () => void
|
||||
/** Selected block(s) info */
|
||||
selectedBlocks: ContextMenuBlockInfo[]
|
||||
/** Callbacks for menu actions */
|
||||
onCopy: () => void
|
||||
onPaste: () => void
|
||||
onDuplicate: () => void
|
||||
onDelete: () => void
|
||||
onToggleEnabled: () => void
|
||||
onToggleHandles: () => void
|
||||
onRemoveFromSubflow: () => void
|
||||
onOpenEditor: () => void
|
||||
onRename: () => void
|
||||
/** Whether clipboard has content for pasting */
|
||||
hasClipboard?: boolean
|
||||
/** Whether remove from subflow option should be shown */
|
||||
showRemoveFromSubflow?: boolean
|
||||
/** Whether edit actions are disabled (no permission) */
|
||||
disableEdit?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for PaneContextMenu component
|
||||
*/
|
||||
export interface PaneContextMenuProps {
|
||||
/** Whether the context menu is open */
|
||||
isOpen: boolean
|
||||
/** Position of the context menu */
|
||||
position: ContextMenuPosition
|
||||
/** Ref for the menu element */
|
||||
menuRef: RefObject<HTMLDivElement | null>
|
||||
/** Callback when menu should close */
|
||||
onClose: () => void
|
||||
/** Callbacks for menu actions */
|
||||
onUndo: () => void
|
||||
onRedo: () => void
|
||||
onPaste: () => void
|
||||
onAddBlock: () => void
|
||||
onAutoLayout: () => void
|
||||
onOpenLogs: () => void
|
||||
onOpenVariables: () => void
|
||||
onOpenChat: () => void
|
||||
onInvite: () => void
|
||||
/** Whether clipboard has content for pasting */
|
||||
hasClipboard?: boolean
|
||||
/** Whether edit actions are disabled (no permission) */
|
||||
disableEdit?: boolean
|
||||
/** Whether admin actions are disabled (no admin permission) */
|
||||
disableAdmin?: boolean
|
||||
/** Whether undo is available */
|
||||
canUndo?: boolean
|
||||
/** Whether redo is available */
|
||||
canRedo?: boolean
|
||||
}
|
||||
@@ -42,7 +42,13 @@ const IconComponent = ({ icon: Icon, className }: { icon: any; className?: strin
|
||||
* @returns Editor panel content
|
||||
*/
|
||||
export function Editor() {
|
||||
const { currentBlockId, connectionsHeight, toggleConnectionsCollapsed } = usePanelEditorStore()
|
||||
const {
|
||||
currentBlockId,
|
||||
connectionsHeight,
|
||||
toggleConnectionsCollapsed,
|
||||
shouldFocusRename,
|
||||
setShouldFocusRename,
|
||||
} = usePanelEditorStore()
|
||||
const currentWorkflow = useCurrentWorkflow()
|
||||
const currentBlock = currentBlockId ? currentWorkflow.getBlockById(currentBlockId) : null
|
||||
const blockConfig = currentBlock ? getBlock(currentBlock.type) : null
|
||||
@@ -158,6 +164,14 @@ export function Editor() {
|
||||
}
|
||||
}, [isRenaming])
|
||||
|
||||
// Trigger rename mode when signaled from context menu
|
||||
useEffect(() => {
|
||||
if (shouldFocusRename && currentBlock && !isSubflow) {
|
||||
handleStartRename()
|
||||
setShouldFocusRename(false)
|
||||
}
|
||||
}, [shouldFocusRename, currentBlock, isSubflow, handleStartRename, setShouldFocusRename])
|
||||
|
||||
/**
|
||||
* Handles opening documentation link in a new secure tab.
|
||||
*/
|
||||
|
||||
@@ -61,7 +61,7 @@ export interface SubflowNodeData {
|
||||
*/
|
||||
export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeData>) => {
|
||||
const { getNodes } = useReactFlow()
|
||||
const { collaborativeRemoveBlock } = useCollaborativeWorkflow()
|
||||
const { collaborativeBatchRemoveBlocks } = useCollaborativeWorkflow()
|
||||
const blockRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const currentWorkflow = useCurrentWorkflow()
|
||||
@@ -184,7 +184,7 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
collaborativeRemoveBlock(id)
|
||||
collaborativeBatchRemoveBlocks([id])
|
||||
}}
|
||||
className='h-[14px] w-[14px] p-0 opacity-0 transition-opacity duration-100 group-hover:opacity-100'
|
||||
>
|
||||
|
||||
@@ -4,8 +4,13 @@ import { Button, Copy, Tooltip, Trash2 } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { getUniqueBlockName, prepareDuplicateBlockState } from '@/stores/workflows/utils'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
const DEFAULT_DUPLICATE_OFFSET = { x: 50, y: 50 }
|
||||
|
||||
/**
|
||||
* Props for the ActionBar component
|
||||
*/
|
||||
@@ -27,11 +32,39 @@ interface ActionBarProps {
|
||||
export const ActionBar = memo(
|
||||
function ActionBar({ blockId, blockType, disabled = false }: ActionBarProps) {
|
||||
const {
|
||||
collaborativeRemoveBlock,
|
||||
collaborativeBatchAddBlocks,
|
||||
collaborativeBatchRemoveBlocks,
|
||||
collaborativeToggleBlockEnabled,
|
||||
collaborativeDuplicateBlock,
|
||||
collaborativeToggleBlockHandles,
|
||||
} = useCollaborativeWorkflow()
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
const blocks = useWorkflowStore((state) => state.blocks)
|
||||
const subBlockStore = useSubBlockStore()
|
||||
|
||||
const handleDuplicateBlock = useCallback(() => {
|
||||
const sourceBlock = blocks[blockId]
|
||||
if (!sourceBlock) return
|
||||
|
||||
const newId = crypto.randomUUID()
|
||||
const newName = getUniqueBlockName(sourceBlock.name, blocks)
|
||||
const subBlockValues = subBlockStore.workflowValues[activeWorkflowId || '']?.[blockId] || {}
|
||||
|
||||
const { block, subBlockValues: filteredValues } = prepareDuplicateBlockState({
|
||||
sourceBlock,
|
||||
newId,
|
||||
newName,
|
||||
positionOffset: DEFAULT_DUPLICATE_OFFSET,
|
||||
subBlockValues,
|
||||
})
|
||||
|
||||
collaborativeBatchAddBlocks([block], [], {}, {}, { [newId]: filteredValues })
|
||||
}, [
|
||||
blockId,
|
||||
blocks,
|
||||
activeWorkflowId,
|
||||
subBlockStore.workflowValues,
|
||||
collaborativeBatchAddBlocks,
|
||||
])
|
||||
|
||||
/**
|
||||
* Optimized single store subscription for all block data
|
||||
@@ -115,7 +148,7 @@ export const ActionBar = memo(
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!disabled) {
|
||||
collaborativeDuplicateBlock(blockId)
|
||||
handleDuplicateBlock()
|
||||
}
|
||||
}}
|
||||
className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-7)] p-0 text-[var(--text-secondary)] hover:bg-[var(--brand-secondary)]'
|
||||
@@ -185,7 +218,7 @@ export const ActionBar = memo(
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!disabled) {
|
||||
collaborativeRemoveBlock(blockId)
|
||||
collaborativeBatchRemoveBlocks([blockId])
|
||||
}
|
||||
}}
|
||||
className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-7)] p-0 text-[var(--text-secondary)] hover:bg-[var(--brand-secondary)]'
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { Node } from 'reactflow'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
import type { ContextMenuBlockInfo, ContextMenuPosition } from '../components/context-menu/types'
|
||||
|
||||
type MenuType = 'block' | 'pane' | null
|
||||
|
||||
interface UseCanvasContextMenuProps {
|
||||
/** Current blocks from workflow store */
|
||||
blocks: Record<string, BlockState>
|
||||
/** Function to get nodes from ReactFlow */
|
||||
getNodes: () => Node[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing workflow canvas context menus.
|
||||
*
|
||||
* Handles:
|
||||
* - Right-click event handling for blocks and pane
|
||||
* - Menu open/close state for both menu types
|
||||
* - Click-outside detection to close menus
|
||||
* - Selected block info extraction for multi-selection support
|
||||
*/
|
||||
export function useCanvasContextMenu({ blocks, getNodes }: UseCanvasContextMenuProps) {
|
||||
const [activeMenu, setActiveMenu] = useState<MenuType>(null)
|
||||
const [position, setPosition] = useState<ContextMenuPosition>({ x: 0, y: 0 })
|
||||
const [selectedBlocks, setSelectedBlocks] = useState<ContextMenuBlockInfo[]>([])
|
||||
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
/** Converts nodes to block info for context menu */
|
||||
const nodesToBlockInfos = useCallback(
|
||||
(nodes: Node[]): ContextMenuBlockInfo[] =>
|
||||
nodes.map((n) => {
|
||||
const block = blocks[n.id]
|
||||
const parentId = block?.data?.parentId
|
||||
const parentType = parentId ? blocks[parentId]?.type : undefined
|
||||
return {
|
||||
id: n.id,
|
||||
type: block?.type || '',
|
||||
enabled: block?.enabled ?? true,
|
||||
horizontalHandles: block?.horizontalHandles ?? false,
|
||||
parentId,
|
||||
parentType,
|
||||
}
|
||||
}),
|
||||
[blocks]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handle right-click on a node (block)
|
||||
*/
|
||||
const handleNodeContextMenu = useCallback(
|
||||
(event: React.MouseEvent, node: Node) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const selectedNodes = getNodes().filter((n) => n.selected)
|
||||
const nodesToUse = selectedNodes.some((n) => n.id === node.id) ? selectedNodes : [node]
|
||||
|
||||
setPosition({ x: event.clientX, y: event.clientY })
|
||||
setSelectedBlocks(nodesToBlockInfos(nodesToUse))
|
||||
setActiveMenu('block')
|
||||
},
|
||||
[getNodes, nodesToBlockInfos]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handle right-click on the pane (empty canvas area)
|
||||
*/
|
||||
const handlePaneContextMenu = useCallback((event: React.MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
setPosition({ x: event.clientX, y: event.clientY })
|
||||
setSelectedBlocks([])
|
||||
setActiveMenu('pane')
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Handle right-click on a selection (multiple selected nodes)
|
||||
*/
|
||||
const handleSelectionContextMenu = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const selectedNodes = getNodes().filter((n) => n.selected)
|
||||
|
||||
setPosition({ x: event.clientX, y: event.clientY })
|
||||
setSelectedBlocks(nodesToBlockInfos(selectedNodes))
|
||||
setActiveMenu('block')
|
||||
},
|
||||
[getNodes, nodesToBlockInfos]
|
||||
)
|
||||
|
||||
/**
|
||||
* Close the active context menu
|
||||
*/
|
||||
const closeMenu = useCallback(() => {
|
||||
setActiveMenu(null)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Handle clicks outside the menu to close it
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!activeMenu) return
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as globalThis.Node)) {
|
||||
closeMenu()
|
||||
}
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
}, 0)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId)
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
}
|
||||
}, [activeMenu, closeMenu])
|
||||
|
||||
/**
|
||||
* Close menu on scroll or zoom to prevent menu from being positioned incorrectly
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!activeMenu) return
|
||||
|
||||
const handleScroll = () => closeMenu()
|
||||
|
||||
window.addEventListener('wheel', handleScroll, { passive: true })
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('wheel', handleScroll)
|
||||
}
|
||||
}, [activeMenu, closeMenu])
|
||||
|
||||
return {
|
||||
/** Whether the block context menu is open */
|
||||
isBlockMenuOpen: activeMenu === 'block',
|
||||
/** Whether the pane context menu is open */
|
||||
isPaneMenuOpen: activeMenu === 'pane',
|
||||
/** Position for the context menu */
|
||||
position,
|
||||
/** Ref for the menu element */
|
||||
menuRef,
|
||||
/** Selected blocks info for multi-selection actions */
|
||||
selectedBlocks,
|
||||
/** Handler for ReactFlow onNodeContextMenu */
|
||||
handleNodeContextMenu,
|
||||
/** Handler for ReactFlow onPaneContextMenu */
|
||||
handlePaneContextMenu,
|
||||
/** Handler for ReactFlow onSelectionContextMenu */
|
||||
handleSelectionContextMenu,
|
||||
/** Close the active context menu */
|
||||
closeMenu,
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/comp
|
||||
|
||||
export default function WorkflowLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<main className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
|
||||
<main className='flex h-full flex-1 flex-col overflow-hidden'>
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
</main>
|
||||
)
|
||||
|
||||
@@ -16,6 +16,7 @@ import ReactFlow, {
|
||||
import 'reactflow/dist/style.css'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import type { OAuthConnectEventDetail } from '@/lib/copilot/tools/client/other/oauth-request-access'
|
||||
import type { OAuthProvider } from '@/lib/oauth'
|
||||
import { DEFAULT_HORIZONTAL_SPACING } from '@/lib/workflows/autolayout/constants'
|
||||
@@ -30,6 +31,10 @@ import {
|
||||
SubflowNodeComponent,
|
||||
Terminal,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components'
|
||||
import {
|
||||
BlockContextMenu,
|
||||
PaneContextMenu,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu'
|
||||
import { Cursors } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/cursors/cursors'
|
||||
import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index'
|
||||
import { NoteBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block'
|
||||
@@ -42,6 +47,7 @@ import {
|
||||
useCurrentWorkflow,
|
||||
useNodeUtilities,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import { useCanvasContextMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-canvas-context-menu'
|
||||
import {
|
||||
clampPositionToContainer,
|
||||
estimateBlockDimensions,
|
||||
@@ -52,15 +58,19 @@ import { isAnnotationOnlyBlock } from '@/executor/constants'
|
||||
import { useWorkspaceEnvironment } from '@/hooks/queries/environment'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useStreamCleanup } from '@/hooks/use-stream-cleanup'
|
||||
import { useChatStore } from '@/stores/chat/store'
|
||||
import { useCopilotTrainingStore } from '@/stores/copilot-training/store'
|
||||
import { useExecutionStore } from '@/stores/execution/store'
|
||||
import { useNotificationStore } from '@/stores/notifications/store'
|
||||
import { useCopilotStore } from '@/stores/panel/copilot/store'
|
||||
import { usePanelEditorStore } from '@/stores/panel/editor/store'
|
||||
import { useSearchModalStore } from '@/stores/search-modal/store'
|
||||
import { useGeneralStore } from '@/stores/settings/general/store'
|
||||
import { useUndoRedoStore } from '@/stores/undo-redo'
|
||||
import { useVariablesStore } from '@/stores/variables/store'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { getUniqueBlockName } from '@/stores/workflows/utils'
|
||||
import { getUniqueBlockName, prepareBlockState } from '@/stores/workflows/utils'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
/** Lazy-loaded components for non-critical UI that can load after initial render */
|
||||
@@ -77,6 +87,59 @@ const LazyOAuthRequiredModal = lazy(() =>
|
||||
|
||||
const logger = createLogger('Workflow')
|
||||
|
||||
const DEFAULT_PASTE_OFFSET = { x: 50, y: 50 }
|
||||
|
||||
/**
|
||||
* Calculates the offset to paste blocks at viewport center
|
||||
*/
|
||||
function calculatePasteOffset(
|
||||
clipboard: {
|
||||
blocks: Record<string, { position: { x: number; y: number }; type: string; height?: number }>
|
||||
} | null,
|
||||
screenToFlowPosition: (pos: { x: number; y: number }) => { x: number; y: number }
|
||||
): { x: number; y: number } {
|
||||
if (!clipboard) return DEFAULT_PASTE_OFFSET
|
||||
|
||||
const clipboardBlocks = Object.values(clipboard.blocks)
|
||||
if (clipboardBlocks.length === 0) return DEFAULT_PASTE_OFFSET
|
||||
|
||||
const minX = Math.min(...clipboardBlocks.map((b) => b.position.x))
|
||||
const maxX = Math.max(
|
||||
...clipboardBlocks.map((b) => {
|
||||
const width =
|
||||
b.type === 'loop' || b.type === 'parallel'
|
||||
? CONTAINER_DIMENSIONS.DEFAULT_WIDTH
|
||||
: BLOCK_DIMENSIONS.FIXED_WIDTH
|
||||
return b.position.x + width
|
||||
})
|
||||
)
|
||||
const minY = Math.min(...clipboardBlocks.map((b) => b.position.y))
|
||||
const maxY = Math.max(
|
||||
...clipboardBlocks.map((b) => {
|
||||
const height =
|
||||
b.type === 'loop' || b.type === 'parallel'
|
||||
? CONTAINER_DIMENSIONS.DEFAULT_HEIGHT
|
||||
: Math.max(b.height || BLOCK_DIMENSIONS.MIN_HEIGHT, BLOCK_DIMENSIONS.MIN_HEIGHT)
|
||||
return b.position.y + height
|
||||
})
|
||||
)
|
||||
const clipboardCenter = { x: (minX + maxX) / 2, y: (minY + maxY) / 2 }
|
||||
|
||||
const flowContainer = document.querySelector('.react-flow')
|
||||
if (!flowContainer) return DEFAULT_PASTE_OFFSET
|
||||
|
||||
const rect = flowContainer.getBoundingClientRect()
|
||||
const viewportCenter = screenToFlowPosition({
|
||||
x: rect.width / 2,
|
||||
y: rect.height / 2,
|
||||
})
|
||||
|
||||
return {
|
||||
x: viewportCenter.x - clipboardCenter.x,
|
||||
y: viewportCenter.y - clipboardCenter.y,
|
||||
}
|
||||
}
|
||||
|
||||
/** Custom node types for ReactFlow. */
|
||||
const nodeTypes: NodeTypes = {
|
||||
workflowBlock: WorkflowBlock,
|
||||
@@ -93,20 +156,14 @@ const edgeTypes: EdgeTypes = {
|
||||
/** ReactFlow configuration constants. */
|
||||
const defaultEdgeOptions = { type: 'custom' }
|
||||
|
||||
/** Tailwind classes for ReactFlow internal element styling */
|
||||
const reactFlowStyles = [
|
||||
// Z-index layering
|
||||
'[&_.react-flow__edges]:!z-0',
|
||||
'[&_.react-flow__node]:!z-[21]',
|
||||
'[&_.react-flow__handle]:!z-[30]',
|
||||
'[&_.react-flow__edge-labels]:!z-[60]',
|
||||
// Light mode: transparent pane to show dots
|
||||
'[&_.react-flow__pane]:!bg-transparent',
|
||||
'[&_.react-flow__renderer]:!bg-transparent',
|
||||
// Dark mode: solid background, hide dots
|
||||
'dark:[&_.react-flow__pane]:!bg-[var(--bg)]',
|
||||
'dark:[&_.react-flow__renderer]:!bg-[var(--bg)]',
|
||||
'dark:[&_.react-flow__background]:hidden',
|
||||
'[&_.react-flow__background]:hidden',
|
||||
].join(' ')
|
||||
const reactFlowFitViewOptions = { padding: 0.6 } as const
|
||||
const reactFlowProOptions = { hideAttribution: true } as const
|
||||
@@ -132,6 +189,8 @@ const WorkflowContent = React.memo(() => {
|
||||
const [isCanvasReady, setIsCanvasReady] = useState(false)
|
||||
const [potentialParentId, setPotentialParentId] = useState<string | null>(null)
|
||||
const [selectedEdgeInfo, setSelectedEdgeInfo] = useState<SelectedEdgeInfo | null>(null)
|
||||
const [isShiftPressed, setIsShiftPressed] = useState(false)
|
||||
const [isSelectionDragActive, setIsSelectionDragActive] = useState(false)
|
||||
const [isErrorConnectionDrag, setIsErrorConnectionDrag] = useState(false)
|
||||
const [oauthModal, setOauthModal] = useState<{
|
||||
provider: OAuthProvider
|
||||
@@ -143,7 +202,7 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const { screenToFlowPosition, getNodes, fitView, getIntersectingNodes } = useReactFlow()
|
||||
const { screenToFlowPosition, getNodes, setNodes, fitView, getIntersectingNodes } = useReactFlow()
|
||||
const { emitCursorUpdate } = useSocket()
|
||||
|
||||
const workspaceId = params.workspaceId as string
|
||||
@@ -151,17 +210,39 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
const addNotification = useNotificationStore((state) => state.addNotification)
|
||||
|
||||
const { workflows, activeWorkflowId, hydration, setActiveWorkflow } = useWorkflowRegistry(
|
||||
const {
|
||||
workflows,
|
||||
activeWorkflowId,
|
||||
hydration,
|
||||
setActiveWorkflow,
|
||||
copyBlocks,
|
||||
preparePasteData,
|
||||
hasClipboard,
|
||||
clipboard,
|
||||
} = useWorkflowRegistry(
|
||||
useShallow((state) => ({
|
||||
workflows: state.workflows,
|
||||
activeWorkflowId: state.activeWorkflowId,
|
||||
hydration: state.hydration,
|
||||
setActiveWorkflow: state.setActiveWorkflow,
|
||||
copyBlocks: state.copyBlocks,
|
||||
preparePasteData: state.preparePasteData,
|
||||
hasClipboard: state.hasClipboard,
|
||||
clipboard: state.clipboard,
|
||||
}))
|
||||
)
|
||||
|
||||
const currentWorkflow = useCurrentWorkflow()
|
||||
|
||||
// Undo/redo availability for context menu
|
||||
const { data: session } = useSession()
|
||||
const userId = session?.user?.id || 'unknown'
|
||||
const undoRedoStacks = useUndoRedoStore((s) => s.stacks)
|
||||
const undoRedoKey = activeWorkflowId && userId ? `${activeWorkflowId}:${userId}` : ''
|
||||
const undoRedoStack = (undoRedoKey && undoRedoStacks[undoRedoKey]) || { undo: [], redo: [] }
|
||||
const canUndo = undoRedoStack.undo.length > 0
|
||||
const canRedo = undoRedoStack.redo.length > 0
|
||||
|
||||
const { updateNodeDimensions, setDragStartPosition, getDragStartPosition } = useWorkflowStore(
|
||||
useShallow((state) => ({
|
||||
updateNodeDimensions: state.updateNodeDimensions,
|
||||
@@ -172,10 +253,8 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
const copilotCleanup = useCopilotStore((state) => state.cleanup)
|
||||
|
||||
// Training modal state
|
||||
const showTrainingModal = useCopilotTrainingStore((state) => state.showModal)
|
||||
|
||||
// Snap to grid settings
|
||||
const snapToGridSize = useGeneralStore((state) => state.snapToGridSize)
|
||||
const snapToGrid = snapToGridSize > 0
|
||||
const snapGrid: [number, number] = useMemo(
|
||||
@@ -183,7 +262,6 @@ const WorkflowContent = React.memo(() => {
|
||||
[snapToGridSize]
|
||||
)
|
||||
|
||||
// Handle copilot stream cleanup on page unload and component unmount
|
||||
useStreamCleanup(copilotCleanup)
|
||||
|
||||
const { blocks, edges, isDiffMode, lastSaved } = currentWorkflow
|
||||
@@ -208,7 +286,6 @@ const WorkflowContent = React.memo(() => {
|
||||
getBlockDimensions,
|
||||
} = useNodeUtilities(blocks)
|
||||
|
||||
/** Triggers immediate subflow resize without delays. */
|
||||
const resizeLoopNodesWrapper = useCallback(() => {
|
||||
return resizeLoopNodes(updateNodeDimensions)
|
||||
}, [resizeLoopNodes, updateNodeDimensions])
|
||||
@@ -336,16 +413,58 @@ const WorkflowContent = React.memo(() => {
|
||||
}, [userPermissions, currentWorkflow.isSnapshotView])
|
||||
|
||||
const {
|
||||
collaborativeAddBlock: addBlock,
|
||||
collaborativeAddEdge: addEdge,
|
||||
collaborativeRemoveBlock: removeBlock,
|
||||
collaborativeRemoveEdge: removeEdge,
|
||||
collaborativeUpdateBlockPosition,
|
||||
collaborativeBatchUpdatePositions,
|
||||
collaborativeUpdateParentId: updateParentId,
|
||||
collaborativeBatchAddBlocks,
|
||||
collaborativeBatchRemoveBlocks,
|
||||
collaborativeToggleBlockEnabled,
|
||||
collaborativeToggleBlockHandles,
|
||||
undo,
|
||||
redo,
|
||||
} = useCollaborativeWorkflow()
|
||||
|
||||
const updateBlockPosition = useCallback(
|
||||
(id: string, position: { x: number; y: number }) => {
|
||||
collaborativeBatchUpdatePositions([{ id, position }])
|
||||
},
|
||||
[collaborativeBatchUpdatePositions]
|
||||
)
|
||||
|
||||
const addBlock = useCallback(
|
||||
(
|
||||
id: string,
|
||||
type: string,
|
||||
name: string,
|
||||
position: { x: number; y: number },
|
||||
data?: Record<string, unknown>,
|
||||
parentId?: string,
|
||||
extent?: 'parent',
|
||||
autoConnectEdge?: Edge,
|
||||
triggerMode?: boolean
|
||||
) => {
|
||||
const blockData: Record<string, unknown> = { ...(data || {}) }
|
||||
if (parentId) blockData.parentId = parentId
|
||||
if (extent) blockData.extent = extent
|
||||
|
||||
const block = prepareBlockState({
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
position,
|
||||
data: blockData,
|
||||
parentId,
|
||||
extent,
|
||||
triggerMode,
|
||||
})
|
||||
|
||||
collaborativeBatchAddBlocks([block], autoConnectEdge ? [autoConnectEdge] : [], {}, {}, {})
|
||||
usePanelEditorStore.getState().setCurrentBlockId(id)
|
||||
},
|
||||
[collaborativeBatchAddBlocks]
|
||||
)
|
||||
|
||||
const { activeBlockIds, pendingBlocks, isDebugging } = useExecutionStore(
|
||||
useShallow((state) => ({
|
||||
activeBlockIds: state.activeBlockIds,
|
||||
@@ -419,7 +538,7 @@ const WorkflowContent = React.memo(() => {
|
||||
const result = updateNodeParentUtil(
|
||||
nodeId,
|
||||
newParentId,
|
||||
collaborativeUpdateBlockPosition,
|
||||
updateBlockPosition,
|
||||
updateParentId,
|
||||
() => resizeLoopNodesWrapper()
|
||||
)
|
||||
@@ -443,7 +562,7 @@ const WorkflowContent = React.memo(() => {
|
||||
},
|
||||
[
|
||||
getNodes,
|
||||
collaborativeUpdateBlockPosition,
|
||||
updateBlockPosition,
|
||||
updateParentId,
|
||||
blocks,
|
||||
edgesForDisplay,
|
||||
@@ -467,6 +586,186 @@ const WorkflowContent = React.memo(() => {
|
||||
return () => clearTimeout(debounceTimer)
|
||||
}, [handleAutoLayout])
|
||||
|
||||
const {
|
||||
isBlockMenuOpen,
|
||||
isPaneMenuOpen,
|
||||
position: contextMenuPosition,
|
||||
menuRef: contextMenuRef,
|
||||
selectedBlocks: contextMenuBlocks,
|
||||
handleNodeContextMenu,
|
||||
handlePaneContextMenu,
|
||||
handleSelectionContextMenu,
|
||||
closeMenu: closeContextMenu,
|
||||
} = useCanvasContextMenu({ blocks, getNodes })
|
||||
|
||||
const handleContextCopy = useCallback(() => {
|
||||
const blockIds = contextMenuBlocks.map((b) => b.id)
|
||||
copyBlocks(blockIds)
|
||||
}, [contextMenuBlocks, copyBlocks])
|
||||
|
||||
const handleContextPaste = useCallback(() => {
|
||||
if (!hasClipboard()) return
|
||||
|
||||
const pasteOffset = calculatePasteOffset(clipboard, screenToFlowPosition)
|
||||
|
||||
const pasteData = preparePasteData(pasteOffset)
|
||||
if (!pasteData) return
|
||||
|
||||
const {
|
||||
blocks: pastedBlocks,
|
||||
edges: pastedEdges,
|
||||
loops: pastedLoops,
|
||||
parallels: pastedParallels,
|
||||
subBlockValues: pastedSubBlockValues,
|
||||
} = pasteData
|
||||
|
||||
const pastedBlocksArray = Object.values(pastedBlocks)
|
||||
for (const block of pastedBlocksArray) {
|
||||
if (TriggerUtils.isAnyTriggerType(block.type)) {
|
||||
const issue = TriggerUtils.getTriggerAdditionIssue(blocks, block.type)
|
||||
if (issue) {
|
||||
const message =
|
||||
issue.issue === 'legacy'
|
||||
? 'Cannot paste trigger blocks when a legacy Start block exists.'
|
||||
: `A workflow can only have one ${issue.triggerName} trigger block. Please remove the existing one before pasting.`
|
||||
addNotification({
|
||||
level: 'error',
|
||||
message,
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collaborativeBatchAddBlocks(
|
||||
pastedBlocksArray,
|
||||
pastedEdges,
|
||||
pastedLoops,
|
||||
pastedParallels,
|
||||
pastedSubBlockValues
|
||||
)
|
||||
}, [
|
||||
hasClipboard,
|
||||
clipboard,
|
||||
screenToFlowPosition,
|
||||
preparePasteData,
|
||||
blocks,
|
||||
activeWorkflowId,
|
||||
addNotification,
|
||||
collaborativeBatchAddBlocks,
|
||||
])
|
||||
|
||||
const handleContextDuplicate = useCallback(() => {
|
||||
const blockIds = contextMenuBlocks.map((b) => b.id)
|
||||
copyBlocks(blockIds)
|
||||
const pasteData = preparePasteData(DEFAULT_PASTE_OFFSET)
|
||||
if (!pasteData) return
|
||||
|
||||
const {
|
||||
blocks: pastedBlocks,
|
||||
edges: pastedEdges,
|
||||
loops: pastedLoops,
|
||||
parallels: pastedParallels,
|
||||
subBlockValues: pastedSubBlockValues,
|
||||
} = pasteData
|
||||
|
||||
const pastedBlocksArray = Object.values(pastedBlocks)
|
||||
for (const block of pastedBlocksArray) {
|
||||
if (TriggerUtils.isAnyTriggerType(block.type)) {
|
||||
const issue = TriggerUtils.getTriggerAdditionIssue(blocks, block.type)
|
||||
if (issue) {
|
||||
const message =
|
||||
issue.issue === 'legacy'
|
||||
? 'Cannot duplicate trigger blocks when a legacy Start block exists.'
|
||||
: `A workflow can only have one ${issue.triggerName} trigger block. Cannot duplicate.`
|
||||
addNotification({
|
||||
level: 'error',
|
||||
message,
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collaborativeBatchAddBlocks(
|
||||
pastedBlocksArray,
|
||||
pastedEdges,
|
||||
pastedLoops,
|
||||
pastedParallels,
|
||||
pastedSubBlockValues
|
||||
)
|
||||
}, [
|
||||
contextMenuBlocks,
|
||||
copyBlocks,
|
||||
preparePasteData,
|
||||
blocks,
|
||||
activeWorkflowId,
|
||||
addNotification,
|
||||
collaborativeBatchAddBlocks,
|
||||
])
|
||||
|
||||
const handleContextDelete = useCallback(() => {
|
||||
const blockIds = contextMenuBlocks.map((b) => b.id)
|
||||
collaborativeBatchRemoveBlocks(blockIds)
|
||||
}, [contextMenuBlocks, collaborativeBatchRemoveBlocks])
|
||||
|
||||
const handleContextToggleEnabled = useCallback(() => {
|
||||
contextMenuBlocks.forEach((block) => {
|
||||
collaborativeToggleBlockEnabled(block.id)
|
||||
})
|
||||
}, [contextMenuBlocks, collaborativeToggleBlockEnabled])
|
||||
|
||||
const handleContextToggleHandles = useCallback(() => {
|
||||
contextMenuBlocks.forEach((block) => {
|
||||
collaborativeToggleBlockHandles(block.id)
|
||||
})
|
||||
}, [contextMenuBlocks, collaborativeToggleBlockHandles])
|
||||
|
||||
const handleContextRemoveFromSubflow = useCallback(() => {
|
||||
contextMenuBlocks.forEach((block) => {
|
||||
if (block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel')) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('remove-from-subflow', { detail: { blockId: block.id } })
|
||||
)
|
||||
}
|
||||
})
|
||||
}, [contextMenuBlocks])
|
||||
|
||||
const handleContextOpenEditor = useCallback(() => {
|
||||
if (contextMenuBlocks.length === 1) {
|
||||
usePanelEditorStore.getState().setCurrentBlockId(contextMenuBlocks[0].id)
|
||||
}
|
||||
}, [contextMenuBlocks])
|
||||
|
||||
const handleContextRename = useCallback(() => {
|
||||
if (contextMenuBlocks.length === 1) {
|
||||
usePanelEditorStore.getState().setCurrentBlockId(contextMenuBlocks[0].id)
|
||||
usePanelEditorStore.getState().setShouldFocusRename(true)
|
||||
}
|
||||
}, [contextMenuBlocks])
|
||||
|
||||
const handleContextAddBlock = useCallback(() => {
|
||||
useSearchModalStore.getState().open()
|
||||
}, [])
|
||||
|
||||
const handleContextOpenLogs = useCallback(() => {
|
||||
router.push(`/workspace/${workspaceId}/logs?workflowIds=${workflowIdParam}`)
|
||||
}, [router, workspaceId, workflowIdParam])
|
||||
|
||||
const handleContextOpenVariables = useCallback(() => {
|
||||
useVariablesStore.getState().setIsOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleContextOpenChat = useCallback(() => {
|
||||
useChatStore.getState().setIsChatOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleContextInvite = useCallback(() => {
|
||||
window.dispatchEvent(new CustomEvent('open-invite-modal'))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cleanup: (() => void) | null = null
|
||||
|
||||
@@ -495,6 +794,54 @@ const WorkflowContent = React.memo(() => {
|
||||
) {
|
||||
event.preventDefault()
|
||||
redo()
|
||||
} else if ((event.ctrlKey || event.metaKey) && event.key === 'c') {
|
||||
const selectedNodes = getNodes().filter((node) => node.selected)
|
||||
if (selectedNodes.length > 0) {
|
||||
event.preventDefault()
|
||||
copyBlocks(selectedNodes.map((node) => node.id))
|
||||
} else {
|
||||
const currentBlockId = usePanelEditorStore.getState().currentBlockId
|
||||
if (currentBlockId && blocks[currentBlockId]) {
|
||||
event.preventDefault()
|
||||
copyBlocks([currentBlockId])
|
||||
}
|
||||
}
|
||||
} else if ((event.ctrlKey || event.metaKey) && event.key === 'v') {
|
||||
if (effectivePermissions.canEdit && hasClipboard()) {
|
||||
event.preventDefault()
|
||||
|
||||
const pasteOffset = calculatePasteOffset(clipboard, screenToFlowPosition)
|
||||
|
||||
const pasteData = preparePasteData(pasteOffset)
|
||||
if (pasteData) {
|
||||
const pastedBlocks = Object.values(pasteData.blocks)
|
||||
for (const block of pastedBlocks) {
|
||||
if (TriggerUtils.isAnyTriggerType(block.type)) {
|
||||
const issue = TriggerUtils.getTriggerAdditionIssue(blocks, block.type)
|
||||
if (issue) {
|
||||
const message =
|
||||
issue.issue === 'legacy'
|
||||
? 'Cannot paste trigger blocks when a legacy Start block exists.'
|
||||
: `A workflow can only have one ${issue.triggerName} trigger block. Please remove the existing one before pasting.`
|
||||
addNotification({
|
||||
level: 'error',
|
||||
message,
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collaborativeBatchAddBlocks(
|
||||
pastedBlocks,
|
||||
pasteData.edges,
|
||||
pasteData.loops,
|
||||
pasteData.parallels,
|
||||
pasteData.subBlockValues
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -504,7 +851,22 @@ const WorkflowContent = React.memo(() => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
if (cleanup) cleanup()
|
||||
}
|
||||
}, [debouncedAutoLayout, undo, redo])
|
||||
}, [
|
||||
debouncedAutoLayout,
|
||||
undo,
|
||||
redo,
|
||||
getNodes,
|
||||
copyBlocks,
|
||||
preparePasteData,
|
||||
collaborativeBatchAddBlocks,
|
||||
hasClipboard,
|
||||
effectivePermissions.canEdit,
|
||||
blocks,
|
||||
addNotification,
|
||||
activeWorkflowId,
|
||||
clipboard,
|
||||
screenToFlowPosition,
|
||||
])
|
||||
|
||||
/**
|
||||
* Removes all edges connected to a block, skipping individual edge recording for undo/redo.
|
||||
@@ -617,14 +979,17 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
/** Creates a standardized edge object for workflow connections. */
|
||||
const createEdgeObject = useCallback(
|
||||
(sourceId: string, targetId: string, sourceHandle: string): Edge => ({
|
||||
id: crypto.randomUUID(),
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
sourceHandle,
|
||||
targetHandle: 'target',
|
||||
type: 'workflowEdge',
|
||||
}),
|
||||
(sourceId: string, targetId: string, sourceHandle: string): Edge => {
|
||||
const edge = {
|
||||
id: crypto.randomUUID(),
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
sourceHandle,
|
||||
targetHandle: 'target',
|
||||
type: 'workflowEdge',
|
||||
}
|
||||
return edge
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
@@ -1568,18 +1933,39 @@ const WorkflowContent = React.memo(() => {
|
||||
// Local state for nodes - allows smooth drag without store updates on every frame
|
||||
const [displayNodes, setDisplayNodes] = useState<Node[]>([])
|
||||
|
||||
// Sync derived nodes to display nodes when structure changes
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Shift') setIsShiftPressed(true)
|
||||
}
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Shift') setIsShiftPressed(false)
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
window.addEventListener('keyup', handleKeyUp)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
window.removeEventListener('keyup', handleKeyUp)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isShiftPressed) {
|
||||
document.body.style.userSelect = 'none'
|
||||
} else {
|
||||
document.body.style.userSelect = ''
|
||||
}
|
||||
return () => {
|
||||
document.body.style.userSelect = ''
|
||||
}
|
||||
}, [isShiftPressed])
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayNodes(derivedNodes)
|
||||
}, [derivedNodes])
|
||||
|
||||
/** Handles node position changes - updates local state for smooth drag, syncs to store only on drag end. */
|
||||
const onNodesChange = useCallback((changes: NodeChange[]) => {
|
||||
// Apply position changes to local state for smooth rendering
|
||||
setDisplayNodes((nds) => applyNodeChanges(changes, nds))
|
||||
|
||||
// Don't sync to store during drag - that's handled in onNodeDragStop
|
||||
// Only sync non-position changes (like selection) to store if needed
|
||||
}, [])
|
||||
|
||||
/**
|
||||
@@ -1673,21 +2059,12 @@ const WorkflowContent = React.memo(() => {
|
||||
missingParentId: parentId,
|
||||
})
|
||||
|
||||
// Fix the node by removing its parent reference and calculating absolute position
|
||||
const absolutePosition = getNodeAbsolutePosition(id)
|
||||
|
||||
// Update the node to remove parent reference and use absolute position
|
||||
collaborativeUpdateBlockPosition(id, absolutePosition)
|
||||
updateBlockPosition(id, absolutePosition)
|
||||
updateParentId(id, '', 'parent')
|
||||
}
|
||||
})
|
||||
}, [
|
||||
blocks,
|
||||
collaborativeUpdateBlockPosition,
|
||||
updateParentId,
|
||||
getNodeAbsolutePosition,
|
||||
isWorkflowReady,
|
||||
])
|
||||
}, [blocks, updateBlockPosition, updateParentId, getNodeAbsolutePosition, isWorkflowReady])
|
||||
|
||||
/** Handles edge removal changes. */
|
||||
const onEdgesChange = useCallback(
|
||||
@@ -2095,9 +2472,7 @@ const WorkflowContent = React.memo(() => {
|
||||
}
|
||||
}
|
||||
|
||||
// Emit collaborative position update for the final position
|
||||
// This ensures other users see the smooth final position
|
||||
collaborativeUpdateBlockPosition(node.id, finalPosition, true)
|
||||
updateBlockPosition(node.id, finalPosition)
|
||||
|
||||
// Record single move entry on drag end to avoid micro-moves
|
||||
const start = getDragStartPosition()
|
||||
@@ -2218,7 +2593,7 @@ const WorkflowContent = React.memo(() => {
|
||||
dragStartParentId,
|
||||
potentialParentId,
|
||||
updateNodeParent,
|
||||
collaborativeUpdateBlockPosition,
|
||||
updateBlockPosition,
|
||||
addEdge,
|
||||
tryCreateAutoConnectEdge,
|
||||
blocks,
|
||||
@@ -2232,7 +2607,57 @@ const WorkflowContent = React.memo(() => {
|
||||
]
|
||||
)
|
||||
|
||||
/** Clears edge selection and panel state when clicking empty canvas. */
|
||||
// Lock selection mode when selection drag starts (captures Shift state at drag start)
|
||||
const onSelectionStart = useCallback(() => {
|
||||
if (isShiftPressed) {
|
||||
setIsSelectionDragActive(true)
|
||||
}
|
||||
}, [isShiftPressed])
|
||||
|
||||
const onSelectionEnd = useCallback(() => {
|
||||
requestAnimationFrame(() => setIsSelectionDragActive(false))
|
||||
}, [])
|
||||
|
||||
const onSelectionDragStop = useCallback(
|
||||
(_event: React.MouseEvent, nodes: any[]) => {
|
||||
requestAnimationFrame(() => setIsSelectionDragActive(false))
|
||||
if (nodes.length === 0) return
|
||||
|
||||
const positionUpdates = nodes.map((node) => {
|
||||
const currentBlock = blocks[node.id]
|
||||
const currentParentId = currentBlock?.data?.parentId
|
||||
let finalPosition = node.position
|
||||
|
||||
if (currentParentId) {
|
||||
const parentNode = getNodes().find((n) => n.id === currentParentId)
|
||||
if (parentNode) {
|
||||
const containerDimensions = {
|
||||
width: parentNode.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
height: parentNode.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
}
|
||||
const blockDimensions = {
|
||||
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
|
||||
height: Math.max(
|
||||
currentBlock?.height || BLOCK_DIMENSIONS.MIN_HEIGHT,
|
||||
BLOCK_DIMENSIONS.MIN_HEIGHT
|
||||
),
|
||||
}
|
||||
finalPosition = clampPositionToContainer(
|
||||
node.position,
|
||||
containerDimensions,
|
||||
blockDimensions
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return { id: node.id, position: finalPosition }
|
||||
})
|
||||
|
||||
collaborativeBatchUpdatePositions(positionUpdates)
|
||||
},
|
||||
[blocks, getNodes, collaborativeBatchUpdatePositions]
|
||||
)
|
||||
|
||||
const onPaneClick = useCallback(() => {
|
||||
setSelectedEdgeInfo(null)
|
||||
usePanelEditorStore.getState().clearCurrentBlock()
|
||||
@@ -2333,17 +2758,23 @@ const WorkflowContent = React.memo(() => {
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
const primaryNode = selectedNodes[0]
|
||||
removeBlock(primaryNode.id)
|
||||
const selectedIds = selectedNodes.map((node) => node.id)
|
||||
collaborativeBatchRemoveBlocks(selectedIds)
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [selectedEdgeInfo, removeEdge, getNodes, removeBlock, effectivePermissions.canEdit])
|
||||
}, [
|
||||
selectedEdgeInfo,
|
||||
removeEdge,
|
||||
getNodes,
|
||||
collaborativeBatchRemoveBlocks,
|
||||
effectivePermissions.canEdit,
|
||||
])
|
||||
|
||||
return (
|
||||
<div className='flex h-full w-full flex-col overflow-hidden bg-[var(--bg)]'>
|
||||
<div className='relative h-full w-full flex-1 bg-[var(--bg)]'>
|
||||
<div className='flex h-full w-full flex-col overflow-hidden'>
|
||||
<div className='relative h-full w-full flex-1'>
|
||||
{/* Loading spinner - always mounted, animation paused when hidden to avoid overhead */}
|
||||
<div
|
||||
className={`absolute inset-0 z-[5] flex items-center justify-center bg-[var(--bg)] transition-opacity duration-150 ${isWorkflowReady ? 'pointer-events-none opacity-0' : 'opacity-100'}`}
|
||||
@@ -2390,24 +2821,29 @@ const WorkflowContent = React.memo(() => {
|
||||
proOptions={reactFlowProOptions}
|
||||
connectionLineStyle={connectionLineStyle}
|
||||
connectionLineType={ConnectionLineType.SmoothStep}
|
||||
onNodeClick={(e, _node) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onPaneClick={onPaneClick}
|
||||
onEdgeClick={onEdgeClick}
|
||||
onPaneContextMenu={handlePaneContextMenu}
|
||||
onNodeContextMenu={handleNodeContextMenu}
|
||||
onSelectionContextMenu={handleSelectionContextMenu}
|
||||
onPointerMove={handleCanvasPointerMove}
|
||||
onPointerLeave={handleCanvasPointerLeave}
|
||||
elementsSelectable={true}
|
||||
selectNodesOnDrag={false}
|
||||
selectionOnDrag={isShiftPressed || isSelectionDragActive}
|
||||
panOnDrag={isShiftPressed || isSelectionDragActive ? false : [0, 1]}
|
||||
onSelectionStart={onSelectionStart}
|
||||
onSelectionEnd={onSelectionEnd}
|
||||
multiSelectionKeyCode={['Meta', 'Control']}
|
||||
nodesConnectable={effectivePermissions.canEdit}
|
||||
nodesDraggable={effectivePermissions.canEdit}
|
||||
draggable={false}
|
||||
noWheelClassName='allow-scroll'
|
||||
edgesFocusable={true}
|
||||
edgesUpdatable={effectivePermissions.canEdit}
|
||||
className={`workflow-container h-full bg-[var(--bg)] transition-opacity duration-150 ${reactFlowStyles} ${isCanvasReady ? 'opacity-100' : 'opacity-0'}`}
|
||||
className={`workflow-container h-full transition-opacity duration-150 ${reactFlowStyles} ${isCanvasReady ? 'opacity-100' : 'opacity-0'}`}
|
||||
onNodeDrag={effectivePermissions.canEdit ? onNodeDrag : undefined}
|
||||
onNodeDragStop={effectivePermissions.canEdit ? onNodeDragStop : undefined}
|
||||
onSelectionDragStop={effectivePermissions.canEdit ? onSelectionDragStop : undefined}
|
||||
onNodeDragStart={effectivePermissions.canEdit ? onNodeDragStart : undefined}
|
||||
snapToGrid={snapToGrid}
|
||||
snapGrid={snapGrid}
|
||||
@@ -2426,6 +2862,50 @@ const WorkflowContent = React.memo(() => {
|
||||
</Suspense>
|
||||
|
||||
<DiffControls />
|
||||
|
||||
{/* Context Menus */}
|
||||
<BlockContextMenu
|
||||
isOpen={isBlockMenuOpen}
|
||||
position={contextMenuPosition}
|
||||
menuRef={contextMenuRef}
|
||||
onClose={closeContextMenu}
|
||||
selectedBlocks={contextMenuBlocks}
|
||||
onCopy={handleContextCopy}
|
||||
onPaste={handleContextPaste}
|
||||
onDuplicate={handleContextDuplicate}
|
||||
onDelete={handleContextDelete}
|
||||
onToggleEnabled={handleContextToggleEnabled}
|
||||
onToggleHandles={handleContextToggleHandles}
|
||||
onRemoveFromSubflow={handleContextRemoveFromSubflow}
|
||||
onOpenEditor={handleContextOpenEditor}
|
||||
onRename={handleContextRename}
|
||||
hasClipboard={hasClipboard()}
|
||||
showRemoveFromSubflow={contextMenuBlocks.some(
|
||||
(b) => b.parentId && (b.parentType === 'loop' || b.parentType === 'parallel')
|
||||
)}
|
||||
disableEdit={!effectivePermissions.canEdit}
|
||||
/>
|
||||
|
||||
<PaneContextMenu
|
||||
isOpen={isPaneMenuOpen}
|
||||
position={contextMenuPosition}
|
||||
menuRef={contextMenuRef}
|
||||
onClose={closeContextMenu}
|
||||
onUndo={undo}
|
||||
onRedo={redo}
|
||||
onPaste={handleContextPaste}
|
||||
onAddBlock={handleContextAddBlock}
|
||||
onAutoLayout={handleAutoLayout}
|
||||
onOpenLogs={handleContextOpenLogs}
|
||||
onOpenVariables={handleContextOpenVariables}
|
||||
onOpenChat={handleContextOpenChat}
|
||||
onInvite={handleContextInvite}
|
||||
hasClipboard={hasClipboard()}
|
||||
disableEdit={!effectivePermissions.canEdit}
|
||||
disableAdmin={!effectivePermissions.canAdmin}
|
||||
canUndo={canUndo}
|
||||
canRedo={canRedo}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -151,6 +151,13 @@ export function WorkspaceHeader({
|
||||
setIsMounted(true)
|
||||
}, [])
|
||||
|
||||
// Listen for open-invite-modal event from context menu
|
||||
useEffect(() => {
|
||||
const handleOpenInvite = () => setIsInviteModalOpen(true)
|
||||
window.addEventListener('open-invite-modal', handleOpenInvite)
|
||||
return () => window.removeEventListener('open-invite-modal', handleOpenInvite)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Focus the inline list rename input when it becomes active
|
||||
*/
|
||||
|
||||
@@ -31,7 +31,7 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
|
||||
{ label: 'Add Customer', id: 'add_customer' },
|
||||
{ label: 'Get Organizations', id: 'get_organizations' },
|
||||
{ label: 'Create Organization', id: 'create_organization' },
|
||||
{ label: 'Add Organization to Service Desk', id: 'add_organization_to_service_desk' },
|
||||
{ label: 'Add Organization', id: 'add_organization' },
|
||||
{ label: 'Get Queues', id: 'get_queues' },
|
||||
{ label: 'Get SLA', id: 'get_sla' },
|
||||
{ label: 'Get Transitions', id: 'get_transitions' },
|
||||
@@ -107,7 +107,7 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
|
||||
'get_customers',
|
||||
'add_customer',
|
||||
'get_organizations',
|
||||
'add_organization_to_service_desk',
|
||||
'add_organization',
|
||||
'get_queues',
|
||||
],
|
||||
},
|
||||
@@ -270,7 +270,7 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
|
||||
type: 'short-input',
|
||||
required: true,
|
||||
placeholder: 'Enter organization ID',
|
||||
condition: { field: 'operation', value: 'add_organization_to_service_desk' },
|
||||
condition: { field: 'operation', value: 'add_organization' },
|
||||
},
|
||||
{
|
||||
id: 'participantAccountIds',
|
||||
@@ -332,7 +332,7 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
|
||||
'jsm_add_customer',
|
||||
'jsm_get_organizations',
|
||||
'jsm_create_organization',
|
||||
'jsm_add_organization_to_service_desk',
|
||||
'jsm_add_organization',
|
||||
'jsm_get_queues',
|
||||
'jsm_get_sla',
|
||||
'jsm_get_transitions',
|
||||
@@ -367,8 +367,8 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
|
||||
return 'jsm_get_organizations'
|
||||
case 'create_organization':
|
||||
return 'jsm_create_organization'
|
||||
case 'add_organization_to_service_desk':
|
||||
return 'jsm_add_organization_to_service_desk'
|
||||
case 'add_organization':
|
||||
return 'jsm_add_organization'
|
||||
case 'get_queues':
|
||||
return 'jsm_get_queues'
|
||||
case 'get_sla':
|
||||
@@ -560,7 +560,7 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
|
||||
...baseParams,
|
||||
name: params.organizationName,
|
||||
}
|
||||
case 'add_organization_to_service_desk':
|
||||
case 'add_organization':
|
||||
if (!params.serviceDeskId) {
|
||||
throw new Error('Service Desk ID is required')
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@ import { useCallback, useEffect, useRef } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { Edge } from 'reactflow'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants'
|
||||
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
|
||||
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
|
||||
import { useSocket } from '@/app/workspace/providers/socket-provider'
|
||||
import { getBlock } from '@/blocks'
|
||||
@@ -16,9 +14,9 @@ import { useUndoRedoStore } from '@/stores/undo-redo'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { getUniqueBlockName, mergeSubblockState, normalizeName } from '@/stores/workflows/utils'
|
||||
import { mergeSubblockState, normalizeName } from '@/stores/workflows/utils'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import type { BlockState, Position } from '@/stores/workflows/workflow/types'
|
||||
import type { BlockState, Loop, Parallel, Position } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('CollaborativeWorkflow')
|
||||
|
||||
@@ -201,39 +199,6 @@ export function useCollaborativeWorkflow() {
|
||||
try {
|
||||
if (target === 'block') {
|
||||
switch (operation) {
|
||||
case 'add':
|
||||
workflowStore.addBlock(
|
||||
payload.id,
|
||||
payload.type,
|
||||
payload.name,
|
||||
payload.position,
|
||||
payload.data,
|
||||
payload.parentId,
|
||||
payload.extent,
|
||||
{
|
||||
enabled: payload.enabled,
|
||||
horizontalHandles: payload.horizontalHandles,
|
||||
advancedMode: payload.advancedMode,
|
||||
triggerMode: payload.triggerMode ?? false,
|
||||
height: payload.height,
|
||||
}
|
||||
)
|
||||
if (payload.autoConnectEdge) {
|
||||
workflowStore.addEdge(payload.autoConnectEdge)
|
||||
}
|
||||
// Apply subblock values if present in payload
|
||||
if (payload.subBlocks && typeof payload.subBlocks === 'object') {
|
||||
Object.entries(payload.subBlocks).forEach(([subblockId, subblock]) => {
|
||||
if (WEBHOOK_SUBBLOCK_FIELDS.includes(subblockId)) {
|
||||
return
|
||||
}
|
||||
const value = (subblock as any)?.value
|
||||
if (value !== undefined && value !== null) {
|
||||
subBlockStore.setValue(payload.id, subblockId, value)
|
||||
}
|
||||
})
|
||||
}
|
||||
break
|
||||
case 'update-position': {
|
||||
const blockId = payload.id
|
||||
|
||||
@@ -265,40 +230,6 @@ export function useCollaborativeWorkflow() {
|
||||
case 'update-name':
|
||||
workflowStore.updateBlockName(payload.id, payload.name)
|
||||
break
|
||||
case 'remove': {
|
||||
const blockId = payload.id
|
||||
const blocksToRemove = new Set<string>([blockId])
|
||||
|
||||
const findAllDescendants = (parentId: string) => {
|
||||
Object.entries(workflowStore.blocks).forEach(([id, block]) => {
|
||||
if (block.data?.parentId === parentId) {
|
||||
blocksToRemove.add(id)
|
||||
findAllDescendants(id)
|
||||
}
|
||||
})
|
||||
}
|
||||
findAllDescendants(blockId)
|
||||
|
||||
workflowStore.removeBlock(blockId)
|
||||
lastPositionTimestamps.current.delete(blockId)
|
||||
|
||||
const updatedBlocks = useWorkflowStore.getState().blocks
|
||||
const updatedEdges = useWorkflowStore.getState().edges
|
||||
const graph = {
|
||||
blocksById: updatedBlocks,
|
||||
edgesById: Object.fromEntries(updatedEdges.map((e) => [e.id, e])),
|
||||
}
|
||||
|
||||
const undoRedoStore = useUndoRedoStore.getState()
|
||||
const stackKeys = Object.keys(undoRedoStore.stacks)
|
||||
stackKeys.forEach((key) => {
|
||||
const [workflowId, userId] = key.split(':')
|
||||
if (workflowId === activeWorkflowId) {
|
||||
undoRedoStore.pruneInvalidEntries(workflowId, userId, graph)
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'toggle-enabled':
|
||||
workflowStore.toggleBlockEnabled(payload.id)
|
||||
break
|
||||
@@ -318,40 +249,20 @@ export function useCollaborativeWorkflow() {
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'duplicate':
|
||||
workflowStore.addBlock(
|
||||
payload.id,
|
||||
payload.type,
|
||||
payload.name,
|
||||
payload.position,
|
||||
payload.data,
|
||||
payload.parentId,
|
||||
payload.extent,
|
||||
{
|
||||
enabled: payload.enabled,
|
||||
horizontalHandles: payload.horizontalHandles,
|
||||
advancedMode: payload.advancedMode,
|
||||
triggerMode: payload.triggerMode ?? false,
|
||||
height: payload.height,
|
||||
}
|
||||
)
|
||||
// Handle auto-connect edge if present
|
||||
if (payload.autoConnectEdge) {
|
||||
workflowStore.addEdge(payload.autoConnectEdge)
|
||||
}
|
||||
// Apply subblock values from duplicate payload so collaborators see content immediately
|
||||
if (payload.subBlocks && typeof payload.subBlocks === 'object') {
|
||||
Object.entries(payload.subBlocks).forEach(([subblockId, subblock]) => {
|
||||
if (WEBHOOK_SUBBLOCK_FIELDS.includes(subblockId)) {
|
||||
return
|
||||
}
|
||||
const value = (subblock as any)?.value
|
||||
if (value !== undefined) {
|
||||
subBlockStore.setValue(payload.id, subblockId, value)
|
||||
}
|
||||
} else if (target === 'blocks') {
|
||||
switch (operation) {
|
||||
case 'batch-update-positions': {
|
||||
const { updates } = payload
|
||||
if (Array.isArray(updates)) {
|
||||
updates.forEach(({ id, position }: { id: string; position: Position }) => {
|
||||
if (id && position) {
|
||||
workflowStore.updateBlockPosition(id, position)
|
||||
}
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if (target === 'edge') {
|
||||
switch (operation) {
|
||||
@@ -439,9 +350,6 @@ export function useCollaborativeWorkflow() {
|
||||
case 'remove':
|
||||
variablesStore.deleteVariable(payload.variableId)
|
||||
break
|
||||
case 'duplicate':
|
||||
variablesStore.duplicateVariable(payload.sourceVariableId, payload.id)
|
||||
break
|
||||
}
|
||||
} else if (target === 'workflow') {
|
||||
switch (operation) {
|
||||
@@ -477,6 +385,93 @@ export function useCollaborativeWorkflow() {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (target === 'blocks') {
|
||||
switch (operation) {
|
||||
case 'batch-add-blocks': {
|
||||
const {
|
||||
blocks,
|
||||
edges,
|
||||
loops,
|
||||
parallels,
|
||||
subBlockValues: addedSubBlockValues,
|
||||
} = payload
|
||||
logger.info('Received batch-add-blocks from remote user', {
|
||||
userId,
|
||||
blockCount: (blocks || []).length,
|
||||
edgeCount: (edges || []).length,
|
||||
})
|
||||
|
||||
;(blocks || []).forEach((block: BlockState) => {
|
||||
workflowStore.addBlock(
|
||||
block.id,
|
||||
block.type,
|
||||
block.name,
|
||||
block.position,
|
||||
block.data,
|
||||
block.data?.parentId,
|
||||
block.data?.extent,
|
||||
{
|
||||
enabled: block.enabled,
|
||||
horizontalHandles: block.horizontalHandles,
|
||||
advancedMode: block.advancedMode,
|
||||
triggerMode: block.triggerMode ?? false,
|
||||
height: block.height,
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
;(edges || []).forEach((edge: Edge) => {
|
||||
workflowStore.addEdge(edge)
|
||||
})
|
||||
|
||||
if (loops) {
|
||||
Object.entries(loops as Record<string, Loop>).forEach(([loopId, loopConfig]) => {
|
||||
useWorkflowStore.setState((state) => ({
|
||||
loops: { ...state.loops, [loopId]: loopConfig },
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
if (parallels) {
|
||||
Object.entries(parallels as Record<string, Parallel>).forEach(
|
||||
([parallelId, parallelConfig]) => {
|
||||
useWorkflowStore.setState((state) => ({
|
||||
parallels: { ...state.parallels, [parallelId]: parallelConfig },
|
||||
}))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (addedSubBlockValues && activeWorkflowId) {
|
||||
Object.entries(
|
||||
addedSubBlockValues as Record<string, Record<string, unknown>>
|
||||
).forEach(([blockId, subBlocks]) => {
|
||||
Object.entries(subBlocks).forEach(([subBlockId, value]) => {
|
||||
subBlockStore.setValue(blockId, subBlockId, value)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
logger.info('Successfully applied batch-add-blocks from remote user')
|
||||
break
|
||||
}
|
||||
case 'batch-remove-blocks': {
|
||||
const { ids } = payload
|
||||
logger.info('Received batch-remove-blocks from remote user', {
|
||||
userId,
|
||||
count: (ids || []).length,
|
||||
})
|
||||
|
||||
;(ids || []).forEach((id: string) => {
|
||||
workflowStore.removeBlock(id)
|
||||
})
|
||||
|
||||
logger.info('Successfully applied batch-remove-blocks from remote user')
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error applying remote operation:', error)
|
||||
} finally {
|
||||
@@ -726,294 +721,33 @@ export function useCollaborativeWorkflow() {
|
||||
]
|
||||
)
|
||||
|
||||
const executeQueuedDebouncedOperation = useCallback(
|
||||
(operation: string, target: string, payload: any, localAction: () => void) => {
|
||||
if (isApplyingRemoteChange.current) return
|
||||
|
||||
if (isBaselineDiffView) {
|
||||
logger.debug('Skipping debounced socket operation while viewing baseline diff:', operation)
|
||||
return
|
||||
}
|
||||
|
||||
const collaborativeBatchUpdatePositions = useCallback(
|
||||
(updates: Array<{ id: string; position: Position }>) => {
|
||||
if (!isInActiveRoom()) {
|
||||
logger.debug('Skipping debounced operation - not in active workflow', {
|
||||
currentWorkflowId,
|
||||
activeWorkflowId,
|
||||
operation,
|
||||
target,
|
||||
})
|
||||
logger.debug('Skipping batch position update - not in active workflow')
|
||||
return
|
||||
}
|
||||
|
||||
localAction()
|
||||
if (updates.length === 0) return
|
||||
|
||||
emitWorkflowOperation(operation, target, payload)
|
||||
},
|
||||
[emitWorkflowOperation, isBaselineDiffView, isInActiveRoom, currentWorkflowId, activeWorkflowId]
|
||||
)
|
||||
|
||||
const collaborativeAddBlock = useCallback(
|
||||
(
|
||||
id: string,
|
||||
type: string,
|
||||
name: string,
|
||||
position: Position,
|
||||
data?: Record<string, any>,
|
||||
parentId?: string,
|
||||
extent?: 'parent',
|
||||
autoConnectEdge?: Edge,
|
||||
triggerMode?: boolean
|
||||
) => {
|
||||
// Skip socket operations when viewing baseline diff
|
||||
if (isBaselineDiffView) {
|
||||
logger.debug('Skipping collaborative add block while viewing baseline diff')
|
||||
return
|
||||
}
|
||||
|
||||
if (!isInActiveRoom()) {
|
||||
logger.debug('Skipping collaborative add block - not in active workflow', {
|
||||
currentWorkflowId,
|
||||
activeWorkflowId,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const blockConfig = getBlock(type)
|
||||
|
||||
// Handle loop/parallel blocks that don't use BlockConfig
|
||||
if (!blockConfig && (type === 'loop' || type === 'parallel')) {
|
||||
// For loop/parallel blocks, use empty subBlocks and outputs
|
||||
const completeBlockData = {
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
position,
|
||||
data: data || {},
|
||||
subBlocks: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
horizontalHandles: true,
|
||||
advancedMode: false,
|
||||
triggerMode: triggerMode || false,
|
||||
height: 0,
|
||||
parentId,
|
||||
extent,
|
||||
autoConnectEdge, // Include edge data for atomic operation
|
||||
}
|
||||
|
||||
// Skip if applying remote changes (don't auto-select blocks added by other users)
|
||||
if (isApplyingRemoteChange.current) {
|
||||
workflowStore.addBlock(id, type, name, position, data, parentId, extent, {
|
||||
triggerMode: triggerMode || false,
|
||||
})
|
||||
if (autoConnectEdge) {
|
||||
workflowStore.addEdge(autoConnectEdge)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Generate operation ID for queue tracking
|
||||
const operationId = crypto.randomUUID()
|
||||
|
||||
// Add to queue for retry mechanism
|
||||
addToQueue({
|
||||
id: operationId,
|
||||
operation: {
|
||||
operation: 'add',
|
||||
target: 'block',
|
||||
payload: completeBlockData,
|
||||
},
|
||||
workflowId: activeWorkflowId || '',
|
||||
userId: session?.user?.id || 'unknown',
|
||||
})
|
||||
|
||||
// Apply locally first (immediate UI feedback)
|
||||
workflowStore.addBlock(id, type, name, position, data, parentId, extent, {
|
||||
triggerMode: triggerMode || false,
|
||||
})
|
||||
if (autoConnectEdge) {
|
||||
workflowStore.addEdge(autoConnectEdge)
|
||||
}
|
||||
|
||||
// Record for undo AFTER adding (pass the autoConnectEdge explicitly)
|
||||
undoRedo.recordAddBlock(id, autoConnectEdge)
|
||||
|
||||
// Automatically select the newly added block (opens editor tab)
|
||||
usePanelEditorStore.getState().setCurrentBlockId(id)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!blockConfig) {
|
||||
logger.error(`Block type ${type} not found`)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate subBlocks and outputs from the block configuration
|
||||
const subBlocks: Record<string, any> = {}
|
||||
|
||||
if (blockConfig.subBlocks) {
|
||||
blockConfig.subBlocks.forEach((subBlock) => {
|
||||
let initialValue: unknown = null
|
||||
|
||||
if (typeof subBlock.value === 'function') {
|
||||
try {
|
||||
initialValue = subBlock.value({})
|
||||
} catch (error) {
|
||||
logger.warn('Failed to resolve dynamic sub-block default value', {
|
||||
subBlockId: subBlock.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
} else if (subBlock.defaultValue !== undefined) {
|
||||
initialValue = subBlock.defaultValue
|
||||
} else if (subBlock.type === 'input-format') {
|
||||
initialValue = [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
name: '',
|
||||
type: 'string',
|
||||
value: '',
|
||||
collapsed: false,
|
||||
},
|
||||
]
|
||||
} else if (subBlock.type === 'table') {
|
||||
initialValue = []
|
||||
}
|
||||
|
||||
subBlocks[subBlock.id] = {
|
||||
id: subBlock.id,
|
||||
type: subBlock.type,
|
||||
value: initialValue,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Get outputs based on trigger mode
|
||||
const isTriggerMode = triggerMode || false
|
||||
const outputs = getBlockOutputs(type, subBlocks, isTriggerMode)
|
||||
|
||||
const completeBlockData = {
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
position,
|
||||
data: data || {},
|
||||
subBlocks,
|
||||
outputs,
|
||||
enabled: true,
|
||||
horizontalHandles: true,
|
||||
advancedMode: false,
|
||||
triggerMode: isTriggerMode,
|
||||
height: 0, // Default height, will be set by the UI
|
||||
parentId,
|
||||
extent,
|
||||
autoConnectEdge, // Include edge data for atomic operation
|
||||
}
|
||||
|
||||
// Skip if applying remote changes (don't auto-select blocks added by other users)
|
||||
if (isApplyingRemoteChange.current) return
|
||||
|
||||
// Generate operation ID
|
||||
const operationId = crypto.randomUUID()
|
||||
|
||||
// Add to queue
|
||||
addToQueue({
|
||||
id: operationId,
|
||||
operation: {
|
||||
operation: 'add',
|
||||
target: 'block',
|
||||
payload: completeBlockData,
|
||||
operation: 'batch-update-positions',
|
||||
target: 'blocks',
|
||||
payload: { updates },
|
||||
},
|
||||
workflowId: activeWorkflowId || '',
|
||||
userId: session?.user?.id || 'unknown',
|
||||
})
|
||||
|
||||
// Apply locally
|
||||
workflowStore.addBlock(id, type, name, position, data, parentId, extent, {
|
||||
triggerMode: triggerMode || false,
|
||||
})
|
||||
if (autoConnectEdge) {
|
||||
workflowStore.addEdge(autoConnectEdge)
|
||||
}
|
||||
|
||||
// Record for undo AFTER adding (pass the autoConnectEdge explicitly)
|
||||
undoRedo.recordAddBlock(id, autoConnectEdge)
|
||||
|
||||
// Automatically select the newly added block (opens editor tab)
|
||||
usePanelEditorStore.getState().setCurrentBlockId(id)
|
||||
},
|
||||
[
|
||||
workflowStore,
|
||||
activeWorkflowId,
|
||||
addToQueue,
|
||||
session?.user?.id,
|
||||
isBaselineDiffView,
|
||||
isInActiveRoom,
|
||||
currentWorkflowId,
|
||||
undoRedo,
|
||||
]
|
||||
)
|
||||
|
||||
const collaborativeRemoveBlock = useCallback(
|
||||
(id: string) => {
|
||||
cancelOperationsForBlock(id)
|
||||
|
||||
// Get all blocks that will be removed (including nested blocks in subflows)
|
||||
const blocksToRemove = new Set<string>([id])
|
||||
const findAllDescendants = (parentId: string) => {
|
||||
Object.entries(workflowStore.blocks).forEach(([blockId, block]) => {
|
||||
if (block.data?.parentId === parentId) {
|
||||
blocksToRemove.add(blockId)
|
||||
findAllDescendants(blockId)
|
||||
}
|
||||
})
|
||||
}
|
||||
findAllDescendants(id)
|
||||
|
||||
// If the currently edited block is among the blocks being removed, clear selection to reset the panel
|
||||
const currentEditedBlockId = usePanelEditorStore.getState().currentBlockId
|
||||
if (currentEditedBlockId && blocksToRemove.has(currentEditedBlockId)) {
|
||||
usePanelEditorStore.getState().clearCurrentBlock()
|
||||
}
|
||||
|
||||
// Capture state before removal, including all nested blocks with subblock values
|
||||
const allBlocks = mergeSubblockState(workflowStore.blocks, activeWorkflowId || undefined)
|
||||
const capturedBlocks: Record<string, BlockState> = {}
|
||||
blocksToRemove.forEach((blockId) => {
|
||||
if (allBlocks[blockId]) {
|
||||
capturedBlocks[blockId] = allBlocks[blockId]
|
||||
}
|
||||
})
|
||||
|
||||
// Capture all edges connected to any of the blocks being removed
|
||||
const edges = workflowStore.edges.filter(
|
||||
(edge) => blocksToRemove.has(edge.source) || blocksToRemove.has(edge.target)
|
||||
)
|
||||
|
||||
if (Object.keys(capturedBlocks).length > 0) {
|
||||
undoRedo.recordRemoveBlock(id, capturedBlocks[id], edges, capturedBlocks)
|
||||
}
|
||||
|
||||
executeQueuedOperation('remove', 'block', { id }, () => workflowStore.removeBlock(id))
|
||||
},
|
||||
[executeQueuedOperation, workflowStore, cancelOperationsForBlock, undoRedo, activeWorkflowId]
|
||||
)
|
||||
|
||||
const collaborativeUpdateBlockPosition = useCallback(
|
||||
(id: string, position: Position, commit = true) => {
|
||||
if (commit) {
|
||||
executeQueuedOperation('update-position', 'block', { id, position, commit }, () => {
|
||||
workflowStore.updateBlockPosition(id, position)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
executeQueuedDebouncedOperation('update-position', 'block', { id, position }, () => {
|
||||
updates.forEach(({ id, position }) => {
|
||||
workflowStore.updateBlockPosition(id, position)
|
||||
})
|
||||
},
|
||||
[executeQueuedDebouncedOperation, executeQueuedOperation, workflowStore]
|
||||
[addToQueue, activeWorkflowId, session?.user?.id, isInActiveRoom, workflowStore]
|
||||
)
|
||||
|
||||
const collaborativeUpdateBlockName = useCallback(
|
||||
@@ -1333,148 +1067,6 @@ export function useCollaborativeWorkflow() {
|
||||
]
|
||||
)
|
||||
|
||||
const collaborativeDuplicateBlock = useCallback(
|
||||
(sourceId: string) => {
|
||||
if (!isInActiveRoom()) {
|
||||
logger.debug('Skipping duplicate block - not in active workflow', {
|
||||
currentWorkflowId,
|
||||
activeWorkflowId,
|
||||
sourceId,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const sourceBlock = workflowStore.blocks[sourceId]
|
||||
if (!sourceBlock) return
|
||||
|
||||
// Prevent duplication of start blocks (both legacy starter and unified start_trigger)
|
||||
if (sourceBlock.type === 'starter' || sourceBlock.type === 'start_trigger') {
|
||||
logger.warn('Cannot duplicate start block - only one start block allowed per workflow', {
|
||||
blockId: sourceId,
|
||||
blockType: sourceBlock.type,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate new ID and calculate position
|
||||
const newId = crypto.randomUUID()
|
||||
const offsetPosition = {
|
||||
x: sourceBlock.position.x + DEFAULT_DUPLICATE_OFFSET.x,
|
||||
y: sourceBlock.position.y + DEFAULT_DUPLICATE_OFFSET.y,
|
||||
}
|
||||
|
||||
const newName = getUniqueBlockName(sourceBlock.name, workflowStore.blocks)
|
||||
|
||||
// Get subblock values from the store, excluding webhook-specific fields
|
||||
const allSubBlockValues =
|
||||
subBlockStore.workflowValues[activeWorkflowId || '']?.[sourceId] || {}
|
||||
const subBlockValues = Object.fromEntries(
|
||||
Object.entries(allSubBlockValues).filter(([key]) => !WEBHOOK_SUBBLOCK_FIELDS.includes(key))
|
||||
)
|
||||
|
||||
// Merge subblock structure with actual values
|
||||
const mergedSubBlocks = sourceBlock.subBlocks
|
||||
? JSON.parse(JSON.stringify(sourceBlock.subBlocks))
|
||||
: {}
|
||||
|
||||
WEBHOOK_SUBBLOCK_FIELDS.forEach((field) => {
|
||||
if (field in mergedSubBlocks) {
|
||||
delete mergedSubBlocks[field]
|
||||
}
|
||||
})
|
||||
Object.entries(subBlockValues).forEach(([subblockId, value]) => {
|
||||
if (mergedSubBlocks[subblockId]) {
|
||||
mergedSubBlocks[subblockId].value = value
|
||||
} else {
|
||||
// Create subblock if it doesn't exist in structure
|
||||
mergedSubBlocks[subblockId] = {
|
||||
id: subblockId,
|
||||
type: 'unknown',
|
||||
value: value,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Create the complete block data for the socket operation
|
||||
const duplicatedBlockData = {
|
||||
sourceId,
|
||||
id: newId,
|
||||
type: sourceBlock.type,
|
||||
name: newName,
|
||||
position: offsetPosition,
|
||||
data: sourceBlock.data ? JSON.parse(JSON.stringify(sourceBlock.data)) : {},
|
||||
subBlocks: mergedSubBlocks,
|
||||
outputs: sourceBlock.outputs ? JSON.parse(JSON.stringify(sourceBlock.outputs)) : {},
|
||||
parentId: sourceBlock.data?.parentId || null,
|
||||
extent: sourceBlock.data?.extent || null,
|
||||
enabled: sourceBlock.enabled ?? true,
|
||||
horizontalHandles: sourceBlock.horizontalHandles ?? true,
|
||||
advancedMode: sourceBlock.advancedMode ?? false,
|
||||
triggerMode: sourceBlock.triggerMode ?? false,
|
||||
height: sourceBlock.height || 0,
|
||||
}
|
||||
|
||||
workflowStore.addBlock(
|
||||
newId,
|
||||
sourceBlock.type,
|
||||
newName,
|
||||
offsetPosition,
|
||||
sourceBlock.data ? JSON.parse(JSON.stringify(sourceBlock.data)) : {},
|
||||
sourceBlock.data?.parentId,
|
||||
sourceBlock.data?.extent,
|
||||
{
|
||||
enabled: sourceBlock.enabled,
|
||||
horizontalHandles: sourceBlock.horizontalHandles,
|
||||
advancedMode: sourceBlock.advancedMode,
|
||||
triggerMode: sourceBlock.triggerMode ?? false,
|
||||
height: sourceBlock.height,
|
||||
}
|
||||
)
|
||||
|
||||
// Focus the newly duplicated block in the editor
|
||||
usePanelEditorStore.getState().setCurrentBlockId(newId)
|
||||
|
||||
executeQueuedOperation('duplicate', 'block', duplicatedBlockData, () => {
|
||||
workflowStore.addBlock(
|
||||
newId,
|
||||
sourceBlock.type,
|
||||
newName,
|
||||
offsetPosition,
|
||||
sourceBlock.data ? JSON.parse(JSON.stringify(sourceBlock.data)) : {},
|
||||
sourceBlock.data?.parentId,
|
||||
sourceBlock.data?.extent,
|
||||
{
|
||||
enabled: sourceBlock.enabled,
|
||||
horizontalHandles: sourceBlock.horizontalHandles,
|
||||
advancedMode: sourceBlock.advancedMode,
|
||||
triggerMode: sourceBlock.triggerMode ?? false,
|
||||
height: sourceBlock.height,
|
||||
}
|
||||
)
|
||||
|
||||
// Apply subblock values locally for immediate UI feedback
|
||||
// The server will persist these values as part of the block creation
|
||||
if (activeWorkflowId && Object.keys(subBlockValues).length > 0) {
|
||||
Object.entries(subBlockValues).forEach(([subblockId, value]) => {
|
||||
subBlockStore.setValue(newId, subblockId, value)
|
||||
})
|
||||
}
|
||||
|
||||
// Record for undo after the block is added
|
||||
undoRedo.recordDuplicateBlock(sourceId, newId, duplicatedBlockData, undefined)
|
||||
})
|
||||
},
|
||||
[
|
||||
executeQueuedOperation,
|
||||
workflowStore,
|
||||
subBlockStore,
|
||||
activeWorkflowId,
|
||||
isInActiveRoom,
|
||||
currentWorkflowId,
|
||||
undoRedo,
|
||||
]
|
||||
)
|
||||
|
||||
const collaborativeUpdateLoopType = useCallback(
|
||||
(loopId: string, loopType: 'for' | 'forEach' | 'while' | 'doWhile') => {
|
||||
const currentBlock = workflowStore.blocks[loopId]
|
||||
@@ -1714,23 +1306,196 @@ export function useCollaborativeWorkflow() {
|
||||
[executeQueuedOperation, variablesStore, cancelOperationsForVariable]
|
||||
)
|
||||
|
||||
const collaborativeDuplicateVariable = useCallback(
|
||||
(variableId: string) => {
|
||||
const newId = crypto.randomUUID()
|
||||
const sourceVariable = useVariablesStore.getState().variables[variableId]
|
||||
if (!sourceVariable) return null
|
||||
const collaborativeBatchAddBlocks = useCallback(
|
||||
(
|
||||
blocks: BlockState[],
|
||||
edges: Edge[] = [],
|
||||
loops: Record<string, Loop> = {},
|
||||
parallels: Record<string, Parallel> = {},
|
||||
subBlockValues: Record<string, Record<string, unknown>> = {},
|
||||
options?: { skipUndoRedo?: boolean }
|
||||
) => {
|
||||
if (!isInActiveRoom()) {
|
||||
logger.debug('Skipping batch add blocks - not in active workflow')
|
||||
return false
|
||||
}
|
||||
|
||||
executeQueuedOperation(
|
||||
'duplicate',
|
||||
'variable',
|
||||
{ sourceVariableId: variableId, id: newId },
|
||||
() => {
|
||||
variablesStore.duplicateVariable(variableId, newId)
|
||||
}
|
||||
)
|
||||
return newId
|
||||
if (isBaselineDiffView) {
|
||||
logger.debug('Skipping batch add blocks while viewing baseline diff')
|
||||
return false
|
||||
}
|
||||
|
||||
if (blocks.length === 0) return false
|
||||
|
||||
logger.info('Batch adding blocks collaboratively', {
|
||||
blockCount: blocks.length,
|
||||
edgeCount: edges.length,
|
||||
})
|
||||
|
||||
const operationId = crypto.randomUUID()
|
||||
|
||||
addToQueue({
|
||||
id: operationId,
|
||||
operation: {
|
||||
operation: 'batch-add-blocks',
|
||||
target: 'blocks',
|
||||
payload: { blocks, edges, loops, parallels, subBlockValues },
|
||||
},
|
||||
workflowId: activeWorkflowId || '',
|
||||
userId: session?.user?.id || 'unknown',
|
||||
})
|
||||
|
||||
blocks.forEach((block) => {
|
||||
workflowStore.addBlock(
|
||||
block.id,
|
||||
block.type,
|
||||
block.name,
|
||||
block.position,
|
||||
block.data,
|
||||
block.data?.parentId,
|
||||
block.data?.extent,
|
||||
{
|
||||
enabled: block.enabled,
|
||||
horizontalHandles: block.horizontalHandles,
|
||||
advancedMode: block.advancedMode,
|
||||
triggerMode: block.triggerMode ?? false,
|
||||
height: block.height,
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
edges.forEach((edge) => {
|
||||
workflowStore.addEdge(edge)
|
||||
})
|
||||
|
||||
if (Object.keys(loops).length > 0) {
|
||||
useWorkflowStore.setState((state) => ({
|
||||
loops: { ...state.loops, ...loops },
|
||||
}))
|
||||
}
|
||||
|
||||
if (Object.keys(parallels).length > 0) {
|
||||
useWorkflowStore.setState((state) => ({
|
||||
parallels: { ...state.parallels, ...parallels },
|
||||
}))
|
||||
}
|
||||
|
||||
if (activeWorkflowId) {
|
||||
Object.entries(subBlockValues).forEach(([blockId, subBlocks]) => {
|
||||
Object.entries(subBlocks).forEach(([subBlockId, value]) => {
|
||||
subBlockStore.setValue(blockId, subBlockId, value)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (!options?.skipUndoRedo) {
|
||||
undoRedo.recordBatchAddBlocks(blocks, edges, subBlockValues)
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
[executeQueuedOperation, variablesStore]
|
||||
[
|
||||
addToQueue,
|
||||
activeWorkflowId,
|
||||
session?.user?.id,
|
||||
isBaselineDiffView,
|
||||
isInActiveRoom,
|
||||
workflowStore,
|
||||
subBlockStore,
|
||||
undoRedo,
|
||||
]
|
||||
)
|
||||
|
||||
const collaborativeBatchRemoveBlocks = useCallback(
|
||||
(blockIds: string[], options?: { skipUndoRedo?: boolean }) => {
|
||||
if (!isInActiveRoom()) {
|
||||
logger.debug('Skipping batch remove blocks - not in active workflow')
|
||||
return false
|
||||
}
|
||||
|
||||
if (blockIds.length === 0) return false
|
||||
|
||||
blockIds.forEach((id) => cancelOperationsForBlock(id))
|
||||
|
||||
const allBlocksToRemove = new Set<string>(blockIds)
|
||||
const findAllDescendants = (parentId: string) => {
|
||||
Object.entries(workflowStore.blocks).forEach(([blockId, block]) => {
|
||||
if (block.data?.parentId === parentId) {
|
||||
allBlocksToRemove.add(blockId)
|
||||
findAllDescendants(blockId)
|
||||
}
|
||||
})
|
||||
}
|
||||
blockIds.forEach((id) => findAllDescendants(id))
|
||||
|
||||
const currentEditedBlockId = usePanelEditorStore.getState().currentBlockId
|
||||
if (currentEditedBlockId && allBlocksToRemove.has(currentEditedBlockId)) {
|
||||
usePanelEditorStore.getState().clearCurrentBlock()
|
||||
}
|
||||
|
||||
const mergedBlocks = mergeSubblockState(workflowStore.blocks, activeWorkflowId || undefined)
|
||||
const blockSnapshots: BlockState[] = []
|
||||
const subBlockValues: Record<string, Record<string, unknown>> = {}
|
||||
|
||||
allBlocksToRemove.forEach((blockId) => {
|
||||
const block = mergedBlocks[blockId]
|
||||
if (block) {
|
||||
blockSnapshots.push(block)
|
||||
if (block.subBlocks) {
|
||||
const values: Record<string, unknown> = {}
|
||||
Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]) => {
|
||||
if (subBlock.value !== null && subBlock.value !== undefined) {
|
||||
values[subBlockId] = subBlock.value
|
||||
}
|
||||
})
|
||||
if (Object.keys(values).length > 0) {
|
||||
subBlockValues[blockId] = values
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const edgeSnapshots = workflowStore.edges.filter(
|
||||
(e) => allBlocksToRemove.has(e.source) || allBlocksToRemove.has(e.target)
|
||||
)
|
||||
|
||||
logger.info('Batch removing blocks collaboratively', {
|
||||
requestedCount: blockIds.length,
|
||||
totalCount: allBlocksToRemove.size,
|
||||
})
|
||||
|
||||
const operationId = crypto.randomUUID()
|
||||
|
||||
addToQueue({
|
||||
id: operationId,
|
||||
operation: {
|
||||
operation: 'batch-remove-blocks',
|
||||
target: 'blocks',
|
||||
payload: { ids: Array.from(allBlocksToRemove) },
|
||||
},
|
||||
workflowId: activeWorkflowId || '',
|
||||
userId: session?.user?.id || 'unknown',
|
||||
})
|
||||
|
||||
blockIds.forEach((id) => {
|
||||
workflowStore.removeBlock(id)
|
||||
})
|
||||
|
||||
if (!options?.skipUndoRedo && blockSnapshots.length > 0) {
|
||||
undoRedo.recordBatchRemoveBlocks(blockSnapshots, edgeSnapshots, subBlockValues)
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
[
|
||||
addToQueue,
|
||||
activeWorkflowId,
|
||||
session?.user?.id,
|
||||
isInActiveRoom,
|
||||
workflowStore,
|
||||
cancelOperationsForBlock,
|
||||
undoRedo,
|
||||
]
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -1745,16 +1510,15 @@ export function useCollaborativeWorkflow() {
|
||||
leaveWorkflow,
|
||||
|
||||
// Collaborative operations
|
||||
collaborativeAddBlock,
|
||||
collaborativeUpdateBlockPosition,
|
||||
collaborativeBatchUpdatePositions,
|
||||
collaborativeUpdateBlockName,
|
||||
collaborativeRemoveBlock,
|
||||
collaborativeToggleBlockEnabled,
|
||||
collaborativeUpdateParentId,
|
||||
collaborativeToggleBlockAdvancedMode,
|
||||
collaborativeToggleBlockTriggerMode,
|
||||
collaborativeToggleBlockHandles,
|
||||
collaborativeDuplicateBlock,
|
||||
collaborativeBatchAddBlocks,
|
||||
collaborativeBatchRemoveBlocks,
|
||||
collaborativeAddEdge,
|
||||
collaborativeRemoveEdge,
|
||||
collaborativeSetSubblockValue,
|
||||
@@ -1764,7 +1528,6 @@ export function useCollaborativeWorkflow() {
|
||||
collaborativeUpdateVariable,
|
||||
collaborativeAddVariable,
|
||||
collaborativeDeleteVariable,
|
||||
collaborativeDuplicateVariable,
|
||||
|
||||
// Collaborative loop/parallel operations
|
||||
collaborativeUpdateLoopType,
|
||||
|
||||
@@ -5,11 +5,11 @@ import { useSession } from '@/lib/auth/auth-client'
|
||||
import { enqueueReplaceWorkflowState } from '@/lib/workflows/operations/socket-operations'
|
||||
import { useOperationQueue } from '@/stores/operation-queue/store'
|
||||
import {
|
||||
type BatchAddBlocksOperation,
|
||||
type BatchRemoveBlocksOperation,
|
||||
createOperationEntry,
|
||||
type DuplicateBlockOperation,
|
||||
type MoveBlockOperation,
|
||||
type Operation,
|
||||
type RemoveBlockOperation,
|
||||
type RemoveEdgeOperation,
|
||||
runWithUndoRedoRecordingSuspended,
|
||||
type UpdateParentOperation,
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from '@/stores/undo-redo'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { getUniqueBlockName, mergeSubblockState } from '@/stores/workflows/utils'
|
||||
import { mergeSubblockState } from '@/stores/workflows/utils'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
@@ -32,89 +32,94 @@ export function useUndoRedo() {
|
||||
|
||||
const userId = session?.user?.id || 'unknown'
|
||||
|
||||
const recordAddBlock = useCallback(
|
||||
(blockId: string, autoConnectEdge?: Edge) => {
|
||||
if (!activeWorkflowId) return
|
||||
const recordBatchAddBlocks = useCallback(
|
||||
(
|
||||
blockSnapshots: BlockState[],
|
||||
edgeSnapshots: Edge[] = [],
|
||||
subBlockValues: Record<string, Record<string, unknown>> = {}
|
||||
) => {
|
||||
if (!activeWorkflowId || blockSnapshots.length === 0) return
|
||||
|
||||
const operation: Operation = {
|
||||
const operation: BatchAddBlocksOperation = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'add-block',
|
||||
timestamp: Date.now(),
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
data: { blockId },
|
||||
}
|
||||
|
||||
// Get fresh state from store
|
||||
const currentBlocks = useWorkflowStore.getState().blocks
|
||||
const merged = mergeSubblockState(currentBlocks, activeWorkflowId, blockId)
|
||||
const blockSnapshot = merged[blockId] || currentBlocks[blockId]
|
||||
|
||||
const edgesToRemove = autoConnectEdge ? [autoConnectEdge] : []
|
||||
|
||||
const inverse: RemoveBlockOperation = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'remove-block',
|
||||
type: 'batch-add-blocks',
|
||||
timestamp: Date.now(),
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
data: {
|
||||
blockId,
|
||||
blockSnapshot,
|
||||
edgeSnapshots: edgesToRemove,
|
||||
blockSnapshots,
|
||||
edgeSnapshots,
|
||||
subBlockValues,
|
||||
},
|
||||
}
|
||||
|
||||
const inverse: BatchRemoveBlocksOperation = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'batch-remove-blocks',
|
||||
timestamp: Date.now(),
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
data: {
|
||||
blockSnapshots,
|
||||
edgeSnapshots,
|
||||
subBlockValues,
|
||||
},
|
||||
}
|
||||
|
||||
const entry = createOperationEntry(operation, inverse)
|
||||
undoRedoStore.push(activeWorkflowId, userId, entry)
|
||||
|
||||
logger.debug('Recorded add block', {
|
||||
blockId,
|
||||
hasAutoConnect: !!autoConnectEdge,
|
||||
edgeCount: edgesToRemove.length,
|
||||
logger.debug('Recorded batch add blocks', {
|
||||
blockCount: blockSnapshots.length,
|
||||
edgeCount: edgeSnapshots.length,
|
||||
workflowId: activeWorkflowId,
|
||||
hasSnapshot: !!blockSnapshot,
|
||||
})
|
||||
},
|
||||
[activeWorkflowId, userId, undoRedoStore]
|
||||
)
|
||||
|
||||
const recordRemoveBlock = useCallback(
|
||||
const recordBatchRemoveBlocks = useCallback(
|
||||
(
|
||||
blockId: string,
|
||||
blockSnapshot: BlockState,
|
||||
edgeSnapshots: Edge[],
|
||||
allBlockSnapshots?: Record<string, BlockState>
|
||||
blockSnapshots: BlockState[],
|
||||
edgeSnapshots: Edge[] = [],
|
||||
subBlockValues: Record<string, Record<string, unknown>> = {}
|
||||
) => {
|
||||
if (!activeWorkflowId) return
|
||||
if (!activeWorkflowId || blockSnapshots.length === 0) return
|
||||
|
||||
const operation: RemoveBlockOperation = {
|
||||
const operation: BatchRemoveBlocksOperation = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'remove-block',
|
||||
type: 'batch-remove-blocks',
|
||||
timestamp: Date.now(),
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
data: {
|
||||
blockId,
|
||||
blockSnapshot,
|
||||
blockSnapshots,
|
||||
edgeSnapshots,
|
||||
allBlockSnapshots,
|
||||
subBlockValues,
|
||||
},
|
||||
}
|
||||
|
||||
const inverse: Operation = {
|
||||
const inverse: BatchAddBlocksOperation = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'add-block',
|
||||
type: 'batch-add-blocks',
|
||||
timestamp: Date.now(),
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
data: { blockId },
|
||||
data: {
|
||||
blockSnapshots,
|
||||
edgeSnapshots,
|
||||
subBlockValues,
|
||||
},
|
||||
}
|
||||
|
||||
const entry = createOperationEntry(operation, inverse)
|
||||
undoRedoStore.push(activeWorkflowId, userId, entry)
|
||||
|
||||
logger.debug('Recorded remove block', { blockId, workflowId: activeWorkflowId })
|
||||
logger.debug('Recorded batch remove blocks', {
|
||||
blockCount: blockSnapshots.length,
|
||||
edgeCount: edgeSnapshots.length,
|
||||
workflowId: activeWorkflowId,
|
||||
})
|
||||
},
|
||||
[activeWorkflowId, userId, undoRedoStore]
|
||||
)
|
||||
@@ -227,51 +232,6 @@ export function useUndoRedo() {
|
||||
[activeWorkflowId, userId, undoRedoStore]
|
||||
)
|
||||
|
||||
const recordDuplicateBlock = useCallback(
|
||||
(
|
||||
sourceBlockId: string,
|
||||
duplicatedBlockId: string,
|
||||
duplicatedBlockSnapshot: BlockState,
|
||||
autoConnectEdge?: Edge
|
||||
) => {
|
||||
if (!activeWorkflowId) return
|
||||
|
||||
const operation: DuplicateBlockOperation = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'duplicate-block',
|
||||
timestamp: Date.now(),
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
data: {
|
||||
sourceBlockId,
|
||||
duplicatedBlockId,
|
||||
duplicatedBlockSnapshot,
|
||||
autoConnectEdge,
|
||||
},
|
||||
}
|
||||
|
||||
// Inverse is to remove the duplicated block
|
||||
const inverse: RemoveBlockOperation = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'remove-block',
|
||||
timestamp: Date.now(),
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
data: {
|
||||
blockId: duplicatedBlockId,
|
||||
blockSnapshot: duplicatedBlockSnapshot,
|
||||
edgeSnapshots: autoConnectEdge ? [autoConnectEdge] : [],
|
||||
},
|
||||
}
|
||||
|
||||
const entry = createOperationEntry(operation, inverse)
|
||||
undoRedoStore.push(activeWorkflowId, userId, entry)
|
||||
|
||||
logger.debug('Recorded duplicate block', { sourceBlockId, duplicatedBlockId })
|
||||
},
|
||||
[activeWorkflowId, userId, undoRedoStore]
|
||||
)
|
||||
|
||||
const recordUpdateParent = useCallback(
|
||||
(
|
||||
blockId: string,
|
||||
@@ -347,204 +307,117 @@ export function useUndoRedo() {
|
||||
const opId = crypto.randomUUID()
|
||||
|
||||
switch (entry.inverse.type) {
|
||||
case 'remove-block': {
|
||||
const removeInverse = entry.inverse as RemoveBlockOperation
|
||||
const blockId = removeInverse.data.blockId
|
||||
case 'batch-remove-blocks': {
|
||||
const batchRemoveOp = entry.inverse as BatchRemoveBlocksOperation
|
||||
const { blockSnapshots } = batchRemoveOp.data
|
||||
const blockIds = blockSnapshots.map((b) => b.id)
|
||||
|
||||
if (workflowStore.blocks[blockId]) {
|
||||
// Refresh inverse snapshot to capture the latest subblock values and edges at undo time
|
||||
const mergedNow = mergeSubblockState(workflowStore.blocks, activeWorkflowId, blockId)
|
||||
const latestBlockSnapshot = mergedNow[blockId] || workflowStore.blocks[blockId]
|
||||
const latestEdgeSnapshots = workflowStore.edges.filter(
|
||||
(e) => e.source === blockId || e.target === blockId
|
||||
)
|
||||
removeInverse.data.blockSnapshot = latestBlockSnapshot
|
||||
removeInverse.data.edgeSnapshots = latestEdgeSnapshots
|
||||
// First remove the edges that were added with the block (autoConnect edge)
|
||||
const edgesToRemove = removeInverse.data.edgeSnapshots || []
|
||||
edgesToRemove.forEach((edge) => {
|
||||
if (workflowStore.edges.find((e) => e.id === edge.id)) {
|
||||
workflowStore.removeEdge(edge.id)
|
||||
// Send edge removal to server
|
||||
addToQueue({
|
||||
id: crypto.randomUUID(),
|
||||
operation: {
|
||||
operation: 'remove',
|
||||
target: 'edge',
|
||||
payload: { id: edge.id },
|
||||
},
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Then remove the block
|
||||
addToQueue({
|
||||
id: opId,
|
||||
operation: {
|
||||
operation: 'remove',
|
||||
target: 'block',
|
||||
payload: { id: blockId, isUndo: true, originalOpId: entry.id },
|
||||
},
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
})
|
||||
workflowStore.removeBlock(blockId)
|
||||
} else {
|
||||
logger.debug('Undo remove-block skipped; block missing', {
|
||||
blockId,
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'add-block': {
|
||||
const originalOp = entry.operation as RemoveBlockOperation
|
||||
const { blockSnapshot, edgeSnapshots, allBlockSnapshots } = originalOp.data
|
||||
if (!blockSnapshot || workflowStore.blocks[blockSnapshot.id]) {
|
||||
logger.debug('Undo add-block skipped', {
|
||||
hasSnapshot: Boolean(blockSnapshot),
|
||||
exists: Boolean(blockSnapshot && workflowStore.blocks[blockSnapshot.id]),
|
||||
})
|
||||
const existingBlockIds = blockIds.filter((id) => workflowStore.blocks[id])
|
||||
if (existingBlockIds.length === 0) {
|
||||
logger.debug('Undo batch-remove-blocks skipped; no blocks exist')
|
||||
break
|
||||
}
|
||||
|
||||
// Preserve the original name from the snapshot on undo
|
||||
const restoredName = blockSnapshot.name
|
||||
const latestEdges = workflowStore.edges.filter(
|
||||
(e) => existingBlockIds.includes(e.source) || existingBlockIds.includes(e.target)
|
||||
)
|
||||
batchRemoveOp.data.edgeSnapshots = latestEdges
|
||||
|
||||
const latestSubBlockValues: Record<string, Record<string, unknown>> = {}
|
||||
existingBlockIds.forEach((blockId) => {
|
||||
const merged = mergeSubblockState(workflowStore.blocks, activeWorkflowId, blockId)
|
||||
const block = merged[blockId]
|
||||
if (block?.subBlocks) {
|
||||
const values: Record<string, unknown> = {}
|
||||
Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]) => {
|
||||
if (subBlock.value !== null && subBlock.value !== undefined) {
|
||||
values[subBlockId] = subBlock.value
|
||||
}
|
||||
})
|
||||
if (Object.keys(values).length > 0) {
|
||||
latestSubBlockValues[blockId] = values
|
||||
}
|
||||
}
|
||||
})
|
||||
batchRemoveOp.data.subBlockValues = latestSubBlockValues
|
||||
|
||||
// FIRST: Add the main block (parent subflow) with subBlocks in payload
|
||||
addToQueue({
|
||||
id: opId,
|
||||
operation: {
|
||||
operation: 'add',
|
||||
target: 'block',
|
||||
operation: 'batch-remove-blocks',
|
||||
target: 'blocks',
|
||||
payload: { ids: existingBlockIds },
|
||||
},
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
})
|
||||
|
||||
existingBlockIds.forEach((id) => workflowStore.removeBlock(id))
|
||||
break
|
||||
}
|
||||
case 'batch-add-blocks': {
|
||||
const batchAddOp = entry.operation as BatchAddBlocksOperation
|
||||
const { blockSnapshots, edgeSnapshots, subBlockValues } = batchAddOp.data
|
||||
|
||||
const blocksToAdd = blockSnapshots.filter((b) => !workflowStore.blocks[b.id])
|
||||
if (blocksToAdd.length === 0) {
|
||||
logger.debug('Undo batch-add-blocks skipped; all blocks exist')
|
||||
break
|
||||
}
|
||||
|
||||
addToQueue({
|
||||
id: opId,
|
||||
operation: {
|
||||
operation: 'batch-add-blocks',
|
||||
target: 'blocks',
|
||||
payload: {
|
||||
...blockSnapshot,
|
||||
name: restoredName,
|
||||
subBlocks: blockSnapshot.subBlocks || {},
|
||||
autoConnectEdge: undefined,
|
||||
isUndo: true,
|
||||
originalOpId: entry.id,
|
||||
blocks: blocksToAdd,
|
||||
edges: edgeSnapshots || [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
subBlockValues: subBlockValues || {},
|
||||
},
|
||||
},
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
})
|
||||
|
||||
workflowStore.addBlock(
|
||||
blockSnapshot.id,
|
||||
blockSnapshot.type,
|
||||
restoredName,
|
||||
blockSnapshot.position,
|
||||
blockSnapshot.data,
|
||||
blockSnapshot.data?.parentId,
|
||||
blockSnapshot.data?.extent,
|
||||
{
|
||||
enabled: blockSnapshot.enabled,
|
||||
horizontalHandles: blockSnapshot.horizontalHandles,
|
||||
advancedMode: blockSnapshot.advancedMode,
|
||||
triggerMode: blockSnapshot.triggerMode,
|
||||
height: blockSnapshot.height,
|
||||
}
|
||||
)
|
||||
|
||||
// Set subblock values for the main block locally
|
||||
if (blockSnapshot.subBlocks && activeWorkflowId) {
|
||||
const subblockValues: Record<string, any> = {}
|
||||
Object.entries(blockSnapshot.subBlocks).forEach(
|
||||
([subBlockId, subBlock]: [string, any]) => {
|
||||
if (subBlock.value !== null && subBlock.value !== undefined) {
|
||||
subblockValues[subBlockId] = subBlock.value
|
||||
}
|
||||
blocksToAdd.forEach((block) => {
|
||||
workflowStore.addBlock(
|
||||
block.id,
|
||||
block.type,
|
||||
block.name,
|
||||
block.position,
|
||||
block.data,
|
||||
block.data?.parentId,
|
||||
block.data?.extent,
|
||||
{
|
||||
enabled: block.enabled,
|
||||
horizontalHandles: block.horizontalHandles,
|
||||
advancedMode: block.advancedMode,
|
||||
triggerMode: block.triggerMode,
|
||||
height: block.height,
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
if (Object.keys(subblockValues).length > 0) {
|
||||
useSubBlockStore.setState((state) => ({
|
||||
workflowValues: {
|
||||
...state.workflowValues,
|
||||
[activeWorkflowId]: {
|
||||
...state.workflowValues[activeWorkflowId],
|
||||
[blockSnapshot.id]: subblockValues,
|
||||
},
|
||||
if (subBlockValues && Object.keys(subBlockValues).length > 0) {
|
||||
useSubBlockStore.setState((state) => ({
|
||||
workflowValues: {
|
||||
...state.workflowValues,
|
||||
[activeWorkflowId]: {
|
||||
...state.workflowValues[activeWorkflowId],
|
||||
...subBlockValues,
|
||||
},
|
||||
}))
|
||||
}
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
// SECOND: If this is a subflow with nested blocks, restore them AFTER the parent exists
|
||||
if (allBlockSnapshots) {
|
||||
Object.entries(allBlockSnapshots).forEach(([id, snap]: [string, any]) => {
|
||||
if (id !== blockSnapshot.id && !workflowStore.blocks[id]) {
|
||||
// Preserve original nested block name from snapshot on undo
|
||||
const restoredNestedName = snap.name
|
||||
|
||||
// Add nested block locally
|
||||
workflowStore.addBlock(
|
||||
snap.id,
|
||||
snap.type,
|
||||
restoredNestedName,
|
||||
snap.position,
|
||||
snap.data,
|
||||
snap.data?.parentId,
|
||||
snap.data?.extent,
|
||||
{
|
||||
enabled: snap.enabled,
|
||||
horizontalHandles: snap.horizontalHandles,
|
||||
advancedMode: snap.advancedMode,
|
||||
triggerMode: snap.triggerMode,
|
||||
height: snap.height,
|
||||
}
|
||||
)
|
||||
|
||||
// Send to server with subBlocks included in payload
|
||||
addToQueue({
|
||||
id: crypto.randomUUID(),
|
||||
operation: {
|
||||
operation: 'add',
|
||||
target: 'block',
|
||||
payload: {
|
||||
...snap,
|
||||
name: restoredNestedName,
|
||||
subBlocks: snap.subBlocks || {},
|
||||
autoConnectEdge: undefined,
|
||||
isUndo: true,
|
||||
originalOpId: entry.id,
|
||||
},
|
||||
},
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
})
|
||||
|
||||
// Restore subblock values for nested blocks locally
|
||||
if (snap.subBlocks && activeWorkflowId) {
|
||||
const subBlockStore = useSubBlockStore.getState()
|
||||
Object.entries(snap.subBlocks).forEach(
|
||||
([subBlockId, subBlock]: [string, any]) => {
|
||||
if (subBlock.value !== null && subBlock.value !== undefined) {
|
||||
subBlockStore.setValue(snap.id, subBlockId, subBlock.value)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// THIRD: Finally restore edges after all blocks exist
|
||||
if (edgeSnapshots && edgeSnapshots.length > 0) {
|
||||
edgeSnapshots.forEach((edge) => {
|
||||
workflowStore.addEdge(edge)
|
||||
addToQueue({
|
||||
id: crypto.randomUUID(),
|
||||
operation: {
|
||||
operation: 'add',
|
||||
target: 'edge',
|
||||
payload: edge,
|
||||
},
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
})
|
||||
if (!workflowStore.edges.find((e) => e.id === edge.id)) {
|
||||
workflowStore.addEdge(edge)
|
||||
}
|
||||
})
|
||||
}
|
||||
break
|
||||
@@ -639,49 +512,6 @@ export function useUndoRedo() {
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'duplicate-block': {
|
||||
// Undo duplicate means removing the duplicated block
|
||||
const dupOp = entry.operation as DuplicateBlockOperation
|
||||
const duplicatedId = dupOp.data.duplicatedBlockId
|
||||
|
||||
if (workflowStore.blocks[duplicatedId]) {
|
||||
// Remove any edges connected to the duplicated block
|
||||
const edges = workflowStore.edges.filter(
|
||||
(edge) => edge.source === duplicatedId || edge.target === duplicatedId
|
||||
)
|
||||
edges.forEach((edge) => {
|
||||
workflowStore.removeEdge(edge.id)
|
||||
addToQueue({
|
||||
id: crypto.randomUUID(),
|
||||
operation: {
|
||||
operation: 'remove',
|
||||
target: 'edge',
|
||||
payload: { id: edge.id },
|
||||
},
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
})
|
||||
})
|
||||
|
||||
// Remove the duplicated block
|
||||
addToQueue({
|
||||
id: opId,
|
||||
operation: {
|
||||
operation: 'remove',
|
||||
target: 'block',
|
||||
payload: { id: duplicatedId, isUndo: true, originalOpId: entry.id },
|
||||
},
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
})
|
||||
workflowStore.removeBlock(duplicatedId)
|
||||
} else {
|
||||
logger.debug('Undo duplicate-block skipped; duplicated block missing', {
|
||||
duplicatedId,
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'update-parent': {
|
||||
// Undo parent update means reverting to the old parent and position
|
||||
const updateOp = entry.inverse as UpdateParentOperation
|
||||
@@ -963,189 +793,96 @@ export function useUndoRedo() {
|
||||
const opId = crypto.randomUUID()
|
||||
|
||||
switch (entry.operation.type) {
|
||||
case 'add-block': {
|
||||
// Redo should re-apply the original add: add the block first, then edges
|
||||
const inv = entry.inverse as RemoveBlockOperation
|
||||
const snap = inv.data.blockSnapshot
|
||||
const edgeSnapshots = inv.data.edgeSnapshots || []
|
||||
const allBlockSnapshots = inv.data.allBlockSnapshots
|
||||
case 'batch-add-blocks': {
|
||||
const batchOp = entry.operation as BatchAddBlocksOperation
|
||||
const { blockSnapshots, edgeSnapshots, subBlockValues } = batchOp.data
|
||||
|
||||
if (!snap || workflowStore.blocks[snap.id]) {
|
||||
const blocksToAdd = blockSnapshots.filter((b) => !workflowStore.blocks[b.id])
|
||||
if (blocksToAdd.length === 0) {
|
||||
logger.debug('Redo batch-add-blocks skipped; all blocks exist')
|
||||
break
|
||||
}
|
||||
|
||||
// Preserve the original name from the snapshot on redo
|
||||
const restoredName = snap.name
|
||||
|
||||
// FIRST: Add the main block (parent subflow) with subBlocks included
|
||||
addToQueue({
|
||||
id: opId,
|
||||
operation: {
|
||||
operation: 'add',
|
||||
target: 'block',
|
||||
operation: 'batch-add-blocks',
|
||||
target: 'blocks',
|
||||
payload: {
|
||||
...snap,
|
||||
name: restoredName,
|
||||
subBlocks: snap.subBlocks || {},
|
||||
isRedo: true,
|
||||
originalOpId: entry.id,
|
||||
blocks: blocksToAdd,
|
||||
edges: edgeSnapshots || [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
subBlockValues: subBlockValues || {},
|
||||
},
|
||||
},
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
})
|
||||
|
||||
workflowStore.addBlock(
|
||||
snap.id,
|
||||
snap.type,
|
||||
restoredName,
|
||||
snap.position,
|
||||
snap.data,
|
||||
snap.data?.parentId,
|
||||
snap.data?.extent,
|
||||
{
|
||||
enabled: snap.enabled,
|
||||
horizontalHandles: snap.horizontalHandles,
|
||||
advancedMode: snap.advancedMode,
|
||||
triggerMode: snap.triggerMode,
|
||||
height: snap.height,
|
||||
}
|
||||
)
|
||||
|
||||
// Set subblock values for the main block locally
|
||||
if (snap.subBlocks && activeWorkflowId) {
|
||||
const subblockValues: Record<string, any> = {}
|
||||
Object.entries(snap.subBlocks).forEach(([subBlockId, subBlock]: [string, any]) => {
|
||||
if (subBlock.value !== null && subBlock.value !== undefined) {
|
||||
subblockValues[subBlockId] = subBlock.value
|
||||
blocksToAdd.forEach((block) => {
|
||||
workflowStore.addBlock(
|
||||
block.id,
|
||||
block.type,
|
||||
block.name,
|
||||
block.position,
|
||||
block.data,
|
||||
block.data?.parentId,
|
||||
block.data?.extent,
|
||||
{
|
||||
enabled: block.enabled,
|
||||
horizontalHandles: block.horizontalHandles,
|
||||
advancedMode: block.advancedMode,
|
||||
triggerMode: block.triggerMode,
|
||||
height: block.height,
|
||||
}
|
||||
})
|
||||
|
||||
if (Object.keys(subblockValues).length > 0) {
|
||||
useSubBlockStore.setState((state) => ({
|
||||
workflowValues: {
|
||||
...state.workflowValues,
|
||||
[activeWorkflowId]: {
|
||||
...state.workflowValues[activeWorkflowId],
|
||||
[snap.id]: subblockValues,
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// SECOND: If this is a subflow with nested blocks, restore them AFTER the parent exists
|
||||
if (allBlockSnapshots) {
|
||||
Object.entries(allBlockSnapshots).forEach(([id, snapNested]: [string, any]) => {
|
||||
if (id !== snap.id && !workflowStore.blocks[id]) {
|
||||
// Preserve original nested block name from snapshot on redo
|
||||
const restoredNestedName = snapNested.name
|
||||
|
||||
// Add nested block locally
|
||||
workflowStore.addBlock(
|
||||
snapNested.id,
|
||||
snapNested.type,
|
||||
restoredNestedName,
|
||||
snapNested.position,
|
||||
snapNested.data,
|
||||
snapNested.data?.parentId,
|
||||
snapNested.data?.extent,
|
||||
{
|
||||
enabled: snapNested.enabled,
|
||||
horizontalHandles: snapNested.horizontalHandles,
|
||||
advancedMode: snapNested.advancedMode,
|
||||
triggerMode: snapNested.triggerMode,
|
||||
height: snapNested.height,
|
||||
}
|
||||
)
|
||||
|
||||
// Send to server with subBlocks included
|
||||
addToQueue({
|
||||
id: crypto.randomUUID(),
|
||||
operation: {
|
||||
operation: 'add',
|
||||
target: 'block',
|
||||
payload: {
|
||||
...snapNested,
|
||||
name: restoredNestedName,
|
||||
subBlocks: snapNested.subBlocks || {},
|
||||
autoConnectEdge: undefined,
|
||||
isRedo: true,
|
||||
originalOpId: entry.id,
|
||||
},
|
||||
},
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
})
|
||||
|
||||
// Restore subblock values for nested blocks locally
|
||||
if (snapNested.subBlocks && activeWorkflowId) {
|
||||
const subBlockStore = useSubBlockStore.getState()
|
||||
Object.entries(snapNested.subBlocks).forEach(
|
||||
([subBlockId, subBlock]: [string, any]) => {
|
||||
if (subBlock.value !== null && subBlock.value !== undefined) {
|
||||
subBlockStore.setValue(snapNested.id, subBlockId, subBlock.value)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// THIRD: Finally restore edges after all blocks exist
|
||||
edgeSnapshots.forEach((edge) => {
|
||||
if (!workflowStore.edges.find((e) => e.id === edge.id)) {
|
||||
workflowStore.addEdge(edge)
|
||||
addToQueue({
|
||||
id: crypto.randomUUID(),
|
||||
operation: {
|
||||
operation: 'add',
|
||||
target: 'edge',
|
||||
payload: { ...edge, isRedo: true, originalOpId: entry.id },
|
||||
},
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
if (subBlockValues && Object.keys(subBlockValues).length > 0) {
|
||||
useSubBlockStore.setState((state) => ({
|
||||
workflowValues: {
|
||||
...state.workflowValues,
|
||||
[activeWorkflowId]: {
|
||||
...state.workflowValues[activeWorkflowId],
|
||||
...subBlockValues,
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
if (edgeSnapshots && edgeSnapshots.length > 0) {
|
||||
edgeSnapshots.forEach((edge) => {
|
||||
if (!workflowStore.edges.find((e) => e.id === edge.id)) {
|
||||
workflowStore.addEdge(edge)
|
||||
}
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'remove-block': {
|
||||
// Redo should re-apply the original remove: remove edges first, then block
|
||||
const blockId = entry.operation.data.blockId
|
||||
const edgesToRemove = (entry.operation as RemoveBlockOperation).data.edgeSnapshots || []
|
||||
edgesToRemove.forEach((edge) => {
|
||||
if (workflowStore.edges.find((e) => e.id === edge.id)) {
|
||||
workflowStore.removeEdge(edge.id)
|
||||
addToQueue({
|
||||
id: crypto.randomUUID(),
|
||||
operation: {
|
||||
operation: 'remove',
|
||||
target: 'edge',
|
||||
payload: { id: edge.id, isRedo: true, originalOpId: entry.id },
|
||||
},
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
})
|
||||
}
|
||||
case 'batch-remove-blocks': {
|
||||
const batchOp = entry.operation as BatchRemoveBlocksOperation
|
||||
const { blockSnapshots } = batchOp.data
|
||||
const blockIds = blockSnapshots.map((b) => b.id)
|
||||
|
||||
const existingBlockIds = blockIds.filter((id) => workflowStore.blocks[id])
|
||||
if (existingBlockIds.length === 0) {
|
||||
logger.debug('Redo batch-remove-blocks skipped; no blocks exist')
|
||||
break
|
||||
}
|
||||
|
||||
addToQueue({
|
||||
id: opId,
|
||||
operation: {
|
||||
operation: 'batch-remove-blocks',
|
||||
target: 'blocks',
|
||||
payload: { ids: existingBlockIds },
|
||||
},
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
})
|
||||
|
||||
if (workflowStore.blocks[blockId]) {
|
||||
addToQueue({
|
||||
id: opId,
|
||||
operation: {
|
||||
operation: 'remove',
|
||||
target: 'block',
|
||||
payload: { id: blockId, isRedo: true, originalOpId: entry.id },
|
||||
},
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
})
|
||||
workflowStore.removeBlock(blockId)
|
||||
} else {
|
||||
logger.debug('Redo remove-block skipped; block missing', { blockId })
|
||||
}
|
||||
existingBlockIds.forEach((id) => workflowStore.removeBlock(id))
|
||||
break
|
||||
}
|
||||
case 'add-edge': {
|
||||
@@ -1230,100 +967,6 @@ export function useUndoRedo() {
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'duplicate-block': {
|
||||
// Redo duplicate means re-adding the duplicated block
|
||||
const dupOp = entry.operation as DuplicateBlockOperation
|
||||
const { duplicatedBlockSnapshot, autoConnectEdge } = dupOp.data
|
||||
|
||||
if (!duplicatedBlockSnapshot || workflowStore.blocks[duplicatedBlockSnapshot.id]) {
|
||||
logger.debug('Redo duplicate-block skipped', {
|
||||
hasSnapshot: Boolean(duplicatedBlockSnapshot),
|
||||
exists: Boolean(
|
||||
duplicatedBlockSnapshot && workflowStore.blocks[duplicatedBlockSnapshot.id]
|
||||
),
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
const currentBlocks = useWorkflowStore.getState().blocks
|
||||
const uniqueName = getUniqueBlockName(duplicatedBlockSnapshot.name, currentBlocks)
|
||||
|
||||
// Add the duplicated block
|
||||
addToQueue({
|
||||
id: opId,
|
||||
operation: {
|
||||
operation: 'duplicate',
|
||||
target: 'block',
|
||||
payload: {
|
||||
...duplicatedBlockSnapshot,
|
||||
name: uniqueName,
|
||||
subBlocks: duplicatedBlockSnapshot.subBlocks || {},
|
||||
autoConnectEdge,
|
||||
isRedo: true,
|
||||
originalOpId: entry.id,
|
||||
},
|
||||
},
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
})
|
||||
|
||||
workflowStore.addBlock(
|
||||
duplicatedBlockSnapshot.id,
|
||||
duplicatedBlockSnapshot.type,
|
||||
uniqueName,
|
||||
duplicatedBlockSnapshot.position,
|
||||
duplicatedBlockSnapshot.data,
|
||||
duplicatedBlockSnapshot.data?.parentId,
|
||||
duplicatedBlockSnapshot.data?.extent,
|
||||
{
|
||||
enabled: duplicatedBlockSnapshot.enabled,
|
||||
horizontalHandles: duplicatedBlockSnapshot.horizontalHandles,
|
||||
advancedMode: duplicatedBlockSnapshot.advancedMode,
|
||||
triggerMode: duplicatedBlockSnapshot.triggerMode,
|
||||
height: duplicatedBlockSnapshot.height,
|
||||
}
|
||||
)
|
||||
|
||||
// Restore subblock values
|
||||
if (duplicatedBlockSnapshot.subBlocks && activeWorkflowId) {
|
||||
const subblockValues: Record<string, any> = {}
|
||||
Object.entries(duplicatedBlockSnapshot.subBlocks).forEach(
|
||||
([subBlockId, subBlock]: [string, any]) => {
|
||||
if (subBlock.value !== null && subBlock.value !== undefined) {
|
||||
subblockValues[subBlockId] = subBlock.value
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (Object.keys(subblockValues).length > 0) {
|
||||
useSubBlockStore.setState((state) => ({
|
||||
workflowValues: {
|
||||
...state.workflowValues,
|
||||
[activeWorkflowId]: {
|
||||
...state.workflowValues[activeWorkflowId],
|
||||
[duplicatedBlockSnapshot.id]: subblockValues,
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// Add auto-connect edge if present
|
||||
if (autoConnectEdge && !workflowStore.edges.find((e) => e.id === autoConnectEdge.id)) {
|
||||
workflowStore.addEdge(autoConnectEdge)
|
||||
addToQueue({
|
||||
id: crypto.randomUUID(),
|
||||
operation: {
|
||||
operation: 'add',
|
||||
target: 'edge',
|
||||
payload: { ...autoConnectEdge, isRedo: true, originalOpId: entry.id },
|
||||
},
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'update-parent': {
|
||||
// Redo parent update means applying the new parent and position
|
||||
const updateOp = entry.operation as UpdateParentOperation
|
||||
@@ -1726,12 +1369,11 @@ export function useUndoRedo() {
|
||||
)
|
||||
|
||||
return {
|
||||
recordAddBlock,
|
||||
recordRemoveBlock,
|
||||
recordBatchAddBlocks,
|
||||
recordBatchRemoveBlocks,
|
||||
recordAddEdge,
|
||||
recordRemoveEdge,
|
||||
recordMove,
|
||||
recordDuplicateBlock,
|
||||
recordUpdateParent,
|
||||
recordApplyDiff,
|
||||
recordAcceptDiff,
|
||||
|
||||
@@ -173,6 +173,9 @@ export async function persistWorkflowOperation(workflowId: string, operation: an
|
||||
case 'block':
|
||||
await handleBlockOperationTx(tx, workflowId, op, payload)
|
||||
break
|
||||
case 'blocks':
|
||||
await handleBlocksOperationTx(tx, workflowId, op, payload)
|
||||
break
|
||||
case 'edge':
|
||||
await handleEdgeOperationTx(tx, workflowId, op, payload)
|
||||
break
|
||||
@@ -216,107 +219,6 @@ async function handleBlockOperationTx(
|
||||
payload: any
|
||||
) {
|
||||
switch (operation) {
|
||||
case 'add': {
|
||||
// Validate required fields for add operation
|
||||
if (!payload.id || !payload.type || !payload.name || !payload.position) {
|
||||
throw new Error('Missing required fields for add block operation')
|
||||
}
|
||||
|
||||
logger.debug(`Adding block: ${payload.type} (${payload.id})`, {
|
||||
isSubflowType: isSubflowBlockType(payload.type),
|
||||
})
|
||||
|
||||
// Extract parentId and extent from payload.data if they exist there, otherwise from payload directly
|
||||
const parentId = payload.parentId || payload.data?.parentId || null
|
||||
const extent = payload.extent || payload.data?.extent || null
|
||||
|
||||
logger.debug(`Block parent info:`, {
|
||||
blockId: payload.id,
|
||||
hasParent: !!parentId,
|
||||
parentId,
|
||||
extent,
|
||||
payloadParentId: payload.parentId,
|
||||
dataParentId: payload.data?.parentId,
|
||||
})
|
||||
|
||||
try {
|
||||
const insertData = {
|
||||
id: payload.id,
|
||||
workflowId,
|
||||
type: payload.type,
|
||||
name: payload.name,
|
||||
positionX: payload.position.x,
|
||||
positionY: payload.position.y,
|
||||
data: {
|
||||
...(payload.data || {}),
|
||||
...(parentId ? { parentId } : {}),
|
||||
...(extent ? { extent } : {}),
|
||||
},
|
||||
subBlocks: payload.subBlocks || {},
|
||||
outputs: payload.outputs || {},
|
||||
enabled: payload.enabled ?? true,
|
||||
horizontalHandles: payload.horizontalHandles ?? true,
|
||||
advancedMode: payload.advancedMode ?? false,
|
||||
triggerMode: payload.triggerMode ?? false,
|
||||
height: payload.height || 0,
|
||||
}
|
||||
|
||||
await tx.insert(workflowBlocks).values(insertData)
|
||||
|
||||
await insertAutoConnectEdge(tx, workflowId, payload.autoConnectEdge, logger)
|
||||
} catch (insertError) {
|
||||
logger.error(`❌ Failed to insert block ${payload.id}:`, insertError)
|
||||
throw insertError
|
||||
}
|
||||
|
||||
// Auto-create subflow entry for loop/parallel blocks
|
||||
if (isSubflowBlockType(payload.type)) {
|
||||
try {
|
||||
const subflowConfig =
|
||||
payload.type === SubflowType.LOOP
|
||||
? {
|
||||
id: payload.id,
|
||||
nodes: [], // Empty initially, will be populated when child blocks are added
|
||||
iterations: payload.data?.count || DEFAULT_LOOP_ITERATIONS,
|
||||
loopType: payload.data?.loopType || 'for',
|
||||
// Set the appropriate field based on loop type
|
||||
...(payload.data?.loopType === 'while'
|
||||
? { whileCondition: payload.data?.whileCondition || '' }
|
||||
: payload.data?.loopType === 'doWhile'
|
||||
? { doWhileCondition: payload.data?.doWhileCondition || '' }
|
||||
: { forEachItems: payload.data?.collection || '' }),
|
||||
}
|
||||
: {
|
||||
id: payload.id,
|
||||
nodes: [], // Empty initially, will be populated when child blocks are added
|
||||
distribution: payload.data?.collection || '',
|
||||
count: payload.data?.count || DEFAULT_PARALLEL_COUNT,
|
||||
parallelType: payload.data?.parallelType || 'count',
|
||||
}
|
||||
|
||||
logger.debug(`Auto-creating ${payload.type} subflow ${payload.id}:`, subflowConfig)
|
||||
|
||||
await tx.insert(workflowSubflows).values({
|
||||
id: payload.id,
|
||||
workflowId,
|
||||
type: payload.type,
|
||||
config: subflowConfig,
|
||||
})
|
||||
} catch (subflowError) {
|
||||
logger.error(`❌ Failed to create ${payload.type} subflow ${payload.id}:`, subflowError)
|
||||
throw subflowError
|
||||
}
|
||||
}
|
||||
|
||||
// If this block has a parent, update the parent's subflow node list
|
||||
if (parentId) {
|
||||
await updateSubflowNodeList(tx, workflowId, parentId)
|
||||
}
|
||||
|
||||
logger.debug(`Added block ${payload.id} (${payload.type}) to workflow ${workflowId}`)
|
||||
break
|
||||
}
|
||||
|
||||
case 'update-position': {
|
||||
if (!payload.id || !payload.position) {
|
||||
throw new Error('Missing required fields for update position operation')
|
||||
@@ -342,156 +244,6 @@ async function handleBlockOperationTx(
|
||||
break
|
||||
}
|
||||
|
||||
case 'remove': {
|
||||
if (!payload.id) {
|
||||
throw new Error('Missing block ID for remove operation')
|
||||
}
|
||||
|
||||
// Collect all block IDs that will be deleted (including child blocks)
|
||||
const blocksToDelete = new Set<string>([payload.id])
|
||||
|
||||
// Check if this is a subflow block that needs cascade deletion
|
||||
const blockToRemove = await tx
|
||||
.select({
|
||||
type: workflowBlocks.type,
|
||||
parentId: sql<string | null>`${workflowBlocks.data}->>'parentId'`,
|
||||
})
|
||||
.from(workflowBlocks)
|
||||
.where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId)))
|
||||
.limit(1)
|
||||
|
||||
if (blockToRemove.length > 0 && isSubflowBlockType(blockToRemove[0].type)) {
|
||||
// Cascade delete: Remove all child blocks first
|
||||
const childBlocks = await tx
|
||||
.select({ id: workflowBlocks.id, type: workflowBlocks.type })
|
||||
.from(workflowBlocks)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowBlocks.workflowId, workflowId),
|
||||
sql`${workflowBlocks.data}->>'parentId' = ${payload.id}`
|
||||
)
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
`Starting cascade deletion for subflow block ${payload.id} (type: ${blockToRemove[0].type})`
|
||||
)
|
||||
logger.debug(
|
||||
`Found ${childBlocks.length} child blocks to delete: [${childBlocks.map((b: any) => `${b.id} (${b.type})`).join(', ')}]`
|
||||
)
|
||||
|
||||
// Add child blocks to deletion set
|
||||
childBlocks.forEach((child: { id: string; type: string }) => blocksToDelete.add(child.id))
|
||||
|
||||
// Remove edges connected to child blocks
|
||||
for (const childBlock of childBlocks) {
|
||||
await tx
|
||||
.delete(workflowEdges)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowEdges.workflowId, workflowId),
|
||||
or(
|
||||
eq(workflowEdges.sourceBlockId, childBlock.id),
|
||||
eq(workflowEdges.targetBlockId, childBlock.id)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Remove child blocks from database
|
||||
await tx
|
||||
.delete(workflowBlocks)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowBlocks.workflowId, workflowId),
|
||||
sql`${workflowBlocks.data}->>'parentId' = ${payload.id}`
|
||||
)
|
||||
)
|
||||
|
||||
// Remove the subflow entry
|
||||
await tx
|
||||
.delete(workflowSubflows)
|
||||
.where(
|
||||
and(eq(workflowSubflows.id, payload.id), eq(workflowSubflows.workflowId, workflowId))
|
||||
)
|
||||
}
|
||||
|
||||
// Clean up external webhooks before deleting blocks
|
||||
try {
|
||||
const blockIdsArray = Array.from(blocksToDelete)
|
||||
const webhooksToCleanup = await tx
|
||||
.select({
|
||||
webhook: webhook,
|
||||
workflow: {
|
||||
id: workflow.id,
|
||||
userId: workflow.userId,
|
||||
workspaceId: workflow.workspaceId,
|
||||
},
|
||||
})
|
||||
.from(webhook)
|
||||
.innerJoin(workflow, eq(webhook.workflowId, workflow.id))
|
||||
.where(and(eq(webhook.workflowId, workflowId), inArray(webhook.blockId, blockIdsArray)))
|
||||
|
||||
if (webhooksToCleanup.length > 0) {
|
||||
logger.debug(
|
||||
`Found ${webhooksToCleanup.length} webhook(s) to cleanup for blocks: ${blockIdsArray.join(', ')}`
|
||||
)
|
||||
|
||||
const requestId = `socket-${workflowId}-${Date.now()}-${Math.random().toString(36).substring(7)}`
|
||||
|
||||
// Clean up each webhook (don't fail if cleanup fails)
|
||||
for (const webhookData of webhooksToCleanup) {
|
||||
try {
|
||||
await cleanupExternalWebhook(webhookData.webhook, webhookData.workflow, requestId)
|
||||
} catch (cleanupError) {
|
||||
logger.error(`Failed to cleanup external webhook during block deletion`, {
|
||||
webhookId: webhookData.webhook.id,
|
||||
workflowId: webhookData.workflow.id,
|
||||
userId: webhookData.workflow.userId,
|
||||
workspaceId: webhookData.workflow.workspaceId,
|
||||
provider: webhookData.webhook.provider,
|
||||
blockId: webhookData.webhook.blockId,
|
||||
error: cleanupError,
|
||||
})
|
||||
// Continue with deletion even if cleanup fails
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (webhookCleanupError) {
|
||||
logger.error(`Error during webhook cleanup for block deletion (continuing with deletion)`, {
|
||||
workflowId,
|
||||
blockIds: Array.from(blocksToDelete),
|
||||
error: webhookCleanupError,
|
||||
})
|
||||
// Continue with block deletion even if webhook cleanup fails
|
||||
}
|
||||
|
||||
// Remove any edges connected to this block
|
||||
await tx
|
||||
.delete(workflowEdges)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowEdges.workflowId, workflowId),
|
||||
or(
|
||||
eq(workflowEdges.sourceBlockId, payload.id),
|
||||
eq(workflowEdges.targetBlockId, payload.id)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
// Finally remove the block itself
|
||||
await tx
|
||||
.delete(workflowBlocks)
|
||||
.where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId)))
|
||||
|
||||
// If this block had a parent, update the parent's subflow node list
|
||||
if (blockToRemove.length > 0 && blockToRemove[0].parentId) {
|
||||
await updateSubflowNodeList(tx, workflowId, blockToRemove[0].parentId)
|
||||
}
|
||||
|
||||
logger.debug(`Removed block ${payload.id} and its connections from workflow ${workflowId}`)
|
||||
break
|
||||
}
|
||||
|
||||
case 'update-name': {
|
||||
if (!payload.id || !payload.name) {
|
||||
throw new Error('Missing required fields for update name operation')
|
||||
@@ -677,114 +429,272 @@ async function handleBlockOperationTx(
|
||||
break
|
||||
}
|
||||
|
||||
case 'duplicate': {
|
||||
// Validate required fields for duplicate operation
|
||||
if (!payload.sourceId || !payload.id || !payload.type || !payload.name || !payload.position) {
|
||||
throw new Error('Missing required fields for duplicate block operation')
|
||||
}
|
||||
|
||||
logger.debug(`Duplicating block: ${payload.type} (${payload.sourceId} -> ${payload.id})`, {
|
||||
isSubflowType: isSubflowBlockType(payload.type),
|
||||
payload,
|
||||
})
|
||||
|
||||
// Extract parentId and extent from payload
|
||||
const parentId = payload.parentId || null
|
||||
const extent = payload.extent || null
|
||||
|
||||
try {
|
||||
const insertData = {
|
||||
id: payload.id,
|
||||
workflowId,
|
||||
type: payload.type,
|
||||
name: payload.name,
|
||||
positionX: payload.position.x,
|
||||
positionY: payload.position.y,
|
||||
data: {
|
||||
...(payload.data || {}),
|
||||
...(parentId ? { parentId } : {}),
|
||||
...(extent ? { extent } : {}),
|
||||
},
|
||||
subBlocks: payload.subBlocks || {},
|
||||
outputs: payload.outputs || {},
|
||||
enabled: payload.enabled ?? true,
|
||||
horizontalHandles: payload.horizontalHandles ?? true,
|
||||
advancedMode: payload.advancedMode ?? false,
|
||||
triggerMode: payload.triggerMode ?? false,
|
||||
height: payload.height || 0,
|
||||
}
|
||||
|
||||
await tx.insert(workflowBlocks).values(insertData)
|
||||
|
||||
// Handle auto-connect edge if present
|
||||
await insertAutoConnectEdge(tx, workflowId, payload.autoConnectEdge, logger)
|
||||
} catch (insertError) {
|
||||
logger.error(`❌ Failed to insert duplicated block ${payload.id}:`, insertError)
|
||||
throw insertError
|
||||
}
|
||||
|
||||
// Auto-create subflow entry for loop/parallel blocks
|
||||
if (isSubflowBlockType(payload.type)) {
|
||||
try {
|
||||
const subflowConfig =
|
||||
payload.type === SubflowType.LOOP
|
||||
? {
|
||||
id: payload.id,
|
||||
nodes: [], // Empty initially, will be populated when child blocks are added
|
||||
iterations: payload.data?.count || DEFAULT_LOOP_ITERATIONS,
|
||||
loopType: payload.data?.loopType || 'for',
|
||||
// Set the appropriate field based on loop type
|
||||
...(payload.data?.loopType === 'while'
|
||||
? { whileCondition: payload.data?.whileCondition || '' }
|
||||
: payload.data?.loopType === 'doWhile'
|
||||
? { doWhileCondition: payload.data?.doWhileCondition || '' }
|
||||
: { forEachItems: payload.data?.collection || '' }),
|
||||
}
|
||||
: {
|
||||
id: payload.id,
|
||||
nodes: [], // Empty initially, will be populated when child blocks are added
|
||||
distribution: payload.data?.collection || '',
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Auto-creating ${payload.type} subflow for duplicated block ${payload.id}:`,
|
||||
subflowConfig
|
||||
)
|
||||
|
||||
await tx.insert(workflowSubflows).values({
|
||||
id: payload.id,
|
||||
workflowId,
|
||||
type: payload.type,
|
||||
config: subflowConfig,
|
||||
})
|
||||
} catch (subflowError) {
|
||||
logger.error(
|
||||
`❌ Failed to create ${payload.type} subflow for duplicated block ${payload.id}:`,
|
||||
subflowError
|
||||
)
|
||||
throw subflowError
|
||||
}
|
||||
}
|
||||
|
||||
// If this block has a parent, update the parent's subflow node list
|
||||
if (parentId) {
|
||||
await updateSubflowNodeList(tx, workflowId, parentId)
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Duplicated block ${payload.sourceId} -> ${payload.id} (${payload.type}) in workflow ${workflowId}`
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
// Add other block operations as needed
|
||||
default:
|
||||
logger.warn(`Unknown block operation: ${operation}`)
|
||||
throw new Error(`Unsupported block operation: ${operation}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Edge operations
|
||||
async function handleBlocksOperationTx(
|
||||
tx: any,
|
||||
workflowId: string,
|
||||
operation: string,
|
||||
payload: any
|
||||
) {
|
||||
switch (operation) {
|
||||
case 'batch-update-positions': {
|
||||
const { updates } = payload
|
||||
if (!Array.isArray(updates) || updates.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const update of updates) {
|
||||
const { id, position } = update
|
||||
if (!id || !position) continue
|
||||
|
||||
await tx
|
||||
.update(workflowBlocks)
|
||||
.set({
|
||||
positionX: position.x,
|
||||
positionY: position.y,
|
||||
})
|
||||
.where(and(eq(workflowBlocks.id, id), eq(workflowBlocks.workflowId, workflowId)))
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'batch-add-blocks': {
|
||||
const { blocks, edges, loops, parallels } = payload
|
||||
|
||||
logger.info(`Batch adding blocks to workflow ${workflowId}`, {
|
||||
blockCount: blocks?.length || 0,
|
||||
edgeCount: edges?.length || 0,
|
||||
loopCount: Object.keys(loops || {}).length,
|
||||
parallelCount: Object.keys(parallels || {}).length,
|
||||
})
|
||||
|
||||
if (blocks && blocks.length > 0) {
|
||||
const blockValues = blocks.map((block: Record<string, unknown>) => ({
|
||||
id: block.id as string,
|
||||
workflowId,
|
||||
type: block.type as string,
|
||||
name: block.name as string,
|
||||
positionX: (block.position as { x: number; y: number }).x,
|
||||
positionY: (block.position as { x: number; y: number }).y,
|
||||
data: (block.data as Record<string, unknown>) || {},
|
||||
subBlocks: (block.subBlocks as Record<string, unknown>) || {},
|
||||
outputs: (block.outputs as Record<string, unknown>) || {},
|
||||
enabled: (block.enabled as boolean) ?? true,
|
||||
horizontalHandles: (block.horizontalHandles as boolean) ?? true,
|
||||
advancedMode: (block.advancedMode as boolean) ?? false,
|
||||
triggerMode: (block.triggerMode as boolean) ?? false,
|
||||
height: (block.height as number) || 0,
|
||||
}))
|
||||
|
||||
await tx.insert(workflowBlocks).values(blockValues)
|
||||
|
||||
// Create subflow entries for loop/parallel blocks (skip if already in payload)
|
||||
const loopIds = new Set(loops ? Object.keys(loops) : [])
|
||||
const parallelIds = new Set(parallels ? Object.keys(parallels) : [])
|
||||
for (const block of blocks) {
|
||||
const blockId = block.id as string
|
||||
if (block.type === 'loop' && !loopIds.has(blockId)) {
|
||||
await tx.insert(workflowSubflows).values({
|
||||
id: blockId,
|
||||
workflowId,
|
||||
type: 'loop',
|
||||
config: {
|
||||
loopType: 'for',
|
||||
iterations: DEFAULT_LOOP_ITERATIONS,
|
||||
nodes: [],
|
||||
},
|
||||
})
|
||||
} else if (block.type === 'parallel' && !parallelIds.has(blockId)) {
|
||||
await tx.insert(workflowSubflows).values({
|
||||
id: blockId,
|
||||
workflowId,
|
||||
type: 'parallel',
|
||||
config: {
|
||||
parallelType: 'fixed',
|
||||
count: DEFAULT_PARALLEL_COUNT,
|
||||
nodes: [],
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Update parent subflow node lists
|
||||
const parentIds = new Set<string>()
|
||||
for (const block of blocks) {
|
||||
const parentId = (block.data as Record<string, unknown>)?.parentId as string | undefined
|
||||
if (parentId) {
|
||||
parentIds.add(parentId)
|
||||
}
|
||||
}
|
||||
for (const parentId of parentIds) {
|
||||
await updateSubflowNodeList(tx, workflowId, parentId)
|
||||
}
|
||||
}
|
||||
|
||||
if (edges && edges.length > 0) {
|
||||
const edgeValues = edges.map((edge: Record<string, unknown>) => ({
|
||||
id: edge.id as string,
|
||||
workflowId,
|
||||
sourceBlockId: edge.source as string,
|
||||
targetBlockId: edge.target as string,
|
||||
sourceHandle: (edge.sourceHandle as string | null) || null,
|
||||
targetHandle: (edge.targetHandle as string | null) || null,
|
||||
}))
|
||||
|
||||
await tx.insert(workflowEdges).values(edgeValues)
|
||||
}
|
||||
|
||||
if (loops && Object.keys(loops).length > 0) {
|
||||
const loopValues = Object.entries(loops).map(([id, loop]) => ({
|
||||
id,
|
||||
workflowId,
|
||||
type: 'loop',
|
||||
config: loop as Record<string, unknown>,
|
||||
}))
|
||||
|
||||
await tx.insert(workflowSubflows).values(loopValues)
|
||||
}
|
||||
|
||||
if (parallels && Object.keys(parallels).length > 0) {
|
||||
const parallelValues = Object.entries(parallels).map(([id, parallel]) => ({
|
||||
id,
|
||||
workflowId,
|
||||
type: 'parallel',
|
||||
config: parallel as Record<string, unknown>,
|
||||
}))
|
||||
|
||||
await tx.insert(workflowSubflows).values(parallelValues)
|
||||
}
|
||||
|
||||
logger.info(`Successfully batch added blocks to workflow ${workflowId}`)
|
||||
break
|
||||
}
|
||||
|
||||
case 'batch-remove-blocks': {
|
||||
const { ids } = payload
|
||||
if (!Array.isArray(ids) || ids.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
logger.info(`Batch removing ${ids.length} blocks from workflow ${workflowId}`)
|
||||
|
||||
// Collect all block IDs including children of subflows
|
||||
const allBlocksToDelete = new Set<string>(ids)
|
||||
|
||||
for (const id of ids) {
|
||||
const blockToRemove = await tx
|
||||
.select({ type: workflowBlocks.type })
|
||||
.from(workflowBlocks)
|
||||
.where(and(eq(workflowBlocks.id, id), eq(workflowBlocks.workflowId, workflowId)))
|
||||
.limit(1)
|
||||
|
||||
if (blockToRemove.length > 0 && isSubflowBlockType(blockToRemove[0].type)) {
|
||||
const childBlocks = await tx
|
||||
.select({ id: workflowBlocks.id })
|
||||
.from(workflowBlocks)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowBlocks.workflowId, workflowId),
|
||||
sql`${workflowBlocks.data}->>'parentId' = ${id}`
|
||||
)
|
||||
)
|
||||
|
||||
childBlocks.forEach((child: { id: string }) => allBlocksToDelete.add(child.id))
|
||||
}
|
||||
}
|
||||
|
||||
const blockIdsArray = Array.from(allBlocksToDelete)
|
||||
|
||||
// Collect parent IDs BEFORE deleting blocks
|
||||
const parentIds = new Set<string>()
|
||||
for (const id of ids) {
|
||||
const parentInfo = await tx
|
||||
.select({ parentId: sql<string | null>`${workflowBlocks.data}->>'parentId'` })
|
||||
.from(workflowBlocks)
|
||||
.where(and(eq(workflowBlocks.id, id), eq(workflowBlocks.workflowId, workflowId)))
|
||||
.limit(1)
|
||||
|
||||
if (parentInfo.length > 0 && parentInfo[0].parentId) {
|
||||
parentIds.add(parentInfo[0].parentId)
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up external webhooks
|
||||
const webhooksToCleanup = await tx
|
||||
.select({
|
||||
webhook: webhook,
|
||||
workflow: {
|
||||
id: workflow.id,
|
||||
userId: workflow.userId,
|
||||
workspaceId: workflow.workspaceId,
|
||||
},
|
||||
})
|
||||
.from(webhook)
|
||||
.innerJoin(workflow, eq(webhook.workflowId, workflow.id))
|
||||
.where(and(eq(webhook.workflowId, workflowId), inArray(webhook.blockId, blockIdsArray)))
|
||||
|
||||
if (webhooksToCleanup.length > 0) {
|
||||
const requestId = `socket-batch-${workflowId}-${Date.now()}`
|
||||
for (const { webhook: wh, workflow: wf } of webhooksToCleanup) {
|
||||
try {
|
||||
await cleanupExternalWebhook(wh, wf, requestId)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to cleanup webhook ${wh.id}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete edges connected to any of the blocks
|
||||
await tx
|
||||
.delete(workflowEdges)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowEdges.workflowId, workflowId),
|
||||
or(
|
||||
inArray(workflowEdges.sourceBlockId, blockIdsArray),
|
||||
inArray(workflowEdges.targetBlockId, blockIdsArray)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
// Delete subflow entries
|
||||
await tx
|
||||
.delete(workflowSubflows)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowSubflows.workflowId, workflowId),
|
||||
inArray(workflowSubflows.id, blockIdsArray)
|
||||
)
|
||||
)
|
||||
|
||||
// Delete all blocks
|
||||
await tx
|
||||
.delete(workflowBlocks)
|
||||
.where(
|
||||
and(eq(workflowBlocks.workflowId, workflowId), inArray(workflowBlocks.id, blockIdsArray))
|
||||
)
|
||||
|
||||
// Update parent subflow node lists using pre-collected parent IDs
|
||||
for (const parentId of parentIds) {
|
||||
await updateSubflowNodeList(tx, workflowId, parentId)
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Successfully batch removed ${blockIdsArray.length} blocks from workflow ${workflowId}`
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported blocks operation: ${operation}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEdgeOperationTx(tx: any, workflowId: string, operation: string, payload: any) {
|
||||
switch (operation) {
|
||||
case 'add': {
|
||||
@@ -1013,53 +923,6 @@ async function handleVariableOperationTx(
|
||||
break
|
||||
}
|
||||
|
||||
case 'duplicate': {
|
||||
if (!payload.sourceVariableId || !payload.id) {
|
||||
throw new Error('Missing required fields for duplicate variable operation')
|
||||
}
|
||||
|
||||
const sourceVariable = currentVariables[payload.sourceVariableId]
|
||||
if (!sourceVariable) {
|
||||
throw new Error(`Source variable ${payload.sourceVariableId} not found`)
|
||||
}
|
||||
|
||||
// Create duplicated variable with unique name
|
||||
const baseName = `${sourceVariable.name} (copy)`
|
||||
let uniqueName = baseName
|
||||
let nameIndex = 1
|
||||
|
||||
// Ensure name uniqueness
|
||||
const existingNames = Object.values(currentVariables).map((v: any) => v.name)
|
||||
while (existingNames.includes(uniqueName)) {
|
||||
uniqueName = `${baseName} (${nameIndex})`
|
||||
nameIndex++
|
||||
}
|
||||
|
||||
const duplicatedVariable = {
|
||||
...sourceVariable,
|
||||
id: payload.id,
|
||||
name: uniqueName,
|
||||
}
|
||||
|
||||
const updatedVariables = {
|
||||
...currentVariables,
|
||||
[payload.id]: duplicatedVariable,
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(workflow)
|
||||
.set({
|
||||
variables: updatedVariables,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(workflow.id, workflowId))
|
||||
|
||||
logger.debug(
|
||||
`Duplicated variable ${payload.sourceVariableId} -> ${payload.id} (${uniqueName}) in workflow ${workflowId}`
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
logger.warn(`Unknown variable operation: ${operation}`)
|
||||
throw new Error(`Unsupported variable operation: ${operation}`)
|
||||
|
||||
@@ -145,7 +145,46 @@ export function setupOperationsHandlers(
|
||||
return
|
||||
}
|
||||
|
||||
if (target === 'variable' && ['add', 'remove', 'duplicate'].includes(operation)) {
|
||||
if (target === 'blocks' && operation === 'batch-update-positions') {
|
||||
socket.to(workflowId).emit('workflow-operation', {
|
||||
operation,
|
||||
target,
|
||||
payload,
|
||||
timestamp: operationTimestamp,
|
||||
senderId: socket.id,
|
||||
userId: session.userId,
|
||||
userName: session.userName,
|
||||
metadata: { workflowId, operationId: crypto.randomUUID(), isBatchPositionUpdate: true },
|
||||
})
|
||||
|
||||
try {
|
||||
await persistWorkflowOperation(workflowId, {
|
||||
operation,
|
||||
target,
|
||||
payload,
|
||||
timestamp: operationTimestamp,
|
||||
userId: session.userId,
|
||||
})
|
||||
room.lastModified = Date.now()
|
||||
|
||||
if (operationId) {
|
||||
socket.emit('operation-confirmed', { operationId, serverTimestamp: Date.now() })
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to persist batch position update:', error)
|
||||
if (operationId) {
|
||||
socket.emit('operation-failed', {
|
||||
operationId,
|
||||
error: error instanceof Error ? error.message : 'Database persistence failed',
|
||||
retryable: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (target === 'variable' && ['add', 'remove'].includes(operation)) {
|
||||
// Persist first, then broadcast
|
||||
await persistWorkflowOperation(workflowId, {
|
||||
operation,
|
||||
@@ -184,7 +223,6 @@ export function setupOperationsHandlers(
|
||||
}
|
||||
|
||||
if (target === 'workflow' && operation === 'replace-state') {
|
||||
// Persist the workflow state replacement to database first
|
||||
await persistWorkflowOperation(workflowId, {
|
||||
operation,
|
||||
target,
|
||||
@@ -221,6 +259,64 @@ export function setupOperationsHandlers(
|
||||
return
|
||||
}
|
||||
|
||||
if (target === 'blocks' && operation === 'batch-add-blocks') {
|
||||
await persistWorkflowOperation(workflowId, {
|
||||
operation,
|
||||
target,
|
||||
payload,
|
||||
timestamp: operationTimestamp,
|
||||
userId: session.userId,
|
||||
})
|
||||
|
||||
room.lastModified = Date.now()
|
||||
|
||||
socket.to(workflowId).emit('workflow-operation', {
|
||||
operation,
|
||||
target,
|
||||
payload,
|
||||
timestamp: operationTimestamp,
|
||||
senderId: socket.id,
|
||||
userId: session.userId,
|
||||
userName: session.userName,
|
||||
metadata: { workflowId, operationId: crypto.randomUUID() },
|
||||
})
|
||||
|
||||
if (operationId) {
|
||||
socket.emit('operation-confirmed', { operationId, serverTimestamp: Date.now() })
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (target === 'blocks' && operation === 'batch-remove-blocks') {
|
||||
await persistWorkflowOperation(workflowId, {
|
||||
operation,
|
||||
target,
|
||||
payload,
|
||||
timestamp: operationTimestamp,
|
||||
userId: session.userId,
|
||||
})
|
||||
|
||||
room.lastModified = Date.now()
|
||||
|
||||
socket.to(workflowId).emit('workflow-operation', {
|
||||
operation,
|
||||
target,
|
||||
payload,
|
||||
timestamp: operationTimestamp,
|
||||
senderId: socket.id,
|
||||
userId: session.userId,
|
||||
userName: session.userName,
|
||||
metadata: { workflowId, operationId: crypto.randomUUID() },
|
||||
})
|
||||
|
||||
if (operationId) {
|
||||
socket.emit('operation-confirmed', { operationId, serverTimestamp: Date.now() })
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// For non-position operations, persist first then broadcast
|
||||
await persistWorkflowOperation(workflowId, {
|
||||
operation,
|
||||
|
||||
@@ -294,13 +294,21 @@ describe('Socket Server Index Integration', () => {
|
||||
const { WorkflowOperationSchema } = await import('@/socket/validation/schemas')
|
||||
|
||||
const validOperation = {
|
||||
operation: 'add',
|
||||
target: 'block',
|
||||
operation: 'batch-add-blocks',
|
||||
target: 'blocks',
|
||||
payload: {
|
||||
id: 'test-block',
|
||||
type: 'action',
|
||||
name: 'Test Block',
|
||||
position: { x: 100, y: 200 },
|
||||
blocks: [
|
||||
{
|
||||
id: 'test-block',
|
||||
type: 'action',
|
||||
name: 'Test Block',
|
||||
position: { x: 100, y: 200 },
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
subBlockValues: {},
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
@@ -308,30 +316,39 @@ describe('Socket Server Index Integration', () => {
|
||||
expect(() => WorkflowOperationSchema.parse(validOperation)).not.toThrow()
|
||||
})
|
||||
|
||||
it.concurrent('should validate block operations with autoConnectEdge', async () => {
|
||||
it.concurrent('should validate batch-add-blocks with edges', async () => {
|
||||
const { WorkflowOperationSchema } = await import('@/socket/validation/schemas')
|
||||
|
||||
const validOperationWithAutoEdge = {
|
||||
operation: 'add',
|
||||
target: 'block',
|
||||
const validOperationWithEdge = {
|
||||
operation: 'batch-add-blocks',
|
||||
target: 'blocks',
|
||||
payload: {
|
||||
id: 'test-block',
|
||||
type: 'action',
|
||||
name: 'Test Block',
|
||||
position: { x: 100, y: 200 },
|
||||
autoConnectEdge: {
|
||||
id: 'auto-edge-123',
|
||||
source: 'source-block',
|
||||
target: 'test-block',
|
||||
sourceHandle: 'output',
|
||||
targetHandle: 'target',
|
||||
type: 'workflowEdge',
|
||||
},
|
||||
blocks: [
|
||||
{
|
||||
id: 'test-block',
|
||||
type: 'action',
|
||||
name: 'Test Block',
|
||||
position: { x: 100, y: 200 },
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: 'auto-edge-123',
|
||||
source: 'source-block',
|
||||
target: 'test-block',
|
||||
sourceHandle: 'output',
|
||||
targetHandle: 'target',
|
||||
type: 'workflowEdge',
|
||||
},
|
||||
],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
subBlockValues: {},
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
expect(() => WorkflowOperationSchema.parse(validOperationWithAutoEdge)).not.toThrow()
|
||||
expect(() => WorkflowOperationSchema.parse(validOperationWithEdge)).not.toThrow()
|
||||
})
|
||||
|
||||
it.concurrent('should validate edge operations', async () => {
|
||||
|
||||
@@ -27,13 +27,13 @@ describe('checkRolePermission', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('should allow add operation', () => {
|
||||
const result = checkRolePermission('admin', 'add')
|
||||
it('should allow batch-add-blocks operation', () => {
|
||||
const result = checkRolePermission('admin', 'batch-add-blocks')
|
||||
expectPermissionAllowed(result)
|
||||
})
|
||||
|
||||
it('should allow remove operation', () => {
|
||||
const result = checkRolePermission('admin', 'remove')
|
||||
it('should allow batch-remove-blocks operation', () => {
|
||||
const result = checkRolePermission('admin', 'batch-remove-blocks')
|
||||
expectPermissionAllowed(result)
|
||||
})
|
||||
|
||||
@@ -42,8 +42,8 @@ describe('checkRolePermission', () => {
|
||||
expectPermissionAllowed(result)
|
||||
})
|
||||
|
||||
it('should allow duplicate operation', () => {
|
||||
const result = checkRolePermission('admin', 'duplicate')
|
||||
it('should allow batch-update-positions operation', () => {
|
||||
const result = checkRolePermission('admin', 'batch-update-positions')
|
||||
expectPermissionAllowed(result)
|
||||
})
|
||||
|
||||
@@ -63,13 +63,13 @@ describe('checkRolePermission', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('should allow add operation', () => {
|
||||
const result = checkRolePermission('write', 'add')
|
||||
it('should allow batch-add-blocks operation', () => {
|
||||
const result = checkRolePermission('write', 'batch-add-blocks')
|
||||
expectPermissionAllowed(result)
|
||||
})
|
||||
|
||||
it('should allow remove operation', () => {
|
||||
const result = checkRolePermission('write', 'remove')
|
||||
it('should allow batch-remove-blocks operation', () => {
|
||||
const result = checkRolePermission('write', 'batch-remove-blocks')
|
||||
expectPermissionAllowed(result)
|
||||
})
|
||||
|
||||
@@ -85,14 +85,14 @@ describe('checkRolePermission', () => {
|
||||
expectPermissionAllowed(result)
|
||||
})
|
||||
|
||||
it('should deny add operation for read role', () => {
|
||||
const result = checkRolePermission('read', 'add')
|
||||
it('should deny batch-add-blocks operation for read role', () => {
|
||||
const result = checkRolePermission('read', 'batch-add-blocks')
|
||||
expectPermissionDenied(result, 'read')
|
||||
expectPermissionDenied(result, 'add')
|
||||
expectPermissionDenied(result, 'batch-add-blocks')
|
||||
})
|
||||
|
||||
it('should deny remove operation for read role', () => {
|
||||
const result = checkRolePermission('read', 'remove')
|
||||
it('should deny batch-remove-blocks operation for read role', () => {
|
||||
const result = checkRolePermission('read', 'batch-remove-blocks')
|
||||
expectPermissionDenied(result, 'read')
|
||||
})
|
||||
|
||||
@@ -101,9 +101,9 @@ describe('checkRolePermission', () => {
|
||||
expectPermissionDenied(result, 'read')
|
||||
})
|
||||
|
||||
it('should deny duplicate operation for read role', () => {
|
||||
const result = checkRolePermission('read', 'duplicate')
|
||||
expectPermissionDenied(result, 'read')
|
||||
it('should allow batch-update-positions operation for read role', () => {
|
||||
const result = checkRolePermission('read', 'batch-update-positions')
|
||||
expectPermissionAllowed(result)
|
||||
})
|
||||
|
||||
it('should deny replace-state operation for read role', () => {
|
||||
@@ -117,7 +117,8 @@ describe('checkRolePermission', () => {
|
||||
})
|
||||
|
||||
it('should deny all write operations for read role', () => {
|
||||
const writeOperations = SOCKET_OPERATIONS.filter((op) => op !== 'update-position')
|
||||
const readAllowedOps = ['update-position', 'batch-update-positions']
|
||||
const writeOperations = SOCKET_OPERATIONS.filter((op) => !readAllowedOps.includes(op))
|
||||
|
||||
for (const operation of writeOperations) {
|
||||
const result = checkRolePermission('read', operation)
|
||||
@@ -138,7 +139,7 @@ describe('checkRolePermission', () => {
|
||||
})
|
||||
|
||||
it('should deny operations for empty role', () => {
|
||||
const result = checkRolePermission('', 'add')
|
||||
const result = checkRolePermission('', 'batch-add-blocks')
|
||||
expectPermissionDenied(result)
|
||||
})
|
||||
})
|
||||
@@ -186,15 +187,21 @@ describe('checkRolePermission', () => {
|
||||
|
||||
it('should verify read has minimal permissions', () => {
|
||||
const readOps = ROLE_ALLOWED_OPERATIONS.read
|
||||
expect(readOps).toHaveLength(1)
|
||||
expect(readOps).toHaveLength(2)
|
||||
expect(readOps).toContain('update-position')
|
||||
expect(readOps).toContain('batch-update-positions')
|
||||
})
|
||||
})
|
||||
|
||||
describe('specific operations', () => {
|
||||
const testCases = [
|
||||
{ operation: 'add', adminAllowed: true, writeAllowed: true, readAllowed: false },
|
||||
{ operation: 'remove', adminAllowed: true, writeAllowed: true, readAllowed: false },
|
||||
{ operation: 'batch-add-blocks', adminAllowed: true, writeAllowed: true, readAllowed: false },
|
||||
{
|
||||
operation: 'batch-remove-blocks',
|
||||
adminAllowed: true,
|
||||
writeAllowed: true,
|
||||
readAllowed: false,
|
||||
},
|
||||
{ operation: 'update', adminAllowed: true, writeAllowed: true, readAllowed: false },
|
||||
{ operation: 'update-position', adminAllowed: true, writeAllowed: true, readAllowed: true },
|
||||
{ operation: 'update-name', adminAllowed: true, writeAllowed: true, readAllowed: false },
|
||||
@@ -214,7 +221,12 @@ describe('checkRolePermission', () => {
|
||||
readAllowed: false,
|
||||
},
|
||||
{ operation: 'toggle-handles', adminAllowed: true, writeAllowed: true, readAllowed: false },
|
||||
{ operation: 'duplicate', adminAllowed: true, writeAllowed: true, readAllowed: false },
|
||||
{
|
||||
operation: 'batch-update-positions',
|
||||
adminAllowed: true,
|
||||
writeAllowed: true,
|
||||
readAllowed: true,
|
||||
},
|
||||
{ operation: 'replace-state', adminAllowed: true, writeAllowed: true, readAllowed: false },
|
||||
]
|
||||
|
||||
@@ -238,13 +250,13 @@ describe('checkRolePermission', () => {
|
||||
|
||||
describe('reason messages', () => {
|
||||
it('should include role in denial reason', () => {
|
||||
const result = checkRolePermission('read', 'add')
|
||||
const result = checkRolePermission('read', 'batch-add-blocks')
|
||||
expect(result.reason).toContain("'read'")
|
||||
})
|
||||
|
||||
it('should include operation in denial reason', () => {
|
||||
const result = checkRolePermission('read', 'add')
|
||||
expect(result.reason).toContain("'add'")
|
||||
const result = checkRolePermission('read', 'batch-add-blocks')
|
||||
expect(result.reason).toContain("'batch-add-blocks'")
|
||||
})
|
||||
|
||||
it('should have descriptive denial message format', () => {
|
||||
|
||||
@@ -13,6 +13,9 @@ const ROLE_PERMISSIONS: Record<string, string[]> = {
|
||||
'remove',
|
||||
'update',
|
||||
'update-position',
|
||||
'batch-update-positions',
|
||||
'batch-add-blocks',
|
||||
'batch-remove-blocks',
|
||||
'update-name',
|
||||
'toggle-enabled',
|
||||
'update-parent',
|
||||
@@ -20,7 +23,6 @@ const ROLE_PERMISSIONS: Record<string, string[]> = {
|
||||
'update-advanced-mode',
|
||||
'update-trigger-mode',
|
||||
'toggle-handles',
|
||||
'duplicate',
|
||||
'replace-state',
|
||||
],
|
||||
write: [
|
||||
@@ -28,6 +30,9 @@ const ROLE_PERMISSIONS: Record<string, string[]> = {
|
||||
'remove',
|
||||
'update',
|
||||
'update-position',
|
||||
'batch-update-positions',
|
||||
'batch-add-blocks',
|
||||
'batch-remove-blocks',
|
||||
'update-name',
|
||||
'toggle-enabled',
|
||||
'update-parent',
|
||||
@@ -35,10 +40,9 @@ const ROLE_PERMISSIONS: Record<string, string[]> = {
|
||||
'update-advanced-mode',
|
||||
'update-trigger-mode',
|
||||
'toggle-handles',
|
||||
'duplicate',
|
||||
'replace-state',
|
||||
],
|
||||
read: ['update-position'],
|
||||
read: ['update-position', 'batch-update-positions'],
|
||||
}
|
||||
|
||||
// Check if a role allows a specific operation (no DB query, pure logic)
|
||||
|
||||
@@ -103,18 +103,26 @@ describe('Socket Server Integration Tests', () => {
|
||||
|
||||
const operationPromise = new Promise<void>((resolve) => {
|
||||
client2.once('workflow-operation', (data) => {
|
||||
expect(data.operation).toBe('add')
|
||||
expect(data.target).toBe('block')
|
||||
expect(data.payload.id).toBe('block-123')
|
||||
expect(data.operation).toBe('batch-add-blocks')
|
||||
expect(data.target).toBe('blocks')
|
||||
expect(data.payload.blocks[0].id).toBe('block-123')
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
clientSocket.emit('workflow-operation', {
|
||||
workflowId,
|
||||
operation: 'add',
|
||||
target: 'block',
|
||||
payload: { id: 'block-123', type: 'action', name: 'Test Block' },
|
||||
operation: 'batch-add-blocks',
|
||||
target: 'blocks',
|
||||
payload: {
|
||||
blocks: [
|
||||
{ id: 'block-123', type: 'action', name: 'Test Block', position: { x: 0, y: 0 } },
|
||||
],
|
||||
edges: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
subBlockValues: {},
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
@@ -170,9 +178,17 @@ describe('Socket Server Integration Tests', () => {
|
||||
|
||||
clients[0].emit('workflow-operation', {
|
||||
workflowId,
|
||||
operation: 'add',
|
||||
target: 'block',
|
||||
payload: { id: 'stress-block', type: 'action' },
|
||||
operation: 'batch-add-blocks',
|
||||
target: 'blocks',
|
||||
payload: {
|
||||
blocks: [
|
||||
{ id: 'stress-block', type: 'action', name: 'Stress Block', position: { x: 0, y: 0 } },
|
||||
],
|
||||
edges: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
subBlockValues: {},
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
@@ -211,7 +227,7 @@ describe('Socket Server Integration Tests', () => {
|
||||
const operationsPromise = new Promise<void>((resolve) => {
|
||||
client2.on('workflow-operation', (data) => {
|
||||
receivedCount++
|
||||
receivedOperations.add(data.payload.id)
|
||||
receivedOperations.add(data.payload.blocks[0].id)
|
||||
|
||||
if (receivedCount === numOperations) {
|
||||
resolve()
|
||||
@@ -222,9 +238,22 @@ describe('Socket Server Integration Tests', () => {
|
||||
for (let i = 0; i < numOperations; i++) {
|
||||
clientSocket.emit('workflow-operation', {
|
||||
workflowId,
|
||||
operation: 'add',
|
||||
target: 'block',
|
||||
payload: { id: `rapid-block-${i}`, type: 'action' },
|
||||
operation: 'batch-add-blocks',
|
||||
target: 'blocks',
|
||||
payload: {
|
||||
blocks: [
|
||||
{
|
||||
id: `rapid-block-${i}`,
|
||||
type: 'action',
|
||||
name: `Rapid Block ${i}`,
|
||||
position: { x: 0, y: 0 },
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
subBlockValues: {},
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -17,8 +17,6 @@ const AutoConnectEdgeSchema = z.object({
|
||||
|
||||
export const BlockOperationSchema = z.object({
|
||||
operation: z.enum([
|
||||
'add',
|
||||
'remove',
|
||||
'update-position',
|
||||
'update-name',
|
||||
'toggle-enabled',
|
||||
@@ -27,12 +25,10 @@ export const BlockOperationSchema = z.object({
|
||||
'update-advanced-mode',
|
||||
'update-trigger-mode',
|
||||
'toggle-handles',
|
||||
'duplicate',
|
||||
]),
|
||||
target: z.literal('block'),
|
||||
payload: z.object({
|
||||
id: z.string(),
|
||||
sourceId: z.string().optional(), // For duplicate operations
|
||||
type: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
position: PositionSchema.optional(),
|
||||
@@ -47,7 +43,21 @@ export const BlockOperationSchema = z.object({
|
||||
advancedMode: z.boolean().optional(),
|
||||
triggerMode: z.boolean().optional(),
|
||||
height: z.number().optional(),
|
||||
autoConnectEdge: AutoConnectEdgeSchema.optional(), // Add support for auto-connect edges
|
||||
}),
|
||||
timestamp: z.number(),
|
||||
operationId: z.string().optional(),
|
||||
})
|
||||
|
||||
export const BatchPositionUpdateSchema = z.object({
|
||||
operation: z.literal('batch-update-positions'),
|
||||
target: z.literal('blocks'),
|
||||
payload: z.object({
|
||||
updates: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
position: PositionSchema,
|
||||
})
|
||||
),
|
||||
}),
|
||||
timestamp: z.number(),
|
||||
operationId: z.string().optional(),
|
||||
@@ -102,23 +112,37 @@ export const VariableOperationSchema = z.union([
|
||||
timestamp: z.number(),
|
||||
operationId: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
operation: z.literal('duplicate'),
|
||||
target: z.literal('variable'),
|
||||
payload: z.object({
|
||||
sourceVariableId: z.string(),
|
||||
id: z.string(),
|
||||
}),
|
||||
timestamp: z.number(),
|
||||
operationId: z.string().optional(),
|
||||
}),
|
||||
])
|
||||
|
||||
export const WorkflowStateOperationSchema = z.object({
|
||||
operation: z.literal('replace-state'),
|
||||
target: z.literal('workflow'),
|
||||
payload: z.object({
|
||||
state: z.any(), // Full workflow state
|
||||
state: z.any(),
|
||||
}),
|
||||
timestamp: z.number(),
|
||||
operationId: z.string().optional(),
|
||||
})
|
||||
|
||||
export const BatchAddBlocksSchema = z.object({
|
||||
operation: z.literal('batch-add-blocks'),
|
||||
target: z.literal('blocks'),
|
||||
payload: z.object({
|
||||
blocks: z.array(z.record(z.any())),
|
||||
edges: z.array(AutoConnectEdgeSchema).optional(),
|
||||
loops: z.record(z.any()).optional(),
|
||||
parallels: z.record(z.any()).optional(),
|
||||
subBlockValues: z.record(z.record(z.any())).optional(),
|
||||
}),
|
||||
timestamp: z.number(),
|
||||
operationId: z.string().optional(),
|
||||
})
|
||||
|
||||
export const BatchRemoveBlocksSchema = z.object({
|
||||
operation: z.literal('batch-remove-blocks'),
|
||||
target: z.literal('blocks'),
|
||||
payload: z.object({
|
||||
ids: z.array(z.string()),
|
||||
}),
|
||||
timestamp: z.number(),
|
||||
operationId: z.string().optional(),
|
||||
@@ -126,6 +150,9 @@ export const WorkflowStateOperationSchema = z.object({
|
||||
|
||||
export const WorkflowOperationSchema = z.union([
|
||||
BlockOperationSchema,
|
||||
BatchPositionUpdateSchema,
|
||||
BatchAddBlocksSchema,
|
||||
BatchRemoveBlocksSchema,
|
||||
EdgeOperationSchema,
|
||||
SubflowOperationSchema,
|
||||
VariableOperationSchema,
|
||||
|
||||
@@ -375,15 +375,31 @@ export const useOperationQueueStore = create<OperationQueueState>((set, get) =>
|
||||
cancelOperationsForBlock: (blockId: string) => {
|
||||
logger.debug('Canceling all operations for block', { blockId })
|
||||
|
||||
// No debounced timeouts to cancel (moved to server-side)
|
||||
|
||||
// Find and cancel operation timeouts for operations related to this block
|
||||
const state = get()
|
||||
const operationsToCancel = state.operations.filter(
|
||||
(op) =>
|
||||
(op.operation.target === 'block' && op.operation.payload?.id === blockId) ||
|
||||
(op.operation.target === 'subblock' && op.operation.payload?.blockId === blockId)
|
||||
)
|
||||
const operationsToCancel = state.operations.filter((op) => {
|
||||
const { target, payload, operation } = op.operation
|
||||
|
||||
// Single block property updates (update-position, toggle-enabled, update-name, etc.)
|
||||
if (target === 'block' && payload?.id === blockId) return true
|
||||
|
||||
// Subblock updates for this block
|
||||
if (target === 'subblock' && payload?.blockId === blockId) return true
|
||||
|
||||
// Batch block operations
|
||||
if (target === 'blocks') {
|
||||
if (operation === 'batch-add-blocks' && Array.isArray(payload?.blocks)) {
|
||||
return payload.blocks.some((b: { id: string }) => b.id === blockId)
|
||||
}
|
||||
if (operation === 'batch-remove-blocks' && Array.isArray(payload?.ids)) {
|
||||
return payload.ids.includes(blockId)
|
||||
}
|
||||
if (operation === 'batch-update-positions' && Array.isArray(payload?.updates)) {
|
||||
return payload.updates.some((u: { id: string }) => u.id === blockId)
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
// Cancel timeouts for these operations
|
||||
operationsToCancel.forEach((op) => {
|
||||
@@ -401,13 +417,30 @@ export const useOperationQueueStore = create<OperationQueueState>((set, get) =>
|
||||
})
|
||||
|
||||
// Remove all operations for this block (both pending and processing)
|
||||
const newOperations = state.operations.filter(
|
||||
(op) =>
|
||||
!(
|
||||
(op.operation.target === 'block' && op.operation.payload?.id === blockId) ||
|
||||
(op.operation.target === 'subblock' && op.operation.payload?.blockId === blockId)
|
||||
)
|
||||
)
|
||||
const newOperations = state.operations.filter((op) => {
|
||||
const { target, payload, operation } = op.operation
|
||||
|
||||
// Single block property updates (update-position, toggle-enabled, update-name, etc.)
|
||||
if (target === 'block' && payload?.id === blockId) return false
|
||||
|
||||
// Subblock updates for this block
|
||||
if (target === 'subblock' && payload?.blockId === blockId) return false
|
||||
|
||||
// Batch block operations
|
||||
if (target === 'blocks') {
|
||||
if (operation === 'batch-add-blocks' && Array.isArray(payload?.blocks)) {
|
||||
if (payload.blocks.some((b: { id: string }) => b.id === blockId)) return false
|
||||
}
|
||||
if (operation === 'batch-remove-blocks' && Array.isArray(payload?.ids)) {
|
||||
if (payload.ids.includes(blockId)) return false
|
||||
}
|
||||
if (operation === 'batch-update-positions' && Array.isArray(payload?.updates)) {
|
||||
if (payload.updates.some((u: { id: string }) => u.id === blockId)) return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
set({
|
||||
operations: newOperations,
|
||||
|
||||
@@ -22,6 +22,10 @@ interface PanelEditorState {
|
||||
setConnectionsHeight: (height: number) => void
|
||||
/** Toggle connections between collapsed (min height) and expanded (default height) */
|
||||
toggleConnectionsCollapsed: () => void
|
||||
/** Flag to signal the editor to focus the rename input */
|
||||
shouldFocusRename: boolean
|
||||
/** Sets the shouldFocusRename flag */
|
||||
setShouldFocusRename: (value: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,6 +37,8 @@ export const usePanelEditorStore = create<PanelEditorState>()(
|
||||
(set, get) => ({
|
||||
currentBlockId: null,
|
||||
connectionsHeight: EDITOR_CONNECTIONS_HEIGHT.DEFAULT,
|
||||
shouldFocusRename: false,
|
||||
setShouldFocusRename: (value) => set({ shouldFocusRename: value }),
|
||||
setCurrentBlockId: (blockId) => {
|
||||
set({ currentBlockId: blockId })
|
||||
|
||||
@@ -79,6 +85,10 @@ export const usePanelEditorStore = create<PanelEditorState>()(
|
||||
}),
|
||||
{
|
||||
name: 'panel-editor-state',
|
||||
partialize: (state) => ({
|
||||
currentBlockId: state.currentBlockId,
|
||||
connectionsHeight: state.connectionsHeight,
|
||||
}),
|
||||
onRehydrateStorage: () => (state) => {
|
||||
// Sync CSS variables with stored state after rehydration
|
||||
if (state && typeof window !== 'undefined') {
|
||||
|
||||
@@ -283,39 +283,6 @@ export const useVariablesStore = create<VariablesStore>()(
|
||||
})
|
||||
},
|
||||
|
||||
duplicateVariable: (id, providedId?: string) => {
|
||||
const state = get()
|
||||
if (!state.variables[id]) return ''
|
||||
|
||||
const variable = state.variables[id]
|
||||
const newId = providedId || crypto.randomUUID()
|
||||
|
||||
const workflowVariables = get().getVariablesByWorkflowId(variable.workflowId)
|
||||
const baseName = `${variable.name} (copy)`
|
||||
let uniqueName = baseName
|
||||
let nameIndex = 1
|
||||
|
||||
while (workflowVariables.some((v) => v.name === uniqueName)) {
|
||||
uniqueName = `${baseName} (${nameIndex})`
|
||||
nameIndex++
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
variables: {
|
||||
...state.variables,
|
||||
[newId]: {
|
||||
id: newId,
|
||||
workflowId: variable.workflowId,
|
||||
name: uniqueName,
|
||||
type: variable.type,
|
||||
value: variable.value,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
return newId
|
||||
},
|
||||
|
||||
getVariablesByWorkflowId: (workflowId) => {
|
||||
return Object.values(get().variables).filter((variable) => variable.workflowId === workflowId)
|
||||
},
|
||||
|
||||
@@ -43,12 +43,6 @@ export interface VariablesStore {
|
||||
|
||||
deleteVariable: (id: string) => void
|
||||
|
||||
/**
|
||||
* Duplicates a variable with a "(copy)" suffix, ensuring name uniqueness
|
||||
* Optionally accepts a predetermined ID for collaborative operations
|
||||
*/
|
||||
duplicateVariable: (id: string, providedId?: string) => string
|
||||
|
||||
/**
|
||||
* Returns all variables for a specific workflow
|
||||
*/
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
createAddBlockEntry,
|
||||
createAddEdgeEntry,
|
||||
createBlock,
|
||||
createDuplicateBlockEntry,
|
||||
createMockStorage,
|
||||
createMoveBlockEntry,
|
||||
createRemoveBlockEntry,
|
||||
@@ -23,7 +22,7 @@ import {
|
||||
} from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { runWithUndoRedoRecordingSuspended, useUndoRedoStore } from '@/stores/undo-redo/store'
|
||||
import type { DuplicateBlockOperation, UpdateParentOperation } from '@/stores/undo-redo/types'
|
||||
import type { UpdateParentOperation } from '@/stores/undo-redo/types'
|
||||
|
||||
describe('useUndoRedoStore', () => {
|
||||
const workflowId = 'wf-test'
|
||||
@@ -617,63 +616,6 @@ describe('useUndoRedoStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('duplicate-block operations', () => {
|
||||
it('should handle duplicate-block operations', () => {
|
||||
const { push, undo, redo, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
const sourceBlock = createBlock({ id: 'source-block' })
|
||||
const duplicatedBlock = createBlock({ id: 'duplicated-block' })
|
||||
|
||||
push(
|
||||
workflowId,
|
||||
userId,
|
||||
createDuplicateBlockEntry('source-block', 'duplicated-block', duplicatedBlock, {
|
||||
workflowId,
|
||||
userId,
|
||||
})
|
||||
)
|
||||
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
|
||||
|
||||
const entry = undo(workflowId, userId)
|
||||
expect(entry?.operation.type).toBe('duplicate-block')
|
||||
expect(entry?.inverse.type).toBe('remove-block')
|
||||
expect(getStackSizes(workflowId, userId).redoSize).toBe(1)
|
||||
|
||||
redo(workflowId, userId)
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
|
||||
})
|
||||
|
||||
it('should store the duplicated block snapshot correctly', () => {
|
||||
const { push, undo } = useUndoRedoStore.getState()
|
||||
|
||||
const duplicatedBlock = createBlock({
|
||||
id: 'duplicated-block',
|
||||
name: 'Duplicated Agent',
|
||||
type: 'agent',
|
||||
position: { x: 200, y: 200 },
|
||||
})
|
||||
|
||||
push(
|
||||
workflowId,
|
||||
userId,
|
||||
createDuplicateBlockEntry('source-block', 'duplicated-block', duplicatedBlock, {
|
||||
workflowId,
|
||||
userId,
|
||||
})
|
||||
)
|
||||
|
||||
const entry = undo(workflowId, userId)
|
||||
const operation = entry?.operation as DuplicateBlockOperation
|
||||
expect(operation.data.duplicatedBlockSnapshot).toMatchObject({
|
||||
id: 'duplicated-block',
|
||||
name: 'Duplicated Agent',
|
||||
type: 'agent',
|
||||
position: { x: 200, y: 200 },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('update-parent operations', () => {
|
||||
it('should handle update-parent operations', () => {
|
||||
const { push, undo, redo, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
@@ -3,10 +3,11 @@ import type { Edge } from 'reactflow'
|
||||
import { create } from 'zustand'
|
||||
import { createJSONStorage, persist } from 'zustand/middleware'
|
||||
import type {
|
||||
BatchAddBlocksOperation,
|
||||
BatchRemoveBlocksOperation,
|
||||
MoveBlockOperation,
|
||||
Operation,
|
||||
OperationEntry,
|
||||
RemoveBlockOperation,
|
||||
RemoveEdgeOperation,
|
||||
UndoRedoState,
|
||||
} from '@/stores/undo-redo/types'
|
||||
@@ -83,13 +84,13 @@ function isOperationApplicable(
|
||||
graph: { blocksById: Record<string, BlockState>; edgesById: Record<string, Edge> }
|
||||
): boolean {
|
||||
switch (operation.type) {
|
||||
case 'remove-block': {
|
||||
const op = operation as RemoveBlockOperation
|
||||
return Boolean(graph.blocksById[op.data.blockId])
|
||||
case 'batch-remove-blocks': {
|
||||
const op = operation as BatchRemoveBlocksOperation
|
||||
return op.data.blockSnapshots.every((block) => Boolean(graph.blocksById[block.id]))
|
||||
}
|
||||
case 'add-block': {
|
||||
const blockId = operation.data.blockId
|
||||
return !graph.blocksById[blockId]
|
||||
case 'batch-add-blocks': {
|
||||
const op = operation as BatchAddBlocksOperation
|
||||
return op.data.blockSnapshots.every((block) => !graph.blocksById[block.id])
|
||||
}
|
||||
case 'move-block': {
|
||||
const op = operation as MoveBlockOperation
|
||||
@@ -99,10 +100,6 @@ function isOperationApplicable(
|
||||
const blockId = operation.data.blockId
|
||||
return Boolean(graph.blocksById[blockId])
|
||||
}
|
||||
case 'duplicate-block': {
|
||||
const duplicatedId = operation.data.duplicatedBlockId
|
||||
return Boolean(graph.blocksById[duplicatedId])
|
||||
}
|
||||
case 'remove-edge': {
|
||||
const op = operation as RemoveEdgeOperation
|
||||
return Boolean(graph.edgesById[op.data.edgeId])
|
||||
|
||||
@@ -2,15 +2,14 @@ import type { Edge } from 'reactflow'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
export type OperationType =
|
||||
| 'add-block'
|
||||
| 'remove-block'
|
||||
| 'batch-add-blocks'
|
||||
| 'batch-remove-blocks'
|
||||
| 'add-edge'
|
||||
| 'remove-edge'
|
||||
| 'add-subflow'
|
||||
| 'remove-subflow'
|
||||
| 'move-block'
|
||||
| 'move-subflow'
|
||||
| 'duplicate-block'
|
||||
| 'update-parent'
|
||||
| 'apply-diff'
|
||||
| 'accept-diff'
|
||||
@@ -24,20 +23,21 @@ export interface BaseOperation {
|
||||
userId: string
|
||||
}
|
||||
|
||||
export interface AddBlockOperation extends BaseOperation {
|
||||
type: 'add-block'
|
||||
export interface BatchAddBlocksOperation extends BaseOperation {
|
||||
type: 'batch-add-blocks'
|
||||
data: {
|
||||
blockId: string
|
||||
blockSnapshots: BlockState[]
|
||||
edgeSnapshots: Edge[]
|
||||
subBlockValues: Record<string, Record<string, unknown>>
|
||||
}
|
||||
}
|
||||
|
||||
export interface RemoveBlockOperation extends BaseOperation {
|
||||
type: 'remove-block'
|
||||
export interface BatchRemoveBlocksOperation extends BaseOperation {
|
||||
type: 'batch-remove-blocks'
|
||||
data: {
|
||||
blockId: string
|
||||
blockSnapshot: BlockState | null
|
||||
edgeSnapshots?: Edge[]
|
||||
allBlockSnapshots?: Record<string, BlockState>
|
||||
blockSnapshots: BlockState[]
|
||||
edgeSnapshots: Edge[]
|
||||
subBlockValues: Record<string, Record<string, unknown>>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,16 +103,6 @@ export interface MoveSubflowOperation extends BaseOperation {
|
||||
}
|
||||
}
|
||||
|
||||
export interface DuplicateBlockOperation extends BaseOperation {
|
||||
type: 'duplicate-block'
|
||||
data: {
|
||||
sourceBlockId: string
|
||||
duplicatedBlockId: string
|
||||
duplicatedBlockSnapshot: BlockState
|
||||
autoConnectEdge?: Edge
|
||||
}
|
||||
}
|
||||
|
||||
export interface UpdateParentOperation extends BaseOperation {
|
||||
type: 'update-parent'
|
||||
data: {
|
||||
@@ -155,15 +145,14 @@ export interface RejectDiffOperation extends BaseOperation {
|
||||
}
|
||||
|
||||
export type Operation =
|
||||
| AddBlockOperation
|
||||
| RemoveBlockOperation
|
||||
| BatchAddBlocksOperation
|
||||
| BatchRemoveBlocksOperation
|
||||
| AddEdgeOperation
|
||||
| RemoveEdgeOperation
|
||||
| AddSubflowOperation
|
||||
| RemoveSubflowOperation
|
||||
| MoveBlockOperation
|
||||
| MoveSubflowOperation
|
||||
| DuplicateBlockOperation
|
||||
| UpdateParentOperation
|
||||
| ApplyDiffOperation
|
||||
| AcceptDiffOperation
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { Operation, OperationEntry } from '@/stores/undo-redo/types'
|
||||
import type {
|
||||
BatchAddBlocksOperation,
|
||||
BatchRemoveBlocksOperation,
|
||||
Operation,
|
||||
OperationEntry,
|
||||
} from '@/stores/undo-redo/types'
|
||||
|
||||
export function createOperationEntry(operation: Operation, inverse: Operation): OperationEntry {
|
||||
return {
|
||||
@@ -11,25 +16,31 @@ export function createOperationEntry(operation: Operation, inverse: Operation):
|
||||
|
||||
export function createInverseOperation(operation: Operation): Operation {
|
||||
switch (operation.type) {
|
||||
case 'add-block':
|
||||
case 'batch-add-blocks': {
|
||||
const op = operation as BatchAddBlocksOperation
|
||||
return {
|
||||
...operation,
|
||||
type: 'remove-block',
|
||||
type: 'batch-remove-blocks',
|
||||
data: {
|
||||
blockId: operation.data.blockId,
|
||||
blockSnapshot: null,
|
||||
edgeSnapshots: [],
|
||||
blockSnapshots: op.data.blockSnapshots,
|
||||
edgeSnapshots: op.data.edgeSnapshots,
|
||||
subBlockValues: op.data.subBlockValues,
|
||||
},
|
||||
}
|
||||
} as BatchRemoveBlocksOperation
|
||||
}
|
||||
|
||||
case 'remove-block':
|
||||
case 'batch-remove-blocks': {
|
||||
const op = operation as BatchRemoveBlocksOperation
|
||||
return {
|
||||
...operation,
|
||||
type: 'add-block',
|
||||
type: 'batch-add-blocks',
|
||||
data: {
|
||||
blockId: operation.data.blockId,
|
||||
blockSnapshots: op.data.blockSnapshots,
|
||||
edgeSnapshots: op.data.edgeSnapshots,
|
||||
subBlockValues: op.data.subBlockValues,
|
||||
},
|
||||
}
|
||||
} as BatchAddBlocksOperation
|
||||
}
|
||||
|
||||
case 'add-edge':
|
||||
return {
|
||||
@@ -89,17 +100,6 @@ export function createInverseOperation(operation: Operation): Operation {
|
||||
},
|
||||
}
|
||||
|
||||
case 'duplicate-block':
|
||||
return {
|
||||
...operation,
|
||||
type: 'remove-block',
|
||||
data: {
|
||||
blockId: operation.data.duplicatedBlockId,
|
||||
blockSnapshot: operation.data.duplicatedBlockSnapshot,
|
||||
edgeSnapshots: [],
|
||||
},
|
||||
}
|
||||
|
||||
case 'update-parent':
|
||||
return {
|
||||
...operation,
|
||||
@@ -147,7 +147,7 @@ export function createInverseOperation(operation: Operation): Operation {
|
||||
|
||||
default: {
|
||||
const exhaustiveCheck: never = operation
|
||||
throw new Error(`Unhandled operation type: ${(exhaustiveCheck as any).type}`)
|
||||
throw new Error(`Unhandled operation type: ${(exhaustiveCheck as Operation).type}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,22 +155,32 @@ export function createInverseOperation(operation: Operation): Operation {
|
||||
export function operationToCollaborativePayload(operation: Operation): {
|
||||
operation: string
|
||||
target: string
|
||||
payload: any
|
||||
payload: Record<string, unknown>
|
||||
} {
|
||||
switch (operation.type) {
|
||||
case 'add-block':
|
||||
case 'batch-add-blocks': {
|
||||
const op = operation as BatchAddBlocksOperation
|
||||
return {
|
||||
operation: 'add',
|
||||
target: 'block',
|
||||
payload: { id: operation.data.blockId },
|
||||
operation: 'batch-add-blocks',
|
||||
target: 'blocks',
|
||||
payload: {
|
||||
blocks: op.data.blockSnapshots,
|
||||
edges: op.data.edgeSnapshots,
|
||||
loops: {},
|
||||
parallels: {},
|
||||
subBlockValues: op.data.subBlockValues,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
case 'remove-block':
|
||||
case 'batch-remove-blocks': {
|
||||
const op = operation as BatchRemoveBlocksOperation
|
||||
return {
|
||||
operation: 'remove',
|
||||
target: 'block',
|
||||
payload: { id: operation.data.blockId },
|
||||
operation: 'batch-remove-blocks',
|
||||
target: 'blocks',
|
||||
payload: { ids: op.data.blockSnapshots.map((b) => b.id) },
|
||||
}
|
||||
}
|
||||
|
||||
case 'add-edge':
|
||||
return {
|
||||
@@ -223,16 +233,6 @@ export function operationToCollaborativePayload(operation: Operation): {
|
||||
},
|
||||
}
|
||||
|
||||
case 'duplicate-block':
|
||||
return {
|
||||
operation: 'duplicate',
|
||||
target: 'block',
|
||||
payload: {
|
||||
sourceId: operation.data.sourceBlockId,
|
||||
duplicatedId: operation.data.duplicatedBlockId,
|
||||
},
|
||||
}
|
||||
|
||||
case 'update-parent':
|
||||
return {
|
||||
operation: 'update-parent',
|
||||
@@ -274,7 +274,7 @@ export function operationToCollaborativePayload(operation: Operation): {
|
||||
|
||||
default: {
|
||||
const exhaustiveCheck: never = operation
|
||||
throw new Error(`Unhandled operation type: ${(exhaustiveCheck as any).type}`)
|
||||
throw new Error(`Unhandled operation type: ${(exhaustiveCheck as Operation).type}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,37 +381,6 @@ export const useVariablesStore = create<VariablesStore>()(
|
||||
})
|
||||
},
|
||||
|
||||
duplicateVariable: (id, providedId) => {
|
||||
const state = get()
|
||||
const existing = state.variables[id]
|
||||
if (!existing) return ''
|
||||
const newId = providedId || uuidv4()
|
||||
|
||||
const workflowVariables = state.getVariablesByWorkflowId(existing.workflowId)
|
||||
const baseName = `${existing.name} (copy)`
|
||||
let uniqueName = baseName
|
||||
let nameIndex = 1
|
||||
while (workflowVariables.some((v) => v.name === uniqueName)) {
|
||||
uniqueName = `${baseName} (${nameIndex})`
|
||||
nameIndex++
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
variables: {
|
||||
...state.variables,
|
||||
[newId]: {
|
||||
id: newId,
|
||||
workflowId: existing.workflowId,
|
||||
name: uniqueName,
|
||||
type: existing.type,
|
||||
value: existing.value,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
return newId
|
||||
},
|
||||
|
||||
getVariablesByWorkflowId: (workflowId) => {
|
||||
return Object.values(get().variables).filter((v) => v.workflowId === workflowId)
|
||||
},
|
||||
|
||||
@@ -57,6 +57,5 @@ export interface VariablesStore {
|
||||
addVariable: (variable: Omit<Variable, 'id'>, providedId?: string) => string
|
||||
updateVariable: (id: string, update: Partial<Omit<Variable, 'id' | 'workflowId'>>) => void
|
||||
deleteVariable: (id: string) => void
|
||||
duplicateVariable: (id: string, providedId?: string) => string
|
||||
getVariablesByWorkflowId: (workflowId: string) => Variable[]
|
||||
}
|
||||
|
||||
@@ -1,119 +1,9 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'
|
||||
import { regenerateWorkflowIds } from '@/stores/workflows/utils'
|
||||
import type { WorkflowState } from '../workflow/types'
|
||||
|
||||
const logger = createLogger('WorkflowJsonImporter')
|
||||
|
||||
/**
|
||||
* Generate new IDs for all blocks and edges to avoid conflicts
|
||||
*/
|
||||
function regenerateIds(workflowState: WorkflowState): WorkflowState {
|
||||
const { metadata, variables } = workflowState
|
||||
const blockIdMap = new Map<string, string>()
|
||||
const newBlocks: WorkflowState['blocks'] = {}
|
||||
|
||||
// First pass: create new IDs for all blocks
|
||||
Object.entries(workflowState.blocks).forEach(([oldId, block]) => {
|
||||
const newId = uuidv4()
|
||||
blockIdMap.set(oldId, newId)
|
||||
newBlocks[newId] = {
|
||||
...block,
|
||||
id: newId,
|
||||
}
|
||||
})
|
||||
|
||||
// Second pass: update edges with new block IDs
|
||||
const newEdges = workflowState.edges.map((edge) => ({
|
||||
...edge,
|
||||
id: uuidv4(), // Generate new edge ID
|
||||
source: blockIdMap.get(edge.source) || edge.source,
|
||||
target: blockIdMap.get(edge.target) || edge.target,
|
||||
}))
|
||||
|
||||
// Third pass: update loops with new block IDs
|
||||
// CRITICAL: Loop IDs must match their block IDs (loops are keyed by their block ID)
|
||||
const newLoops: WorkflowState['loops'] = {}
|
||||
if (workflowState.loops) {
|
||||
Object.entries(workflowState.loops).forEach(([oldLoopId, loop]) => {
|
||||
// Map the loop ID using the block ID mapping (loop ID = block ID)
|
||||
const newLoopId = blockIdMap.get(oldLoopId) || oldLoopId
|
||||
newLoops[newLoopId] = {
|
||||
...loop,
|
||||
id: newLoopId,
|
||||
nodes: loop.nodes.map((nodeId) => blockIdMap.get(nodeId) || nodeId),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Fourth pass: update parallels with new block IDs
|
||||
// CRITICAL: Parallel IDs must match their block IDs (parallels are keyed by their block ID)
|
||||
const newParallels: WorkflowState['parallels'] = {}
|
||||
if (workflowState.parallels) {
|
||||
Object.entries(workflowState.parallels).forEach(([oldParallelId, parallel]) => {
|
||||
// Map the parallel ID using the block ID mapping (parallel ID = block ID)
|
||||
const newParallelId = blockIdMap.get(oldParallelId) || oldParallelId
|
||||
newParallels[newParallelId] = {
|
||||
...parallel,
|
||||
id: newParallelId,
|
||||
nodes: parallel.nodes.map((nodeId) => blockIdMap.get(nodeId) || nodeId),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Fifth pass: update any block references in subblock values and clear runtime trigger values
|
||||
Object.entries(newBlocks).forEach(([blockId, block]) => {
|
||||
if (block.subBlocks) {
|
||||
Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]) => {
|
||||
if (TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(subBlockId)) {
|
||||
block.subBlocks[subBlockId] = {
|
||||
...subBlock,
|
||||
value: null,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (subBlock.value && typeof subBlock.value === 'string') {
|
||||
// Replace any block references in the value
|
||||
let updatedValue = subBlock.value
|
||||
blockIdMap.forEach((newId, oldId) => {
|
||||
// Replace references like <blockId.output> with new IDs
|
||||
const regex = new RegExp(`<${oldId}\\.`, 'g')
|
||||
updatedValue = updatedValue.replace(regex, `<${newId}.`)
|
||||
})
|
||||
block.subBlocks[subBlockId] = {
|
||||
...subBlock,
|
||||
value: updatedValue,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Update parentId references in block.data
|
||||
if (block.data?.parentId) {
|
||||
const newParentId = blockIdMap.get(block.data.parentId)
|
||||
if (newParentId) {
|
||||
block.data.parentId = newParentId
|
||||
} else {
|
||||
// Parent ID not in mapping - this shouldn't happen but log it
|
||||
logger.warn(`Block ${blockId} references unmapped parent ${block.data.parentId}`)
|
||||
// Remove invalid parent reference
|
||||
block.data.parentId = undefined
|
||||
block.data.extent = undefined
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
blocks: newBlocks,
|
||||
edges: newEdges,
|
||||
loops: newLoops,
|
||||
parallels: newParallels,
|
||||
metadata,
|
||||
variables,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize subblock values by converting empty strings to null.
|
||||
* This provides backwards compatibility for workflows exported before the null sanitization fix,
|
||||
@@ -260,9 +150,10 @@ export function parseWorkflowJson(
|
||||
variables: Array.isArray(workflowData.variables) ? workflowData.variables : undefined,
|
||||
}
|
||||
|
||||
// Regenerate IDs if requested (default: true)
|
||||
if (regenerateIdsFlag) {
|
||||
const regeneratedState = regenerateIds(workflowState)
|
||||
const { idMap: _, ...regeneratedState } = regenerateWorkflowIds(workflowState, {
|
||||
clearTriggerRuntimeValues: true,
|
||||
})
|
||||
workflowState = {
|
||||
...regeneratedState,
|
||||
metadata: workflowState.metadata,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { create } from 'zustand'
|
||||
import { devtools } from 'zustand/middleware'
|
||||
import { withOptimisticUpdate } from '@/lib/core/utils/optimistic-update'
|
||||
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants'
|
||||
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
|
||||
import { useVariablesStore } from '@/stores/panel/variables/store'
|
||||
import type {
|
||||
@@ -12,7 +13,9 @@ import type {
|
||||
} from '@/stores/workflows/registry/types'
|
||||
import { getNextWorkflowColor } from '@/stores/workflows/registry/utils'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { getUniqueBlockName, regenerateBlockIds } from '@/stores/workflows/utils'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('WorkflowRegistry')
|
||||
const initialHydration: HydrationState = {
|
||||
@@ -70,12 +73,12 @@ function setWorkspaceTransitioning(isTransitioning: boolean): void {
|
||||
export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
// Store state
|
||||
workflows: {},
|
||||
activeWorkflowId: null,
|
||||
error: null,
|
||||
deploymentStatuses: {},
|
||||
hydration: initialHydration,
|
||||
clipboard: null,
|
||||
|
||||
beginMetadataLoad: (workspaceId: string) => {
|
||||
set((state) => ({
|
||||
@@ -772,10 +775,104 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
deploymentStatuses: {},
|
||||
error: null,
|
||||
hydration: initialHydration,
|
||||
clipboard: null,
|
||||
})
|
||||
|
||||
logger.info('Logout complete - all workflow data cleared')
|
||||
},
|
||||
|
||||
copyBlocks: (blockIds: string[]) => {
|
||||
if (blockIds.length === 0) return
|
||||
|
||||
const workflowStore = useWorkflowStore.getState()
|
||||
const activeWorkflowId = get().activeWorkflowId
|
||||
const subBlockStore = useSubBlockStore.getState()
|
||||
|
||||
const copiedBlocks: Record<string, BlockState> = {}
|
||||
const copiedSubBlockValues: Record<string, Record<string, unknown>> = {}
|
||||
const blockIdSet = new Set(blockIds)
|
||||
|
||||
// Auto-include nested nodes from selected subflows
|
||||
blockIds.forEach((blockId) => {
|
||||
const loop = workflowStore.loops[blockId]
|
||||
if (loop?.nodes) loop.nodes.forEach((n) => blockIdSet.add(n))
|
||||
const parallel = workflowStore.parallels[blockId]
|
||||
if (parallel?.nodes) parallel.nodes.forEach((n) => blockIdSet.add(n))
|
||||
})
|
||||
|
||||
blockIdSet.forEach((blockId) => {
|
||||
const block = workflowStore.blocks[blockId]
|
||||
if (block) {
|
||||
copiedBlocks[blockId] = JSON.parse(JSON.stringify(block))
|
||||
if (activeWorkflowId) {
|
||||
const blockValues = subBlockStore.workflowValues[activeWorkflowId]?.[blockId]
|
||||
if (blockValues) {
|
||||
copiedSubBlockValues[blockId] = JSON.parse(JSON.stringify(blockValues))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const copiedEdges = workflowStore.edges.filter(
|
||||
(edge) => blockIdSet.has(edge.source) && blockIdSet.has(edge.target)
|
||||
)
|
||||
|
||||
const copiedLoops: Record<string, Loop> = {}
|
||||
Object.entries(workflowStore.loops).forEach(([loopId, loop]) => {
|
||||
if (blockIdSet.has(loopId)) {
|
||||
copiedLoops[loopId] = JSON.parse(JSON.stringify(loop))
|
||||
}
|
||||
})
|
||||
|
||||
const copiedParallels: Record<string, Parallel> = {}
|
||||
Object.entries(workflowStore.parallels).forEach(([parallelId, parallel]) => {
|
||||
if (blockIdSet.has(parallelId)) {
|
||||
copiedParallels[parallelId] = JSON.parse(JSON.stringify(parallel))
|
||||
}
|
||||
})
|
||||
|
||||
set({
|
||||
clipboard: {
|
||||
blocks: copiedBlocks,
|
||||
edges: copiedEdges,
|
||||
subBlockValues: copiedSubBlockValues,
|
||||
loops: copiedLoops,
|
||||
parallels: copiedParallels,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
})
|
||||
|
||||
logger.info('Copied blocks to clipboard', { count: Object.keys(copiedBlocks).length })
|
||||
},
|
||||
|
||||
preparePasteData: (positionOffset = DEFAULT_DUPLICATE_OFFSET) => {
|
||||
const { clipboard, activeWorkflowId } = get()
|
||||
if (!clipboard || Object.keys(clipboard.blocks).length === 0) return null
|
||||
if (!activeWorkflowId) return null
|
||||
|
||||
const workflowStore = useWorkflowStore.getState()
|
||||
const { blocks, edges, loops, parallels, subBlockValues } = regenerateBlockIds(
|
||||
clipboard.blocks,
|
||||
clipboard.edges,
|
||||
clipboard.loops,
|
||||
clipboard.parallels,
|
||||
clipboard.subBlockValues,
|
||||
positionOffset,
|
||||
workflowStore.blocks,
|
||||
getUniqueBlockName
|
||||
)
|
||||
|
||||
return { blocks, edges, loops, parallels, subBlockValues }
|
||||
},
|
||||
|
||||
hasClipboard: () => {
|
||||
const { clipboard } = get()
|
||||
return clipboard !== null && Object.keys(clipboard.blocks).length > 0
|
||||
},
|
||||
|
||||
clearClipboard: () => {
|
||||
set({ clipboard: null })
|
||||
},
|
||||
}),
|
||||
{ name: 'workflow-registry' }
|
||||
)
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { Edge } from 'reactflow'
|
||||
import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types'
|
||||
|
||||
export interface DeploymentStatus {
|
||||
isDeployed: boolean
|
||||
deployedAt?: Date
|
||||
@@ -5,6 +8,15 @@ export interface DeploymentStatus {
|
||||
needsRedeployment?: boolean
|
||||
}
|
||||
|
||||
export interface ClipboardData {
|
||||
blocks: Record<string, BlockState>
|
||||
edges: Edge[]
|
||||
subBlockValues: Record<string, Record<string, unknown>>
|
||||
loops: Record<string, Loop>
|
||||
parallels: Record<string, Parallel>
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface WorkflowMetadata {
|
||||
id: string
|
||||
name: string
|
||||
@@ -38,6 +50,7 @@ export interface WorkflowRegistryState {
|
||||
error: string | null
|
||||
deploymentStatuses: Record<string, DeploymentStatus>
|
||||
hydration: HydrationState
|
||||
clipboard: ClipboardData | null
|
||||
}
|
||||
|
||||
export interface WorkflowRegistryActions {
|
||||
@@ -58,6 +71,17 @@ export interface WorkflowRegistryActions {
|
||||
apiKey?: string
|
||||
) => void
|
||||
setWorkflowNeedsRedeployment: (workflowId: string | null, needsRedeployment: boolean) => void
|
||||
copyBlocks: (blockIds: string[]) => void
|
||||
preparePasteData: (positionOffset?: { x: number; y: number }) => {
|
||||
blocks: Record<string, BlockState>
|
||||
edges: Edge[]
|
||||
loops: Record<string, Loop>
|
||||
parallels: Record<string, Parallel>
|
||||
subBlockValues: Record<string, Record<string, unknown>>
|
||||
} | null
|
||||
hasClipboard: () => boolean
|
||||
clearClipboard: () => void
|
||||
logout: () => void
|
||||
}
|
||||
|
||||
export type WorkflowRegistry = WorkflowRegistryState & WorkflowRegistryActions
|
||||
|
||||
@@ -1,9 +1,31 @@
|
||||
import type { Edge } from 'reactflow'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { normalizeName } from '@/executor/constants'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import type { BlockState, SubBlockState } from '@/stores/workflows/workflow/types'
|
||||
import type {
|
||||
BlockState,
|
||||
Loop,
|
||||
Parallel,
|
||||
Position,
|
||||
SubBlockState,
|
||||
WorkflowState,
|
||||
} from '@/stores/workflows/workflow/types'
|
||||
import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'
|
||||
|
||||
const WEBHOOK_SUBBLOCK_FIELDS = ['webhookId', 'triggerPath']
|
||||
|
||||
export { normalizeName }
|
||||
|
||||
export interface RegeneratedState {
|
||||
blocks: Record<string, BlockState>
|
||||
edges: Edge[]
|
||||
loops: Record<string, Loop>
|
||||
parallels: Record<string, Parallel>
|
||||
idMap: Map<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique block name by finding the highest number suffix among existing blocks
|
||||
* with the same base name and incrementing it
|
||||
@@ -44,6 +66,166 @@ export function getUniqueBlockName(baseName: string, existingBlocks: Record<stri
|
||||
return `${namePrefix} ${maxNumber + 1}`
|
||||
}
|
||||
|
||||
export interface PrepareBlockStateOptions {
|
||||
id: string
|
||||
type: string
|
||||
name: string
|
||||
position: Position
|
||||
data?: Record<string, unknown>
|
||||
parentId?: string
|
||||
extent?: 'parent'
|
||||
triggerMode?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares a BlockState object from block type and configuration.
|
||||
* Generates subBlocks and outputs from the block registry.
|
||||
*/
|
||||
export function prepareBlockState(options: PrepareBlockStateOptions): BlockState {
|
||||
const { id, type, name, position, data, parentId, extent, triggerMode = false } = options
|
||||
|
||||
const blockConfig = getBlock(type)
|
||||
|
||||
const blockData: Record<string, unknown> = { ...(data || {}) }
|
||||
if (parentId) blockData.parentId = parentId
|
||||
if (extent) blockData.extent = extent
|
||||
|
||||
if (!blockConfig) {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
position,
|
||||
data: blockData,
|
||||
subBlocks: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
horizontalHandles: true,
|
||||
advancedMode: false,
|
||||
triggerMode,
|
||||
height: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const subBlocks: Record<string, SubBlockState> = {}
|
||||
|
||||
if (blockConfig.subBlocks) {
|
||||
blockConfig.subBlocks.forEach((subBlock) => {
|
||||
let initialValue: unknown = null
|
||||
|
||||
if (typeof subBlock.value === 'function') {
|
||||
try {
|
||||
initialValue = subBlock.value({})
|
||||
} catch {
|
||||
initialValue = null
|
||||
}
|
||||
} else if (subBlock.defaultValue !== undefined) {
|
||||
initialValue = subBlock.defaultValue
|
||||
} else if (subBlock.type === 'input-format') {
|
||||
initialValue = [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
name: '',
|
||||
type: 'string',
|
||||
value: '',
|
||||
collapsed: false,
|
||||
},
|
||||
]
|
||||
} else if (subBlock.type === 'table') {
|
||||
initialValue = []
|
||||
}
|
||||
|
||||
subBlocks[subBlock.id] = {
|
||||
id: subBlock.id,
|
||||
type: subBlock.type,
|
||||
value: initialValue as SubBlockState['value'],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const outputs = getBlockOutputs(type, subBlocks, triggerMode)
|
||||
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
position,
|
||||
data: blockData,
|
||||
subBlocks,
|
||||
outputs,
|
||||
enabled: true,
|
||||
horizontalHandles: true,
|
||||
advancedMode: false,
|
||||
triggerMode,
|
||||
height: 0,
|
||||
}
|
||||
}
|
||||
|
||||
export interface PrepareDuplicateBlockStateOptions {
|
||||
sourceBlock: BlockState
|
||||
newId: string
|
||||
newName: string
|
||||
positionOffset: { x: number; y: number }
|
||||
subBlockValues: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares a BlockState for duplicating an existing block.
|
||||
* Copies block structure and subblock values, excluding webhook fields.
|
||||
*/
|
||||
export function prepareDuplicateBlockState(options: PrepareDuplicateBlockStateOptions): {
|
||||
block: BlockState
|
||||
subBlockValues: Record<string, unknown>
|
||||
} {
|
||||
const { sourceBlock, newId, newName, positionOffset, subBlockValues } = options
|
||||
|
||||
const filteredSubBlockValues = Object.fromEntries(
|
||||
Object.entries(subBlockValues).filter(([key]) => !WEBHOOK_SUBBLOCK_FIELDS.includes(key))
|
||||
)
|
||||
|
||||
const mergedSubBlocks: Record<string, SubBlockState> = sourceBlock.subBlocks
|
||||
? JSON.parse(JSON.stringify(sourceBlock.subBlocks))
|
||||
: {}
|
||||
|
||||
WEBHOOK_SUBBLOCK_FIELDS.forEach((field) => {
|
||||
if (field in mergedSubBlocks) {
|
||||
delete mergedSubBlocks[field]
|
||||
}
|
||||
})
|
||||
|
||||
Object.entries(filteredSubBlockValues).forEach(([subblockId, value]) => {
|
||||
if (mergedSubBlocks[subblockId]) {
|
||||
mergedSubBlocks[subblockId].value = value as SubBlockState['value']
|
||||
} else {
|
||||
mergedSubBlocks[subblockId] = {
|
||||
id: subblockId,
|
||||
type: 'short-input',
|
||||
value: value as SubBlockState['value'],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const block: BlockState = {
|
||||
id: newId,
|
||||
type: sourceBlock.type,
|
||||
name: newName,
|
||||
position: {
|
||||
x: sourceBlock.position.x + positionOffset.x,
|
||||
y: sourceBlock.position.y + positionOffset.y,
|
||||
},
|
||||
data: sourceBlock.data ? JSON.parse(JSON.stringify(sourceBlock.data)) : {},
|
||||
subBlocks: mergedSubBlocks,
|
||||
outputs: sourceBlock.outputs ? JSON.parse(JSON.stringify(sourceBlock.outputs)) : {},
|
||||
enabled: sourceBlock.enabled ?? true,
|
||||
horizontalHandles: sourceBlock.horizontalHandles ?? true,
|
||||
advancedMode: sourceBlock.advancedMode ?? false,
|
||||
triggerMode: sourceBlock.triggerMode ?? false,
|
||||
height: sourceBlock.height || 0,
|
||||
}
|
||||
|
||||
return { block, subBlockValues: filteredSubBlockValues }
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges workflow block states with subblock values while maintaining block structure
|
||||
* @param blocks - Block configurations from workflow store
|
||||
@@ -211,6 +393,217 @@ export async function mergeSubblockStateAsync(
|
||||
})
|
||||
)
|
||||
|
||||
// Convert entries back to an object
|
||||
return Object.fromEntries(processedBlockEntries) as Record<string, BlockState>
|
||||
}
|
||||
|
||||
function updateValueReferences(value: unknown, nameMap: Map<string, string>): unknown {
|
||||
if (typeof value === 'string') {
|
||||
let updatedValue = value
|
||||
nameMap.forEach((newName, oldName) => {
|
||||
const regex = new RegExp(`<${oldName}\\.`, 'g')
|
||||
updatedValue = updatedValue.replace(regex, `<${newName}.`)
|
||||
})
|
||||
return updatedValue
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => updateValueReferences(item, nameMap))
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
result[key] = updateValueReferences(val, nameMap)
|
||||
}
|
||||
return result
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function updateBlockReferences(
|
||||
blocks: Record<string, BlockState>,
|
||||
idMap: Map<string, string>,
|
||||
nameMap: Map<string, string>,
|
||||
clearTriggerRuntimeValues = false
|
||||
): void {
|
||||
Object.entries(blocks).forEach(([_, block]) => {
|
||||
if (block.data?.parentId) {
|
||||
const newParentId = idMap.get(block.data.parentId)
|
||||
if (newParentId) {
|
||||
block.data = { ...block.data, parentId: newParentId }
|
||||
} else {
|
||||
block.data = { ...block.data, parentId: undefined, extent: undefined }
|
||||
}
|
||||
}
|
||||
|
||||
if (block.subBlocks) {
|
||||
Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]) => {
|
||||
if (clearTriggerRuntimeValues && TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(subBlockId)) {
|
||||
block.subBlocks[subBlockId] = { ...subBlock, value: null }
|
||||
return
|
||||
}
|
||||
|
||||
if (subBlock.value !== undefined && subBlock.value !== null) {
|
||||
const updatedValue = updateValueReferences(
|
||||
subBlock.value,
|
||||
nameMap
|
||||
) as SubBlockState['value']
|
||||
block.subBlocks[subBlockId] = { ...subBlock, value: updatedValue }
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function regenerateWorkflowIds(
|
||||
workflowState: WorkflowState,
|
||||
options: { clearTriggerRuntimeValues?: boolean } = {}
|
||||
): WorkflowState & { idMap: Map<string, string> } {
|
||||
const { clearTriggerRuntimeValues = true } = options
|
||||
const blockIdMap = new Map<string, string>()
|
||||
const nameMap = new Map<string, string>()
|
||||
const newBlocks: Record<string, BlockState> = {}
|
||||
|
||||
Object.entries(workflowState.blocks).forEach(([oldId, block]) => {
|
||||
const newId = uuidv4()
|
||||
blockIdMap.set(oldId, newId)
|
||||
const oldNormalizedName = normalizeName(block.name)
|
||||
nameMap.set(oldNormalizedName, oldNormalizedName)
|
||||
newBlocks[newId] = { ...block, id: newId }
|
||||
})
|
||||
|
||||
const newEdges = workflowState.edges.map((edge) => ({
|
||||
...edge,
|
||||
id: uuidv4(),
|
||||
source: blockIdMap.get(edge.source) || edge.source,
|
||||
target: blockIdMap.get(edge.target) || edge.target,
|
||||
}))
|
||||
|
||||
const newLoops: Record<string, Loop> = {}
|
||||
if (workflowState.loops) {
|
||||
Object.entries(workflowState.loops).forEach(([oldLoopId, loop]) => {
|
||||
const newLoopId = blockIdMap.get(oldLoopId) || oldLoopId
|
||||
newLoops[newLoopId] = {
|
||||
...loop,
|
||||
id: newLoopId,
|
||||
nodes: loop.nodes.map((nodeId) => blockIdMap.get(nodeId) || nodeId),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const newParallels: Record<string, Parallel> = {}
|
||||
if (workflowState.parallels) {
|
||||
Object.entries(workflowState.parallels).forEach(([oldParallelId, parallel]) => {
|
||||
const newParallelId = blockIdMap.get(oldParallelId) || oldParallelId
|
||||
newParallels[newParallelId] = {
|
||||
...parallel,
|
||||
id: newParallelId,
|
||||
nodes: parallel.nodes.map((nodeId) => blockIdMap.get(nodeId) || nodeId),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
updateBlockReferences(newBlocks, blockIdMap, nameMap, clearTriggerRuntimeValues)
|
||||
|
||||
return {
|
||||
blocks: newBlocks,
|
||||
edges: newEdges,
|
||||
loops: newLoops,
|
||||
parallels: newParallels,
|
||||
metadata: workflowState.metadata,
|
||||
variables: workflowState.variables,
|
||||
idMap: blockIdMap,
|
||||
}
|
||||
}
|
||||
|
||||
export function regenerateBlockIds(
|
||||
blocks: Record<string, BlockState>,
|
||||
edges: Edge[],
|
||||
loops: Record<string, Loop>,
|
||||
parallels: Record<string, Parallel>,
|
||||
subBlockValues: Record<string, Record<string, unknown>>,
|
||||
positionOffset: { x: number; y: number },
|
||||
existingBlockNames: Record<string, BlockState>,
|
||||
uniqueNameFn: (name: string, blocks: Record<string, BlockState>) => string
|
||||
): RegeneratedState & { subBlockValues: Record<string, Record<string, unknown>> } {
|
||||
const blockIdMap = new Map<string, string>()
|
||||
const nameMap = new Map<string, string>()
|
||||
const newBlocks: Record<string, BlockState> = {}
|
||||
const newSubBlockValues: Record<string, Record<string, unknown>> = {}
|
||||
|
||||
// Track all blocks for name uniqueness (existing + newly processed)
|
||||
const allBlocksForNaming = { ...existingBlockNames }
|
||||
|
||||
Object.entries(blocks).forEach(([oldId, block]) => {
|
||||
const newId = uuidv4()
|
||||
blockIdMap.set(oldId, newId)
|
||||
|
||||
const oldNormalizedName = normalizeName(block.name)
|
||||
const newName = uniqueNameFn(block.name, allBlocksForNaming)
|
||||
const newNormalizedName = normalizeName(newName)
|
||||
nameMap.set(oldNormalizedName, newNormalizedName)
|
||||
|
||||
const isNested = !!block.data?.parentId
|
||||
const newBlock: BlockState = {
|
||||
...block,
|
||||
id: newId,
|
||||
name: newName,
|
||||
position: isNested
|
||||
? block.position
|
||||
: {
|
||||
x: block.position.x + positionOffset.x,
|
||||
y: block.position.y + positionOffset.y,
|
||||
},
|
||||
}
|
||||
|
||||
newBlocks[newId] = newBlock
|
||||
// Add to tracking so next block gets unique name
|
||||
allBlocksForNaming[newId] = newBlock
|
||||
|
||||
if (subBlockValues[oldId]) {
|
||||
newSubBlockValues[newId] = JSON.parse(JSON.stringify(subBlockValues[oldId]))
|
||||
}
|
||||
})
|
||||
|
||||
const newEdges = edges.map((edge) => ({
|
||||
...edge,
|
||||
id: uuidv4(),
|
||||
source: blockIdMap.get(edge.source) || edge.source,
|
||||
target: blockIdMap.get(edge.target) || edge.target,
|
||||
}))
|
||||
|
||||
const newLoops: Record<string, Loop> = {}
|
||||
Object.entries(loops).forEach(([oldLoopId, loop]) => {
|
||||
const newLoopId = blockIdMap.get(oldLoopId) || oldLoopId
|
||||
newLoops[newLoopId] = {
|
||||
...loop,
|
||||
id: newLoopId,
|
||||
nodes: loop.nodes.map((nodeId) => blockIdMap.get(nodeId) || nodeId),
|
||||
}
|
||||
})
|
||||
|
||||
const newParallels: Record<string, Parallel> = {}
|
||||
Object.entries(parallels).forEach(([oldParallelId, parallel]) => {
|
||||
const newParallelId = blockIdMap.get(oldParallelId) || oldParallelId
|
||||
newParallels[newParallelId] = {
|
||||
...parallel,
|
||||
id: newParallelId,
|
||||
nodes: parallel.nodes.map((nodeId) => blockIdMap.get(nodeId) || nodeId),
|
||||
}
|
||||
})
|
||||
|
||||
updateBlockReferences(newBlocks, blockIdMap, nameMap, false)
|
||||
|
||||
Object.entries(newSubBlockValues).forEach(([_, blockValues]) => {
|
||||
Object.keys(blockValues).forEach((subBlockId) => {
|
||||
blockValues[subBlockId] = updateValueReferences(blockValues[subBlockId], nameMap)
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
blocks: newBlocks,
|
||||
edges: newEdges,
|
||||
loops: newLoops,
|
||||
parallels: newParallels,
|
||||
subBlockValues: newSubBlockValues,
|
||||
idMap: blockIdMap,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,7 @@ function handleBodySizeLimitError(error: unknown, requestId: string, context: st
|
||||
*/
|
||||
const MCP_SYSTEM_PARAMETERS = new Set([
|
||||
'serverId',
|
||||
'serverUrl',
|
||||
'toolName',
|
||||
'serverName',
|
||||
'_context',
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import type {
|
||||
JsmAddOrganizationToServiceDeskParams,
|
||||
JsmAddOrganizationToServiceDeskResponse,
|
||||
} from '@/tools/jsm/types'
|
||||
import type { JsmAddOrganizationParams, JsmAddOrganizationResponse } from '@/tools/jsm/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const jsmAddOrganizationToServiceDeskTool: ToolConfig<
|
||||
JsmAddOrganizationToServiceDeskParams,
|
||||
JsmAddOrganizationToServiceDeskResponse
|
||||
export const jsmAddOrganizationTool: ToolConfig<
|
||||
JsmAddOrganizationParams,
|
||||
JsmAddOrganizationResponse
|
||||
> = {
|
||||
id: 'jsm_add_organization_to_service_desk',
|
||||
name: 'JSM Add Organization to Service Desk',
|
||||
id: 'jsm_add_organization',
|
||||
name: 'JSM Add Organization',
|
||||
description: 'Add an organization to a service desk in Jira Service Management',
|
||||
version: '1.0.0',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { jsmAddCommentTool } from '@/tools/jsm/add_comment'
|
||||
import { jsmAddCustomerTool } from '@/tools/jsm/add_customer'
|
||||
import { jsmAddOrganizationToServiceDeskTool } from '@/tools/jsm/add_organization_to_service_desk'
|
||||
import { jsmAddOrganizationTool } from '@/tools/jsm/add_organization'
|
||||
import { jsmAddParticipantsTool } from '@/tools/jsm/add_participants'
|
||||
import { jsmAnswerApprovalTool } from '@/tools/jsm/answer_approval'
|
||||
import { jsmCreateOrganizationTool } from '@/tools/jsm/create_organization'
|
||||
@@ -22,7 +22,7 @@ import { jsmTransitionRequestTool } from '@/tools/jsm/transition_request'
|
||||
export {
|
||||
jsmAddCommentTool,
|
||||
jsmAddCustomerTool,
|
||||
jsmAddOrganizationToServiceDeskTool,
|
||||
jsmAddOrganizationTool,
|
||||
jsmAddParticipantsTool,
|
||||
jsmAnswerApprovalTool,
|
||||
jsmCreateOrganizationTool,
|
||||
|
||||
@@ -345,12 +345,12 @@ export interface JsmCreateOrganizationResponse extends ToolResponse {
|
||||
}
|
||||
}
|
||||
|
||||
export interface JsmAddOrganizationToServiceDeskParams extends JsmBaseParams {
|
||||
export interface JsmAddOrganizationParams extends JsmBaseParams {
|
||||
serviceDeskId: string
|
||||
organizationId: string
|
||||
}
|
||||
|
||||
export interface JsmAddOrganizationToServiceDeskResponse extends ToolResponse {
|
||||
export interface JsmAddOrganizationResponse extends ToolResponse {
|
||||
output: {
|
||||
ts: string
|
||||
serviceDeskId: string
|
||||
@@ -462,7 +462,7 @@ export type JsmResponse =
|
||||
| JsmTransitionRequestResponse
|
||||
| JsmGetTransitionsResponse
|
||||
| JsmCreateOrganizationResponse
|
||||
| JsmAddOrganizationToServiceDeskResponse
|
||||
| JsmAddOrganizationResponse
|
||||
| JsmGetParticipantsResponse
|
||||
| JsmAddParticipantsResponse
|
||||
| JsmGetApprovalsResponse
|
||||
|
||||
@@ -494,7 +494,7 @@ import {
|
||||
import {
|
||||
jsmAddCommentTool,
|
||||
jsmAddCustomerTool,
|
||||
jsmAddOrganizationToServiceDeskTool,
|
||||
jsmAddOrganizationTool,
|
||||
jsmAddParticipantsTool,
|
||||
jsmAnswerApprovalTool,
|
||||
jsmCreateOrganizationTool,
|
||||
@@ -1531,7 +1531,7 @@ export const tools: Record<string, ToolConfig> = {
|
||||
jsm_add_customer: jsmAddCustomerTool,
|
||||
jsm_get_organizations: jsmGetOrganizationsTool,
|
||||
jsm_create_organization: jsmCreateOrganizationTool,
|
||||
jsm_add_organization_to_service_desk: jsmAddOrganizationToServiceDeskTool,
|
||||
jsm_add_organization: jsmAddOrganizationTool,
|
||||
jsm_get_queues: jsmGetQueuesTool,
|
||||
jsm_get_sla: jsmGetSlaTool,
|
||||
jsm_get_transitions: jsmGetTransitionsTool,
|
||||
|
||||
@@ -120,22 +120,20 @@ export {
|
||||
} from './serialized-block.factory'
|
||||
// Undo/redo operation factories
|
||||
export {
|
||||
type AddBlockOperation,
|
||||
type AddEdgeOperation,
|
||||
type BaseOperation,
|
||||
type BatchAddBlocksOperation,
|
||||
type BatchRemoveBlocksOperation,
|
||||
createAddBlockEntry,
|
||||
createAddEdgeEntry,
|
||||
createDuplicateBlockEntry,
|
||||
createMoveBlockEntry,
|
||||
createRemoveBlockEntry,
|
||||
createRemoveEdgeEntry,
|
||||
createUpdateParentEntry,
|
||||
type DuplicateBlockOperation,
|
||||
type MoveBlockOperation,
|
||||
type Operation,
|
||||
type OperationEntry,
|
||||
type OperationType,
|
||||
type RemoveBlockOperation,
|
||||
type RemoveEdgeOperation,
|
||||
type UpdateParentOperation,
|
||||
} from './undo-redo.factory'
|
||||
|
||||
@@ -257,6 +257,8 @@ export function createWorkflowAccessContext(options: {
|
||||
export const SOCKET_OPERATIONS = [
|
||||
'add',
|
||||
'remove',
|
||||
'batch-add-blocks',
|
||||
'batch-remove-blocks',
|
||||
'update',
|
||||
'update-position',
|
||||
'update-name',
|
||||
@@ -266,7 +268,7 @@ export const SOCKET_OPERATIONS = [
|
||||
'update-advanced-mode',
|
||||
'update-trigger-mode',
|
||||
'toggle-handles',
|
||||
'duplicate',
|
||||
'batch-update-positions',
|
||||
'replace-state',
|
||||
] as const
|
||||
|
||||
@@ -278,7 +280,7 @@ export type SocketOperation = (typeof SOCKET_OPERATIONS)[number]
|
||||
export const ROLE_ALLOWED_OPERATIONS: Record<PermissionType, SocketOperation[]> = {
|
||||
admin: [...SOCKET_OPERATIONS],
|
||||
write: [...SOCKET_OPERATIONS],
|
||||
read: ['update-position'],
|
||||
read: ['update-position', 'batch-update-positions'],
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,12 +6,11 @@ import { nanoid } from 'nanoid'
|
||||
* Operation types supported by the undo/redo store.
|
||||
*/
|
||||
export type OperationType =
|
||||
| 'add-block'
|
||||
| 'remove-block'
|
||||
| 'batch-add-blocks'
|
||||
| 'batch-remove-blocks'
|
||||
| 'add-edge'
|
||||
| 'remove-edge'
|
||||
| 'move-block'
|
||||
| 'duplicate-block'
|
||||
| 'update-parent'
|
||||
|
||||
/**
|
||||
@@ -38,22 +37,26 @@ export interface MoveBlockOperation extends BaseOperation {
|
||||
}
|
||||
|
||||
/**
|
||||
* Add block operation data.
|
||||
* Batch add blocks operation data.
|
||||
*/
|
||||
export interface AddBlockOperation extends BaseOperation {
|
||||
type: 'add-block'
|
||||
data: { blockId: string }
|
||||
export interface BatchAddBlocksOperation extends BaseOperation {
|
||||
type: 'batch-add-blocks'
|
||||
data: {
|
||||
blockSnapshots: any[]
|
||||
edgeSnapshots: any[]
|
||||
subBlockValues: Record<string, Record<string, any>>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove block operation data.
|
||||
* Batch remove blocks operation data.
|
||||
*/
|
||||
export interface RemoveBlockOperation extends BaseOperation {
|
||||
type: 'remove-block'
|
||||
export interface BatchRemoveBlocksOperation extends BaseOperation {
|
||||
type: 'batch-remove-blocks'
|
||||
data: {
|
||||
blockId: string
|
||||
blockSnapshot: any
|
||||
edgeSnapshots?: any[]
|
||||
blockSnapshots: any[]
|
||||
edgeSnapshots: any[]
|
||||
subBlockValues: Record<string, Record<string, any>>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,18 +76,6 @@ export interface RemoveEdgeOperation extends BaseOperation {
|
||||
data: { edgeId: string; edgeSnapshot: any }
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate block operation data.
|
||||
*/
|
||||
export interface DuplicateBlockOperation extends BaseOperation {
|
||||
type: 'duplicate-block'
|
||||
data: {
|
||||
sourceBlockId: string
|
||||
duplicatedBlockId: string
|
||||
duplicatedBlockSnapshot: any
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update parent operation data.
|
||||
*/
|
||||
@@ -100,12 +91,11 @@ export interface UpdateParentOperation extends BaseOperation {
|
||||
}
|
||||
|
||||
export type Operation =
|
||||
| AddBlockOperation
|
||||
| RemoveBlockOperation
|
||||
| BatchAddBlocksOperation
|
||||
| BatchRemoveBlocksOperation
|
||||
| AddEdgeOperation
|
||||
| RemoveEdgeOperation
|
||||
| MoveBlockOperation
|
||||
| DuplicateBlockOperation
|
||||
| UpdateParentOperation
|
||||
|
||||
/**
|
||||
@@ -126,36 +116,51 @@ interface OperationEntryOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock add-block operation entry.
|
||||
* Creates a mock batch-add-blocks operation entry.
|
||||
*/
|
||||
export function createAddBlockEntry(blockId: string, options: OperationEntryOptions = {}): any {
|
||||
const { id = nanoid(8), workflowId = 'wf-1', userId = 'user-1', createdAt = Date.now() } = options
|
||||
const timestamp = Date.now()
|
||||
|
||||
const mockBlockSnapshot = {
|
||||
id: blockId,
|
||||
type: 'action',
|
||||
name: `Block ${blockId}`,
|
||||
position: { x: 0, y: 0 },
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
createdAt,
|
||||
operation: {
|
||||
id: nanoid(8),
|
||||
type: 'add-block',
|
||||
type: 'batch-add-blocks',
|
||||
timestamp,
|
||||
workflowId,
|
||||
userId,
|
||||
data: { blockId },
|
||||
data: {
|
||||
blockSnapshots: [mockBlockSnapshot],
|
||||
edgeSnapshots: [],
|
||||
subBlockValues: {},
|
||||
},
|
||||
},
|
||||
inverse: {
|
||||
id: nanoid(8),
|
||||
type: 'remove-block',
|
||||
type: 'batch-remove-blocks',
|
||||
timestamp,
|
||||
workflowId,
|
||||
userId,
|
||||
data: { blockId, blockSnapshot: null },
|
||||
data: {
|
||||
blockSnapshots: [mockBlockSnapshot],
|
||||
edgeSnapshots: [],
|
||||
subBlockValues: {},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock remove-block operation entry.
|
||||
* Creates a mock batch-remove-blocks operation entry.
|
||||
*/
|
||||
export function createRemoveBlockEntry(
|
||||
blockId: string,
|
||||
@@ -165,24 +170,39 @@ export function createRemoveBlockEntry(
|
||||
const { id = nanoid(8), workflowId = 'wf-1', userId = 'user-1', createdAt = Date.now() } = options
|
||||
const timestamp = Date.now()
|
||||
|
||||
const snapshotToUse = blockSnapshot || {
|
||||
id: blockId,
|
||||
type: 'action',
|
||||
name: `Block ${blockId}`,
|
||||
position: { x: 0, y: 0 },
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
createdAt,
|
||||
operation: {
|
||||
id: nanoid(8),
|
||||
type: 'remove-block',
|
||||
type: 'batch-remove-blocks',
|
||||
timestamp,
|
||||
workflowId,
|
||||
userId,
|
||||
data: { blockId, blockSnapshot },
|
||||
data: {
|
||||
blockSnapshots: [snapshotToUse],
|
||||
edgeSnapshots: [],
|
||||
subBlockValues: {},
|
||||
},
|
||||
},
|
||||
inverse: {
|
||||
id: nanoid(8),
|
||||
type: 'add-block',
|
||||
type: 'batch-add-blocks',
|
||||
timestamp,
|
||||
workflowId,
|
||||
userId,
|
||||
data: { blockId },
|
||||
data: {
|
||||
blockSnapshots: [snapshotToUse],
|
||||
edgeSnapshots: [],
|
||||
subBlockValues: {},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -290,40 +310,6 @@ export function createMoveBlockEntry(blockId: string, options: MoveBlockOptions
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock duplicate-block operation entry.
|
||||
*/
|
||||
export function createDuplicateBlockEntry(
|
||||
sourceBlockId: string,
|
||||
duplicatedBlockId: string,
|
||||
duplicatedBlockSnapshot: any,
|
||||
options: OperationEntryOptions = {}
|
||||
): any {
|
||||
const { id = nanoid(8), workflowId = 'wf-1', userId = 'user-1', createdAt = Date.now() } = options
|
||||
const timestamp = Date.now()
|
||||
|
||||
return {
|
||||
id,
|
||||
createdAt,
|
||||
operation: {
|
||||
id: nanoid(8),
|
||||
type: 'duplicate-block',
|
||||
timestamp,
|
||||
workflowId,
|
||||
userId,
|
||||
data: { sourceBlockId, duplicatedBlockId, duplicatedBlockSnapshot },
|
||||
},
|
||||
inverse: {
|
||||
id: nanoid(8),
|
||||
type: 'remove-block',
|
||||
timestamp,
|
||||
workflowId,
|
||||
userId,
|
||||
data: { blockId: duplicatedBlockId, blockSnapshot: duplicatedBlockSnapshot },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock update-parent operation entry.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user