Merge remote-tracking branch 'origin/main' into test/workflow-part-8

This commit is contained in:
CodingOnStar 2026-03-24 17:54:37 +08:00
commit 16e8bf1cf9
117 changed files with 10379 additions and 2700 deletions

View File

@ -95,7 +95,7 @@ class CreateAppPayload(BaseModel):
name: str = Field(..., min_length=1, description="App name")
description: str | None = Field(default=None, description="App description (max 400 chars)", max_length=400)
mode: Literal["chat", "agent-chat", "advanced-chat", "workflow", "completion"] = Field(..., description="App mode")
icon_type: str | None = Field(default=None, description="Icon type")
icon_type: IconType | None = Field(default=None, description="Icon type")
icon: str | None = Field(default=None, description="Icon")
icon_background: str | None = Field(default=None, description="Icon background color")
@ -103,7 +103,7 @@ class CreateAppPayload(BaseModel):
class UpdateAppPayload(BaseModel):
name: str = Field(..., min_length=1, description="App name")
description: str | None = Field(default=None, description="App description (max 400 chars)", max_length=400)
icon_type: str | None = Field(default=None, description="Icon type")
icon_type: IconType | None = Field(default=None, description="Icon type")
icon: str | None = Field(default=None, description="Icon")
icon_background: str | None = Field(default=None, description="Icon background color")
use_icon_as_answer_icon: bool | None = Field(default=None, description="Use icon as answer icon")
@ -113,7 +113,7 @@ class UpdateAppPayload(BaseModel):
class CopyAppPayload(BaseModel):
name: str | None = Field(default=None, description="Name for the copied app")
description: str | None = Field(default=None, description="Description for the copied app", max_length=400)
icon_type: str | None = Field(default=None, description="Icon type")
icon_type: IconType | None = Field(default=None, description="Icon type")
icon: str | None = Field(default=None, description="Icon")
icon_background: str | None = Field(default=None, description="Icon background color")
@ -594,7 +594,7 @@ class AppApi(Resource):
args_dict: AppService.ArgsDict = {
"name": args.name,
"description": args.description or "",
"icon_type": args.icon_type or "",
"icon_type": args.icon_type,
"icon": args.icon or "",
"icon_background": args.icon_background or "",
"use_icon_as_answer_icon": args.use_icon_as_answer_icon or False,

View File

@ -19,6 +19,7 @@ class RateLimit:
_REQUEST_MAX_ALIVE_TIME = 10 * 60 # 10 minutes
_ACTIVE_REQUESTS_COUNT_FLUSH_INTERVAL = 5 * 60 # recalculate request_count from request_detail every 5 minutes
_instance_dict: dict[str, "RateLimit"] = {}
max_active_requests: int
def __new__(cls, client_id: str, max_active_requests: int):
if client_id not in cls._instance_dict:
@ -27,7 +28,13 @@ class RateLimit:
return cls._instance_dict[client_id]
def __init__(self, client_id: str, max_active_requests: int):
flush_cache = hasattr(self, "max_active_requests") and self.max_active_requests != max_active_requests
self.max_active_requests = max_active_requests
# Only flush here if this instance has already been fully initialized,
# i.e. the Redis key attributes exist. Otherwise, rely on the flush at
# the end of initialization below.
if flush_cache and hasattr(self, "active_requests_key") and hasattr(self, "max_active_requests_key"):
self.flush_cache(use_local_value=True)
# must be called after max_active_requests is set
if self.disabled():
return
@ -41,8 +48,6 @@ class RateLimit:
self.flush_cache(use_local_value=True)
def flush_cache(self, use_local_value=False):
if self.disabled():
return
self.last_recalculate_time = time.time()
# flush max active requests
if use_local_value or not redis_client.exists(self.max_active_requests_key):
@ -50,7 +55,8 @@ class RateLimit:
else:
self.max_active_requests = int(redis_client.get(self.max_active_requests_key).decode("utf-8"))
redis_client.expire(self.max_active_requests_key, timedelta(days=1))
if self.disabled():
return
# flush max active requests (in-transit request list)
if not redis_client.exists(self.active_requests_key):
return

View File

@ -1,6 +1,9 @@
from __future__ import annotations
from collections.abc import Sequence
import json
import logging
import re
from collections.abc import Mapping, Sequence
from typing import Any, cast
from core.model_manager import ModelInstance
@ -36,6 +39,11 @@ from .exc import (
)
from .protocols import TemplateRenderer
logger = logging.getLogger(__name__)
VARIABLE_PATTERN = re.compile(r"\{\{#[^#]+#\}\}")
MAX_RESOLVED_VALUE_LENGTH = 1024
def fetch_model_schema(*, model_instance: ModelInstance) -> AIModelEntity:
model_schema = cast(LargeLanguageModel, model_instance.model_type_instance).get_model_schema(
@ -475,3 +483,61 @@ def _append_file_prompts(
prompt_messages[-1] = UserPromptMessage(content=file_prompts + existing_contents)
else:
prompt_messages.append(UserPromptMessage(content=file_prompts))
def _coerce_resolved_value(raw: str) -> int | float | bool | str:
"""Try to restore the original type from a resolved template string.
Variable references are always resolved to text, but completion params may
expect numeric or boolean values (e.g. a variable that holds "0.7" mapped to
the ``temperature`` parameter). This helper attempts a JSON parse so that
``"0.7"`` ``0.7``, ``"true"`` ``True``, etc. Plain strings that are not
valid JSON literals are returned as-is.
"""
stripped = raw.strip()
if not stripped:
return raw
try:
parsed: object = json.loads(stripped)
except (json.JSONDecodeError, ValueError):
return raw
if isinstance(parsed, (int, float, bool)):
return parsed
return raw
def resolve_completion_params_variables(
completion_params: Mapping[str, Any],
variable_pool: VariablePool,
) -> dict[str, Any]:
"""Resolve variable references (``{{#node_id.var#}}``) in string-typed completion params.
Security notes:
- Resolved values are length-capped to ``MAX_RESOLVED_VALUE_LENGTH`` to
prevent denial-of-service through excessively large variable payloads.
- This follows the same ``VariablePool.convert_template`` pattern used across
Dify (Answer Node, HTTP Request Node, Agent Node, etc.). The downstream
model plugin receives these values as structured JSON key-value pairs they
are never concatenated into raw HTTP headers or SQL queries.
- Numeric/boolean coercion is applied so that variables holding ``"0.7"`` are
restored to their native type rather than sent as a bare string.
"""
resolved: dict[str, Any] = {}
for key, value in completion_params.items():
if isinstance(value, str) and VARIABLE_PATTERN.search(value):
segment_group = variable_pool.convert_template(value)
text = segment_group.text
if len(text) > MAX_RESOLVED_VALUE_LENGTH:
logger.warning(
"Resolved value for param '%s' truncated from %d to %d chars",
key,
len(text),
MAX_RESOLVED_VALUE_LENGTH,
)
text = text[:MAX_RESOLVED_VALUE_LENGTH]
resolved[key] = _coerce_resolved_value(text)
else:
resolved[key] = value
return resolved

View File

@ -202,6 +202,10 @@ class LLMNode(Node[LLMNodeData]):
# fetch model config
model_instance = self._model_instance
# Resolve variable references in string-typed completion params
model_instance.parameters = llm_utils.resolve_completion_params_variables(
model_instance.parameters, variable_pool
)
model_name = model_instance.model_name
model_provider = model_instance.provider
model_stop = model_instance.stop

View File

@ -164,6 +164,10 @@ class ParameterExtractorNode(Node[ParameterExtractorNodeData]):
)
model_instance = self._model_instance
# Resolve variable references in string-typed completion params
model_instance.parameters = llm_utils.resolve_completion_params_variables(
model_instance.parameters, variable_pool
)
if not isinstance(model_instance.model_type_instance, LargeLanguageModel):
raise InvalidModelTypeError("Model is not a Large Language Model")

View File

@ -114,6 +114,10 @@ class QuestionClassifierNode(Node[QuestionClassifierNodeData]):
variables = {"query": query}
# fetch model instance
model_instance = self._model_instance
# Resolve variable references in string-typed completion params
model_instance.parameters = llm_utils.resolve_completion_params_variables(
model_instance.parameters, variable_pool
)
memory = self._memory
# fetch instruction
node_data.instruction = node_data.instruction or ""

View File

@ -18,15 +18,23 @@ if TYPE_CHECKING:
from models.model import EndUser
def _resolve_current_user() -> EndUser | Account | None:
"""
Resolve the current user proxy to its underlying user object.
This keeps unit tests working when they patch `current_user` directly
instead of bootstrapping a full Flask-Login manager.
"""
user_proxy = current_user
get_current_object = getattr(user_proxy, "_get_current_object", None)
return get_current_object() if callable(get_current_object) else user_proxy # type: ignore
def current_account_with_tenant():
"""
Resolve the underlying account for the current user proxy and ensure tenant context exists.
Allows tests to supply plain Account mocks without the LocalProxy helper.
"""
user_proxy = current_user
get_current_object = getattr(user_proxy, "_get_current_object", None)
user = get_current_object() if callable(get_current_object) else user_proxy # type: ignore
user = _resolve_current_user()
if not isinstance(user, Account):
raise ValueError("current_user must be an Account instance")
@ -79,9 +87,10 @@ def login_required(func: Callable[P, R]) -> Callable[P, R | ResponseReturnValue]
if request.method in EXEMPT_METHODS or dify_config.LOGIN_DISABLED:
return current_app.ensure_sync(func)(*args, **kwargs)
user = _get_user()
user = _resolve_current_user()
if user is None or not user.is_authenticated:
return current_app.login_manager.unauthorized() # type: ignore
g._login_user = user
# we put csrf validation here for less conflicts
# TODO: maybe find a better place for it.
check_csrf_token(request, user.id)

View File

@ -241,7 +241,7 @@ class AppService:
class ArgsDict(TypedDict):
name: str
description: str
icon_type: str
icon_type: IconType | str | None
icon: str
icon_background: str
use_icon_as_answer_icon: bool
@ -257,7 +257,13 @@ class AppService:
assert current_user is not None
app.name = args["name"]
app.description = args["description"]
app.icon_type = IconType(args["icon_type"]) if args["icon_type"] else None
icon_type = args.get("icon_type")
if icon_type is None:
resolved_icon_type = app.icon_type
else:
resolved_icon_type = IconType(icon_type)
app.icon_type = resolved_icon_type
app.icon = args["icon"]
app.icon_background = args["icon_background"]
app.use_icon_as_answer_icon = args.get("use_icon_as_answer_icon", False)

View File

@ -6,7 +6,7 @@ from sqlalchemy.orm import Session
from constants.model_template import default_app_templates
from models import Account
from models.model import App, Site
from models.model import App, IconType, Site
from services.account_service import AccountService, TenantService
from tests.test_containers_integration_tests.helpers import generate_valid_password
@ -463,6 +463,109 @@ class TestAppService:
assert updated_app.tenant_id == app.tenant_id
assert updated_app.created_by == app.created_by
def test_update_app_should_preserve_icon_type_when_omitted(
self, db_session_with_containers: Session, mock_external_service_dependencies
):
"""
Test update_app keeps the persisted icon_type when the update payload omits it.
"""
fake = Faker()
account = AccountService.create_account(
email=fake.email(),
name=fake.name(),
interface_language="en-US",
password=generate_valid_password(fake),
)
TenantService.create_owner_tenant_if_not_exist(account, name=fake.company())
tenant = account.current_tenant
from services.app_service import AppService
app_service = AppService()
app = app_service.create_app(
tenant.id,
{
"name": fake.company(),
"description": fake.text(max_nb_chars=100),
"mode": "chat",
"icon_type": "emoji",
"icon": "🎯",
"icon_background": "#45B7D1",
},
account,
)
mock_current_user = create_autospec(Account, instance=True)
mock_current_user.id = account.id
mock_current_user.current_tenant_id = account.current_tenant_id
with patch("services.app_service.current_user", mock_current_user):
updated_app = app_service.update_app(
app,
{
"name": "Updated App Name",
"description": "Updated app description",
"icon_type": None,
"icon": "🔄",
"icon_background": "#FF8C42",
"use_icon_as_answer_icon": True,
},
)
assert updated_app.icon_type == IconType.EMOJI
def test_update_app_should_reject_empty_icon_type(
self, db_session_with_containers: Session, mock_external_service_dependencies
):
"""
Test update_app rejects an explicit empty icon_type.
"""
fake = Faker()
account = AccountService.create_account(
email=fake.email(),
name=fake.name(),
interface_language="en-US",
password=generate_valid_password(fake),
)
TenantService.create_owner_tenant_if_not_exist(account, name=fake.company())
tenant = account.current_tenant
from services.app_service import AppService
app_service = AppService()
app = app_service.create_app(
tenant.id,
{
"name": fake.company(),
"description": fake.text(max_nb_chars=100),
"mode": "chat",
"icon_type": "emoji",
"icon": "🎯",
"icon_background": "#45B7D1",
},
account,
)
mock_current_user = create_autospec(Account, instance=True)
mock_current_user.id = account.id
mock_current_user.current_tenant_id = account.current_tenant_id
with patch("services.app_service.current_user", mock_current_user):
with pytest.raises(ValueError):
app_service.update_app(
app,
{
"name": "Updated App Name",
"description": "Updated app description",
"icon_type": "",
"icon": "🔄",
"icon_background": "#FF8C42",
"use_icon_as_answer_icon": True,
},
)
def test_update_app_name_success(self, db_session_with_containers: Session, mock_external_service_dependencies):
"""
Test successful app name update.

View File

@ -7,14 +7,19 @@ from __future__ import annotations
import uuid
from types import SimpleNamespace
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
import pytest
from pydantic import ValidationError
from werkzeug.exceptions import BadRequest, NotFound
from controllers.console import console_ns
from controllers.console.app import (
annotation as annotation_module,
)
from controllers.console.app import (
app as app_module,
)
from controllers.console.app import (
completion as completion_module,
)
@ -203,6 +208,48 @@ class TestCompletionEndpoints:
method(app_model=MagicMock(id="app-1"))
class TestAppEndpoints:
"""Tests for app endpoints."""
def test_app_put_should_preserve_icon_type_when_payload_omits_it(self, app, monkeypatch):
api = app_module.AppApi()
method = _unwrap(api.put)
payload = {
"name": "Updated App",
"description": "Updated description",
"icon": "🤖",
"icon_background": "#FFFFFF",
}
app_service = MagicMock()
app_service.update_app.return_value = SimpleNamespace()
response_model = MagicMock()
response_model.model_dump.return_value = {"id": "app-1"}
monkeypatch.setattr(app_module, "AppService", lambda: app_service)
monkeypatch.setattr(app_module.AppDetailWithSite, "model_validate", MagicMock(return_value=response_model))
with (
app.test_request_context("/console/api/apps/app-1", method="PUT", json=payload),
patch.object(type(console_ns), "payload", payload),
):
response = method(app_model=SimpleNamespace(icon_type=app_module.IconType.EMOJI))
assert response == {"id": "app-1"}
assert app_service.update_app.call_args.args[1]["icon_type"] is None
def test_update_app_payload_should_reject_empty_icon_type(self):
with pytest.raises(ValidationError):
app_module.UpdateAppPayload.model_validate(
{
"name": "Updated App",
"description": "Updated description",
"icon_type": "",
"icon": "🤖",
"icon_background": "#FFFFFF",
}
)
# ========== OpsTrace Tests ==========
class TestOpsTraceEndpoints:
"""Tests for ops_trace endpoint."""

View File

@ -68,8 +68,8 @@ class TestRateLimit:
assert rate_limit.disabled()
assert not hasattr(rate_limit, "initialized")
def test_should_skip_reinitialization_of_existing_instance(self, redis_patch):
"""Test that existing instance doesn't reinitialize."""
def test_should_flush_cache_when_reinitializing_existing_instance(self, redis_patch):
"""Test existing instance refreshes Redis cache on reinitialization."""
redis_patch.configure_mock(
**{
"exists.return_value": False,
@ -82,7 +82,37 @@ class TestRateLimit:
RateLimit("client1", 10)
redis_patch.setex.assert_called_once_with(
"dify:rate_limit:client1:max_active_requests",
timedelta(days=1),
10,
)
def test_should_reinitialize_after_being_disabled(self, redis_patch):
"""Test disabled instance can be reinitialized and writes max_active_requests to Redis."""
redis_patch.configure_mock(
**{
"exists.return_value": False,
"setex.return_value": True,
}
)
# First construct with max_active_requests = 0 (disabled), which should skip initialization.
RateLimit("client1", 0)
# Redis should not have been written to during disabled initialization.
redis_patch.setex.assert_not_called()
redis_patch.reset_mock()
# Reinitialize with a positive max_active_requests value; this should not raise
# and must write the max_active_requests key to Redis.
RateLimit("client1", 10)
redis_patch.setex.assert_called_once_with(
"dify:rate_limit:client1:max_active_requests",
timedelta(days=1),
10,
)
def test_should_be_disabled_when_max_requests_is_zero_or_negative(self):
"""Test disabled state for zero or negative limits."""

View File

@ -3,7 +3,11 @@ from unittest import mock
import pytest
from core.model_manager import ModelInstance
from dify_graph.model_runtime.entities import ImagePromptMessageContent, PromptMessageRole, TextPromptMessageContent
from dify_graph.model_runtime.entities import (
ImagePromptMessageContent,
PromptMessageRole,
TextPromptMessageContent,
)
from dify_graph.model_runtime.entities.message_entities import SystemPromptMessage
from dify_graph.nodes.llm import llm_utils
from dify_graph.nodes.llm.entities import LLMNodeChatModelMessage
@ -11,6 +15,15 @@ from dify_graph.nodes.llm.exc import NoPromptFoundError
from dify_graph.runtime import VariablePool
@pytest.fixture
def variable_pool() -> VariablePool:
pool = VariablePool.empty()
pool.add(["node1", "output"], "resolved_value")
pool.add(["node2", "text"], "hello world")
pool.add(["start", "user_input"], "dynamic_param")
return pool
def _fetch_prompt_messages_with_mocked_content(content):
variable_pool = VariablePool.empty()
model_instance = mock.MagicMock(spec=ModelInstance)
@ -53,6 +66,159 @@ def _fetch_prompt_messages_with_mocked_content(content):
)
class TestTypeCoercionViaResolve:
"""Type coercion is tested through the public resolve_completion_params_variables API."""
def test_numeric_string_coerced_to_float(self):
pool = VariablePool.empty()
pool.add(["n", "v"], "0.7")
result = llm_utils.resolve_completion_params_variables({"p": "{{#n.v#}}"}, pool)
assert result["p"] == 0.7
def test_integer_string_coerced_to_int(self):
pool = VariablePool.empty()
pool.add(["n", "v"], "1024")
result = llm_utils.resolve_completion_params_variables({"p": "{{#n.v#}}"}, pool)
assert result["p"] == 1024
def test_boolean_string_coerced_to_bool(self):
pool = VariablePool.empty()
pool.add(["n", "v"], "true")
result = llm_utils.resolve_completion_params_variables({"p": "{{#n.v#}}"}, pool)
assert result["p"] is True
def test_plain_string_stays_string(self):
pool = VariablePool.empty()
pool.add(["n", "v"], "json_object")
result = llm_utils.resolve_completion_params_variables({"p": "{{#n.v#}}"}, pool)
assert result["p"] == "json_object"
def test_json_object_string_stays_string(self):
pool = VariablePool.empty()
pool.add(["n", "v"], '{"key": "val"}')
result = llm_utils.resolve_completion_params_variables({"p": "{{#n.v#}}"}, pool)
assert result["p"] == '{"key": "val"}'
def test_mixed_text_and_variable_stays_string(self):
pool = VariablePool.empty()
pool.add(["n", "v"], "0.7")
result = llm_utils.resolve_completion_params_variables({"p": "val={{#n.v#}}"}, pool)
assert result["p"] == "val=0.7"
class TestResolveCompletionParamsVariables:
def test_plain_string_values_unchanged(self, variable_pool: VariablePool):
params = {"response_format": "json", "custom_param": "static_value"}
result = llm_utils.resolve_completion_params_variables(params, variable_pool)
assert result == {"response_format": "json", "custom_param": "static_value"}
def test_numeric_values_unchanged(self, variable_pool: VariablePool):
params = {"temperature": 0.7, "top_p": 0.9, "max_tokens": 1024}
result = llm_utils.resolve_completion_params_variables(params, variable_pool)
assert result == {"temperature": 0.7, "top_p": 0.9, "max_tokens": 1024}
def test_boolean_values_unchanged(self, variable_pool: VariablePool):
params = {"stream": True, "echo": False}
result = llm_utils.resolve_completion_params_variables(params, variable_pool)
assert result == {"stream": True, "echo": False}
def test_list_values_unchanged(self, variable_pool: VariablePool):
params = {"stop": ["Human:", "Assistant:"]}
result = llm_utils.resolve_completion_params_variables(params, variable_pool)
assert result == {"stop": ["Human:", "Assistant:"]}
def test_single_variable_reference_resolved(self, variable_pool: VariablePool):
params = {"response_format": "{{#node1.output#}}"}
result = llm_utils.resolve_completion_params_variables(params, variable_pool)
assert result == {"response_format": "resolved_value"}
def test_multiple_variable_references_resolved(self, variable_pool: VariablePool):
params = {
"param_a": "{{#node1.output#}}",
"param_b": "{{#node2.text#}}",
}
result = llm_utils.resolve_completion_params_variables(params, variable_pool)
assert result == {"param_a": "resolved_value", "param_b": "hello world"}
def test_mixed_text_and_variable_resolved(self, variable_pool: VariablePool):
params = {"prompt_prefix": "prefix_{{#node1.output#}}_suffix"}
result = llm_utils.resolve_completion_params_variables(params, variable_pool)
assert result == {"prompt_prefix": "prefix_resolved_value_suffix"}
def test_mixed_params_types(self, variable_pool: VariablePool):
"""Non-string params pass through; string params with variables get resolved."""
params = {
"temperature": 0.7,
"response_format": "{{#node1.output#}}",
"custom_string": "no_vars_here",
"max_tokens": 512,
"stop": ["\n"],
}
result = llm_utils.resolve_completion_params_variables(params, variable_pool)
assert result == {
"temperature": 0.7,
"response_format": "resolved_value",
"custom_string": "no_vars_here",
"max_tokens": 512,
"stop": ["\n"],
}
def test_empty_params(self, variable_pool: VariablePool):
result = llm_utils.resolve_completion_params_variables({}, variable_pool)
assert result == {}
def test_unresolvable_variable_keeps_selector_text(self):
"""When a referenced variable doesn't exist in the pool, convert_template
falls back to the raw selector path (e.g. 'nonexistent.var')."""
pool = VariablePool.empty()
params = {"format": "{{#nonexistent.var#}}"}
result = llm_utils.resolve_completion_params_variables(params, pool)
assert result["format"] == "nonexistent.var"
def test_multiple_variables_in_single_value(self, variable_pool: VariablePool):
params = {"combined": "{{#node1.output#}} and {{#node2.text#}}"}
result = llm_utils.resolve_completion_params_variables(params, variable_pool)
assert result == {"combined": "resolved_value and hello world"}
def test_original_params_not_mutated(self, variable_pool: VariablePool):
original = {"response_format": "{{#node1.output#}}", "temperature": 0.5}
original_copy = dict(original)
_ = llm_utils.resolve_completion_params_variables(original, variable_pool)
assert original == original_copy
def test_long_value_truncated(self):
pool = VariablePool.empty()
pool.add(["node1", "big"], "x" * 2000)
params = {"param": "{{#node1.big#}}"}
result = llm_utils.resolve_completion_params_variables(params, pool)
assert len(result["param"]) == llm_utils.MAX_RESOLVED_VALUE_LENGTH
def test_fetch_prompt_messages_skips_messages_when_all_contents_are_filtered_out():
with pytest.raises(NoPromptFoundError):
_fetch_prompt_messages_with_mocked_content(

View File

@ -130,6 +130,25 @@ class TestLoginRequired:
assert result == "Synced content"
setup_app.ensure_sync.assert_called_once()
@patch("libs.login.check_csrf_token", mock_csrf_check)
def test_patched_current_user_without_login_manager(self, app: Flask):
"""Test that patched current_user bypasses login manager bootstrapping."""
@login_required
def protected_view():
return "Protected content"
mock_user = MockUser("test_user", is_authenticated=True)
mock_proxy = MagicMock()
mock_proxy._get_current_object.return_value = mock_user
with app.test_request_context():
app.ensure_sync = lambda func: func
with patch("libs.login.current_user", mock_proxy):
result = protected_view()
assert result == "Protected content"
assert g._login_user == mock_user
@patch("libs.login.check_csrf_token", mock_csrf_check)
def test_flask_1_compatibility(self, setup_app: Flask):
"""Test Flask 1.x compatibility without ensure_sync."""

View File

@ -9,7 +9,7 @@ import pytest
from core.errors.error import ProviderTokenNotInitError
from models import Account, Tenant
from models.model import App, AppMode
from models.model import App, AppMode, IconType
from services.app_service import AppService
@ -411,6 +411,7 @@ class TestAppServiceGetAndUpdate:
# Assert
assert updated is app
assert updated.icon_type == IconType.IMAGE
assert renamed is app
assert iconed is app
assert site_same is app
@ -419,6 +420,79 @@ class TestAppServiceGetAndUpdate:
assert api_changed is app
assert mock_db.session.commit.call_count >= 5
def test_update_app_should_preserve_icon_type_when_not_provided(self, service: AppService) -> None:
"""Test update_app keeps the existing icon_type when the payload omits it."""
# Arrange
app = cast(
App,
SimpleNamespace(
name="old",
description="old",
icon_type=IconType.EMOJI,
icon="a",
icon_background="#111",
use_icon_as_answer_icon=False,
max_active_requests=1,
),
)
args = {
"name": "new",
"description": "new-desc",
"icon_type": None,
"icon": "new-icon",
"icon_background": "#222",
"use_icon_as_answer_icon": True,
"max_active_requests": 5,
}
user = SimpleNamespace(id="user-1")
with (
patch("services.app_service.current_user", user),
patch("services.app_service.db") as mock_db,
patch("services.app_service.naive_utc_now", return_value="now"),
):
# Act
updated = service.update_app(app, args)
# Assert
assert updated is app
assert updated.icon_type == IconType.EMOJI
mock_db.session.commit.assert_called_once()
def test_update_app_should_reject_empty_icon_type(self, service: AppService) -> None:
"""Test update_app rejects an explicit empty icon_type."""
app = cast(
App,
SimpleNamespace(
name="old",
description="old",
icon_type=IconType.EMOJI,
icon="a",
icon_background="#111",
use_icon_as_answer_icon=False,
max_active_requests=1,
),
)
args = {
"name": "new",
"description": "new-desc",
"icon_type": "",
"icon": "new-icon",
"icon_background": "#222",
"use_icon_as_answer_icon": True,
"max_active_requests": 5,
}
user = SimpleNamespace(id="user-1")
with (
patch("services.app_service.current_user", user),
patch("services.app_service.db") as mock_db,
):
with pytest.raises(ValueError):
service.update_app(app, args)
mock_db.session.commit.assert_not_called()
class TestAppServiceDeleteAndMeta:
"""Test suite for delete and metadata methods."""

View File

@ -8,12 +8,14 @@ import AppListContext from '@/context/app-list-context'
import useDocumentTitle from '@/hooks/use-document-title'
import { useImportDSL } from '@/hooks/use-import-dsl'
import { DSLImportMode } from '@/models/app'
import dynamic from '@/next/dynamic'
import { fetchAppDetail } from '@/service/explore'
import DSLConfirmModal from '../app/create-from-dsl-modal/dsl-confirm-modal'
import CreateAppModal from '../explore/create-app-modal'
import TryApp from '../explore/try-app'
import List from './list'
const DSLConfirmModal = dynamic(() => import('../app/create-from-dsl-modal/dsl-confirm-modal'), { ssr: false })
const CreateAppModal = dynamic(() => import('../explore/create-app-modal'), { ssr: false })
const TryApp = dynamic(() => import('../explore/try-app'), { ssr: false })
const Apps = () => {
const { t } = useTranslation()

View File

@ -5,11 +5,11 @@ import { useDebounceFn } from 'ahooks'
import { parseAsStringLiteral, useQueryState } from 'nuqs'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Checkbox from '@/app/components/base/checkbox'
import Input from '@/app/components/base/input'
import TabSliderNew from '@/app/components/base/tab-slider-new'
import TagFilter from '@/app/components/base/tag-management/filter'
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
import CheckboxWithLabel from '@/app/components/datasets/create/website/base/checkbox-with-label'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
@ -205,12 +205,12 @@ const List: FC<Props> = ({
options={options}
/>
<div className="flex items-center gap-2">
<CheckboxWithLabel
className="mr-2"
label={t('showMyCreatedAppsOnly', { ns: 'app' })}
isChecked={isCreatedByMe}
onChange={handleCreatedByMeChange}
/>
<label className="mr-2 flex h-7 items-center space-x-2">
<Checkbox checked={isCreatedByMe} onCheck={handleCreatedByMeChange} />
<div className="text-sm font-normal text-text-secondary">
{t('showMyCreatedAppsOnly', { ns: 'app' })}
</div>
</label>
<TagFilter type="app" value={tagFilterValue} onChange={handleTagsChange} />
<Input
showLeftIcon

View File

@ -5,17 +5,12 @@ import * as amplitude from '@amplitude/analytics-browser'
import { sessionReplayPlugin } from '@amplitude/plugin-session-replay-browser'
import * as React from 'react'
import { useEffect } from 'react'
import { AMPLITUDE_API_KEY, IS_CLOUD_EDITION } from '@/config'
import { AMPLITUDE_API_KEY, isAmplitudeEnabled } from '@/config'
export type IAmplitudeProps = {
sessionReplaySampleRate?: number
}
// Check if Amplitude should be enabled
export const isAmplitudeEnabled = () => {
return IS_CLOUD_EDITION && !!AMPLITUDE_API_KEY
}
// Map URL pathname to English page name for consistent Amplitude tracking
const getEnglishPageName = (pathname: string): string => {
// Remove leading slash and get the first segment
@ -59,7 +54,7 @@ const AmplitudeProvider: FC<IAmplitudeProps> = ({
}) => {
useEffect(() => {
// Only enable in Saas edition with valid API key
if (!isAmplitudeEnabled())
if (!isAmplitudeEnabled)
return
// Initialize Amplitude

View File

@ -2,14 +2,24 @@ import * as amplitude from '@amplitude/analytics-browser'
import { sessionReplayPlugin } from '@amplitude/plugin-session-replay-browser'
import { render } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AmplitudeProvider, { isAmplitudeEnabled } from '../AmplitudeProvider'
import AmplitudeProvider from '../AmplitudeProvider'
const mockConfig = vi.hoisted(() => ({
AMPLITUDE_API_KEY: 'test-api-key',
IS_CLOUD_EDITION: true,
}))
vi.mock('@/config', () => mockConfig)
vi.mock('@/config', () => ({
get AMPLITUDE_API_KEY() {
return mockConfig.AMPLITUDE_API_KEY
},
get IS_CLOUD_EDITION() {
return mockConfig.IS_CLOUD_EDITION
},
get isAmplitudeEnabled() {
return mockConfig.IS_CLOUD_EDITION && !!mockConfig.AMPLITUDE_API_KEY
},
}))
vi.mock('@amplitude/analytics-browser', () => ({
init: vi.fn(),
@ -27,22 +37,6 @@ describe('AmplitudeProvider', () => {
mockConfig.IS_CLOUD_EDITION = true
})
describe('isAmplitudeEnabled', () => {
it('returns true when cloud edition and api key present', () => {
expect(isAmplitudeEnabled()).toBe(true)
})
it('returns false when cloud edition but no api key', () => {
mockConfig.AMPLITUDE_API_KEY = ''
expect(isAmplitudeEnabled()).toBe(false)
})
it('returns false when not cloud edition', () => {
mockConfig.IS_CLOUD_EDITION = false
expect(isAmplitudeEnabled()).toBe(false)
})
})
describe('Component', () => {
it('initializes amplitude when enabled', () => {
render(<AmplitudeProvider sessionReplaySampleRate={0.8} />)

View File

@ -1,32 +0,0 @@
import { describe, expect, it } from 'vitest'
import AmplitudeProvider, { isAmplitudeEnabled } from '../AmplitudeProvider'
import indexDefault, {
isAmplitudeEnabled as indexIsAmplitudeEnabled,
resetUser,
setUserId,
setUserProperties,
trackEvent,
} from '../index'
import {
resetUser as utilsResetUser,
setUserId as utilsSetUserId,
setUserProperties as utilsSetUserProperties,
trackEvent as utilsTrackEvent,
} from '../utils'
describe('Amplitude index exports', () => {
it('exports AmplitudeProvider as default', () => {
expect(indexDefault).toBe(AmplitudeProvider)
})
it('exports isAmplitudeEnabled', () => {
expect(indexIsAmplitudeEnabled).toBe(isAmplitudeEnabled)
})
it('exports utils', () => {
expect(resetUser).toBe(utilsResetUser)
expect(setUserId).toBe(utilsSetUserId)
expect(setUserProperties).toBe(utilsSetUserProperties)
expect(trackEvent).toBe(utilsTrackEvent)
})
})

View File

@ -20,8 +20,10 @@ const MockIdentify = vi.hoisted(() =>
},
)
vi.mock('../AmplitudeProvider', () => ({
isAmplitudeEnabled: () => mockState.enabled,
vi.mock('@/config', () => ({
get isAmplitudeEnabled() {
return mockState.enabled
},
}))
vi.mock('@amplitude/analytics-browser', () => ({

View File

@ -1,2 +1,2 @@
export { default, isAmplitudeEnabled } from './AmplitudeProvider'
export { default } from './lazy-amplitude-provider'
export { resetUser, setUserId, setUserProperties, trackEvent } from './utils'

View File

@ -0,0 +1,11 @@
'use client'
import type { FC } from 'react'
import type { IAmplitudeProps } from './AmplitudeProvider'
import dynamic from '@/next/dynamic'
const AmplitudeProvider = dynamic(() => import('./AmplitudeProvider'), { ssr: false })
const LazyAmplitudeProvider: FC<IAmplitudeProps> = props => <AmplitudeProvider {...props} />
export default LazyAmplitudeProvider

View File

@ -1,5 +1,5 @@
import * as amplitude from '@amplitude/analytics-browser'
import { isAmplitudeEnabled } from './AmplitudeProvider'
import { isAmplitudeEnabled } from '@/config'
/**
* Track custom event
@ -7,7 +7,7 @@ import { isAmplitudeEnabled } from './AmplitudeProvider'
* @param eventProperties Event properties (optional)
*/
export const trackEvent = (eventName: string, eventProperties?: Record<string, any>) => {
if (!isAmplitudeEnabled())
if (!isAmplitudeEnabled)
return
amplitude.track(eventName, eventProperties)
}
@ -17,7 +17,7 @@ export const trackEvent = (eventName: string, eventProperties?: Record<string, a
* @param userId User ID
*/
export const setUserId = (userId: string) => {
if (!isAmplitudeEnabled())
if (!isAmplitudeEnabled)
return
amplitude.setUserId(userId)
}
@ -27,7 +27,7 @@ export const setUserId = (userId: string) => {
* @param properties User properties
*/
export const setUserProperties = (properties: Record<string, any>) => {
if (!isAmplitudeEnabled())
if (!isAmplitudeEnabled)
return
const identifyEvent = new amplitude.Identify()
Object.entries(properties).forEach(([key, value]) => {
@ -40,7 +40,7 @@ export const setUserProperties = (properties: Record<string, any>) => {
* Reset user (e.g., when user logs out)
*/
export const resetUser = () => {
if (!isAmplitudeEnabled())
if (!isAmplitudeEnabled)
return
amplitude.reset()
}

View File

@ -1,12 +1,14 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { Dialog } from '@/app/components/base/ui/dialog'
import { Dialog, DialogContent } from '@/app/components/base/ui/dialog'
import Header from '../header'
function renderHeader(onClose: () => void) {
return render(
<Dialog open>
<Header onClose={onClose} />
<DialogContent>
<Header onClose={onClose} />
</DialogContent>
</Dialog>,
)
}
@ -24,7 +26,7 @@ describe('Header', () => {
expect(screen.getByText('billing.plansCommon.title.plans')).toBeInTheDocument()
expect(screen.getByText('billing.plansCommon.title.description')).toBeInTheDocument()
expect(screen.getByRole('button')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument()
})
})
@ -33,7 +35,7 @@ describe('Header', () => {
const handleClose = vi.fn()
renderHeader(handleClose)
fireEvent.click(screen.getByRole('button'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' }))
expect(handleClose).toHaveBeenCalledTimes(1)
})
@ -41,11 +43,11 @@ describe('Header', () => {
describe('Edge Cases', () => {
it('should render structural elements with translation keys', () => {
const { container } = renderHeader(vi.fn())
renderHeader(vi.fn())
expect(container.querySelector('span')).toBeInTheDocument()
expect(container.querySelector('p')).toBeInTheDocument()
expect(screen.getByRole('button')).toBeInTheDocument()
expect(screen.getByText('billing.plansCommon.title.plans')).toBeInTheDocument()
expect(screen.getByText('billing.plansCommon.title.description')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument()
})
})
})

View File

@ -68,6 +68,7 @@ describe('Pricing', () => {
it('should render pricing header and localized footer link', () => {
render(<Pricing onCancel={vi.fn()} />)
expect(screen.getByRole('dialog', { name: 'billing.plansCommon.title.plans' })).toBeInTheDocument()
expect(screen.getByText('billing.plansCommon.title.plans')).toBeInTheDocument()
expect(screen.getByTestId('pricing-link')).toHaveAttribute('href', 'https://dify.ai/en/pricing#plans-and-features')
})

View File

@ -28,8 +28,9 @@ const Footer = ({
<span className="flex h-fit items-center gap-x-1 text-saas-dify-blue-accessible">
<Link
href={pricingPageURL}
className="system-md-regular"
className="system-md-regular hover:underline focus-visible:underline focus-visible:outline-none"
target="_blank"
rel="noopener noreferrer"
>
{t('plansCommon.comparePlanAndFeatures', { ns: 'billing' })}
</Link>

View File

@ -1,5 +1,6 @@
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { DialogDescription, DialogTitle } from '@/app/components/base/ui/dialog'
import { cn } from '@/utils/classnames'
import Button from '../../base/button'
import DifyLogo from '../../base/logo/dify-logo'
@ -18,24 +19,25 @@ const Header = ({
<div className="flex min-h-[105px] w-full justify-center px-10">
<div className="relative flex max-w-[1680px] grow flex-col justify-end gap-y-1 border-x border-divider-accent p-6 pt-8">
<div className="flex items-end">
<div className="py-[5px]">
<div aria-hidden="true" className="py-[5px]">
<DifyLogo className="h-[27px] w-[60px]" />
</div>
<span
<DialogTitle
className={cn(
'bg-billing-plan-title-bg bg-clip-text px-1.5 text-[37px] leading-[1.2] text-transparent',
styles.instrumentSerif,
)}
>
{t('plansCommon.title.plans', { ns: 'billing' })}
</span>
</DialogTitle>
</div>
<p className="text-text-tertiary system-sm-regular">
<DialogDescription className="text-text-tertiary system-sm-regular">
{t('plansCommon.title.description', { ns: 'billing' })}
</p>
</DialogDescription>
<Button
variant="secondary"
className="absolute bottom-[40.5px] right-[-18px] z-10 size-9 rounded-full p-2"
aria-label={t('operation.close', { ns: 'common' })}
onClick={onClose}
>
<span aria-hidden="true" className="i-ri-close-line size-5" />

View File

@ -4,6 +4,14 @@ import type { Category } from './types'
import * as React from 'react'
import { useState } from 'react'
import { Dialog, DialogContent } from '@/app/components/base/ui/dialog'
import {
ScrollAreaContent,
ScrollAreaCorner,
ScrollAreaRoot,
ScrollAreaScrollbar,
ScrollAreaThumb,
ScrollAreaViewport,
} from '@/app/components/base/ui/scroll-area'
import { useAppContext } from '@/context/app-context'
import { useGetPricingPageLanguage } from '@/context/i18n'
import { useProviderContext } from '@/context/provider-context'
@ -19,6 +27,15 @@ type PricingProps = {
onCancel: () => void
}
const pricingScrollAreaClassNames = {
root: 'relative h-full w-full overflow-hidden [--scroll-area-edge-hint-bg:var(--color-saas-background)]',
viewport: 'overscroll-contain',
content: 'min-h-full min-w-[1200px]',
verticalScrollbar: 'data-[orientation=vertical]:my-2 data-[orientation=vertical]:me-1',
horizontalScrollbar: 'data-[orientation=horizontal]:mx-2 data-[orientation=horizontal]:mb-0.5',
corner: 'bg-saas-background',
} as const
const Pricing: FC<PricingProps> = ({
onCancel,
}) => {
@ -42,30 +59,46 @@ const Pricing: FC<PricingProps> = ({
}}
>
<DialogContent
className="inset-0 h-full max-h-none w-full max-w-none translate-x-0 translate-y-0 overflow-auto rounded-none border-none bg-saas-background p-0 shadow-none"
className="inset-0 h-full max-h-none w-full max-w-none translate-x-0 translate-y-0 overflow-hidden rounded-none border-none bg-saas-background p-0 shadow-none"
>
<div className="relative grid min-h-full min-w-[1200px] grid-rows-[1fr_auto_auto_1fr] overflow-hidden">
<div className="absolute -top-12 left-0 right-0 -z-10">
<NoiseTop />
</div>
<Header onClose={onCancel} />
<PlanSwitcher
currentCategory={currentCategory}
onChangeCategory={setCurrentCategory}
currentPlanRange={planRange}
onChangePlanRange={setPlanRange}
/>
<Plans
plan={plan}
currentPlan={currentCategory}
planRange={planRange}
canPay={canPay}
/>
<Footer pricingPageURL={pricingPageURL} currentCategory={currentCategory} />
<div className="absolute -bottom-12 left-0 right-0 -z-10">
<NoiseBottom />
</div>
</div>
<ScrollAreaRoot className={pricingScrollAreaClassNames.root}>
<ScrollAreaViewport className={pricingScrollAreaClassNames.viewport}>
<ScrollAreaContent className={pricingScrollAreaClassNames.content}>
<div className="relative grid min-h-full grid-rows-[1fr_auto_auto_1fr] overflow-hidden">
<div className="absolute -top-12 left-0 right-0 -z-10">
<NoiseTop />
</div>
<Header onClose={onCancel} />
<PlanSwitcher
currentCategory={currentCategory}
onChangeCategory={setCurrentCategory}
currentPlanRange={planRange}
onChangePlanRange={setPlanRange}
/>
<Plans
plan={plan}
currentPlan={currentCategory}
planRange={planRange}
canPay={canPay}
/>
<Footer pricingPageURL={pricingPageURL} currentCategory={currentCategory} />
<div className="absolute -bottom-12 left-0 right-0 -z-10">
<NoiseBottom />
</div>
</div>
</ScrollAreaContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar className={pricingScrollAreaClassNames.verticalScrollbar}>
<ScrollAreaThumb className="rounded-full" />
</ScrollAreaScrollbar>
<ScrollAreaScrollbar
orientation="horizontal"
className={pricingScrollAreaClassNames.horizontalScrollbar}
>
<ScrollAreaThumb className="rounded-full" />
</ScrollAreaScrollbar>
<ScrollAreaCorner className={pricingScrollAreaClassNames.corner} />
</ScrollAreaRoot>
</DialogContent>
</Dialog>
)

View File

@ -0,0 +1,13 @@
'use client'
import { IS_DEV } from '@/config'
import dynamic from '@/next/dynamic'
const Agentation = dynamic(() => import('agentation').then(module => module.Agentation), { ssr: false })
export function AgentationLoader() {
if (!IS_DEV)
return null
return <Agentation />
}

View File

@ -69,6 +69,7 @@ vi.mock('@/context/i18n', () => ({
const { mockConfig, mockEnv } = vi.hoisted(() => ({
mockConfig: {
IS_CLOUD_EDITION: false,
AMPLITUDE_API_KEY: '',
ZENDESK_WIDGET_KEY: '',
SUPPORT_EMAIL_ADDRESS: '',
},
@ -80,6 +81,8 @@ const { mockConfig, mockEnv } = vi.hoisted(() => ({
}))
vi.mock('@/config', () => ({
get IS_CLOUD_EDITION() { return mockConfig.IS_CLOUD_EDITION },
get AMPLITUDE_API_KEY() { return mockConfig.AMPLITUDE_API_KEY },
get isAmplitudeEnabled() { return mockConfig.IS_CLOUD_EDITION && !!mockConfig.AMPLITUDE_API_KEY },
get ZENDESK_WIDGET_KEY() { return mockConfig.ZENDESK_WIDGET_KEY },
get SUPPORT_EMAIL_ADDRESS() { return mockConfig.SUPPORT_EMAIL_ADDRESS },
IS_DEV: false,

View File

@ -1,7 +1,6 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import ModelParameterModal from '../index'
let isAPIKeySet = true
let parameterRules: Array<Record<string, unknown>> | undefined = [
{
name: 'temperature',
@ -40,7 +39,7 @@ let activeTextGenerationModelList: Array<Record<string, unknown>> = [
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
isAPIKeySet,
isAPIKeySet: true,
}),
}))
@ -50,6 +49,7 @@ vi.mock('@/service/use-common', () => ({
data: parameterRules,
},
isLoading: isRulesLoading,
isPending: isRulesLoading,
}),
}))
@ -62,12 +62,18 @@ vi.mock('../../hooks', () => ({
}))
vi.mock('../parameter-item', () => ({
default: ({ parameterRule, onChange, onSwitch }: {
default: ({ parameterRule, onChange, onSwitch, nodesOutputVars, availableNodes }: {
parameterRule: { name: string, label: { en_US: string } }
onChange: (v: number) => void
onSwitch: (checked: boolean, val: unknown) => void
nodesOutputVars?: unknown[]
availableNodes?: unknown[]
}) => (
<div data-testid={`param-${parameterRule.name}`}>
<div
data-testid={`param-${parameterRule.name}`}
data-has-nodes-output-vars={!!nodesOutputVars}
data-has-available-nodes={!!availableNodes}
>
{parameterRule.label.en_US}
<button onClick={() => onChange(0.9)}>Change</button>
<button onClick={() => onSwitch(false, undefined)}>Remove</button>
@ -119,7 +125,6 @@ describe('ModelParameterModal', () => {
beforeEach(() => {
vi.clearAllMocks()
isAPIKeySet = true
isRulesLoading = false
parameterRules = [
{
@ -233,6 +238,26 @@ describe('ModelParameterModal', () => {
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
})
it('should pass nodesOutputVars and availableNodes to ParameterItem', () => {
const mockNodesOutputVars = [{ nodeId: 'n1', title: 'Node', vars: [] }]
const mockAvailableNodes = [{ id: 'n1', data: { title: 'Node', type: 'llm' } }]
render(
<ModelParameterModal
{...defaultProps}
isInWorkflow
nodesOutputVars={mockNodesOutputVars as never}
availableNodes={mockAvailableNodes as never}
/>,
)
fireEvent.click(screen.getByText('Open Settings'))
const paramEl = screen.getByTestId('param-temperature')
expect(paramEl).toHaveAttribute('data-has-nodes-output-vars', 'true')
expect(paramEl).toHaveAttribute('data-has-available-nodes', 'true')
})
it('should support custom triggers, workflow mode, and missing default model values', async () => {
render(
<ModelParameterModal

View File

@ -1,5 +1,10 @@
import type { ModelParameterRule } from '../../declarations'
import type {
Node,
NodeOutPutVar,
} from '@/app/components/workflow/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { BlockEnum } from '@/app/components/workflow/types'
import ParameterItem from '../parameter-item'
vi.mock('../../hooks', () => ({
@ -18,6 +23,29 @@ vi.mock('@/app/components/base/tag-input', () => ({
),
}))
let promptEditorOnChange: ((text: string) => void) | undefined
let capturedWorkflowNodesMap: Record<string, { title: string, type: string }> | undefined
vi.mock('@/app/components/base/prompt-editor', () => ({
default: ({ value, onChange, workflowVariableBlock }: {
value: string
onChange: (text: string) => void
workflowVariableBlock?: {
show: boolean
variables: NodeOutPutVar[]
workflowNodesMap?: Record<string, { title: string, type: string }>
}
}) => {
promptEditorOnChange = onChange
capturedWorkflowNodesMap = workflowVariableBlock?.workflowNodesMap
return (
<div data-testid="prompt-editor" data-value={value} data-has-workflow-vars={!!workflowVariableBlock?.variables}>
{value}
</div>
)
},
}))
describe('ParameterItem', () => {
const createRule = (overrides: Partial<ModelParameterRule> = {}): ModelParameterRule => ({
name: 'temp',
@ -30,9 +58,10 @@ describe('ParameterItem', () => {
beforeEach(() => {
vi.clearAllMocks()
promptEditorOnChange = undefined
capturedWorkflowNodesMap = undefined
})
// Float tests
it('should render float controls and clamp numeric input to max', () => {
const onChange = vi.fn()
render(<ParameterItem parameterRule={createRule({ type: 'float', min: 0, max: 1 })} value={0.7} onChange={onChange} />)
@ -50,7 +79,6 @@ describe('ParameterItem', () => {
expect(onChange).toHaveBeenCalledWith(0.1)
})
// Int tests
it('should render int controls and clamp numeric input', () => {
const onChange = vi.fn()
render(<ParameterItem parameterRule={createRule({ type: 'int', min: 0, max: 10 })} value={5} onChange={onChange} />)
@ -75,22 +103,17 @@ describe('ParameterItem', () => {
it('should render int input without slider if min or max is missing', () => {
render(<ParameterItem parameterRule={createRule({ type: 'int', min: 0 })} value={5} />)
expect(screen.queryByRole('slider')).not.toBeInTheDocument()
// No max -> precision step
expect(screen.getByRole('spinbutton')).toHaveAttribute('step', '0')
})
// Slider events (uses generic value mock for slider)
it('should handle slide change and clamp values', () => {
const onChange = vi.fn()
render(<ParameterItem parameterRule={createRule({ type: 'float', min: 0, max: 10 })} value={0.7} onChange={onChange} />)
// Test that the actual slider triggers the onChange logic correctly
// The implementation of Slider uses onChange(val) directly via the mock
fireEvent.click(screen.getByTestId('slider-btn'))
expect(onChange).toHaveBeenCalledWith(2)
})
// Text & String tests
it('should render exact string input and propagate text changes', () => {
const onChange = vi.fn()
render(<ParameterItem parameterRule={createRule({ type: 'string', name: 'prompt' })} value="initial" onChange={onChange} />)
@ -109,21 +132,17 @@ describe('ParameterItem', () => {
it('should render select for string with options', () => {
render(<ParameterItem parameterRule={createRule({ type: 'string', options: ['a', 'b'] })} value="a" />)
// Select renders the selected value in the trigger
expect(screen.getByText('a')).toBeInTheDocument()
})
// Tag Tests
it('should render tag input for tag type', () => {
const onChange = vi.fn()
render(<ParameterItem parameterRule={createRule({ type: 'tag', tagPlaceholder: { en_US: 'placeholder', zh_Hans: 'placeholder' } })} value={['a']} onChange={onChange} />)
expect(screen.getByText('placeholder')).toBeInTheDocument()
// Trigger mock tag input
fireEvent.click(screen.getByTestId('tag-input'))
expect(onChange).toHaveBeenCalledWith(['tag1', 'tag2'])
})
// Boolean tests
it('should render boolean radios and update value on click', () => {
const onChange = vi.fn()
render(<ParameterItem parameterRule={createRule({ type: 'boolean', default: false })} value={true} onChange={onChange} />)
@ -131,7 +150,6 @@ describe('ParameterItem', () => {
expect(onChange).toHaveBeenCalledWith(false)
})
// Switch tests
it('should call onSwitch with current value when optional switch is toggled off', () => {
const onSwitch = vi.fn()
render(<ParameterItem parameterRule={createRule()} value={0.7} onSwitch={onSwitch} />)
@ -146,7 +164,6 @@ describe('ParameterItem', () => {
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
})
// Default Value Fallbacks (rendering without value)
it('should use default values if value is undefined', () => {
const { rerender } = render(<ParameterItem parameterRule={createRule({ type: 'float', default: 0.5 })} />)
expect(screen.getByRole('spinbutton')).toHaveValue(0.5)
@ -158,26 +175,102 @@ describe('ParameterItem', () => {
expect(screen.getByText('True')).toBeInTheDocument()
expect(screen.getByText('False')).toBeInTheDocument()
// Without default
rerender(<ParameterItem parameterRule={createRule({ type: 'float' })} />) // min is 0 by default in createRule
rerender(<ParameterItem parameterRule={createRule({ type: 'float' })} />)
expect(screen.getByRole('spinbutton')).toHaveValue(0)
})
// Input Blur
it('should reset input to actual bound value on blur', () => {
render(<ParameterItem parameterRule={createRule({ type: 'float', min: 0, max: 1 })} />)
const input = screen.getByRole('spinbutton')
// change local state (which triggers clamp internally to let's say 1.4 -> 1 but leaves input text, though handleInputChange updates local state)
// Actually our test fires a change so localValue = 1, then blur sets it
fireEvent.change(input, { target: { value: '5' } })
fireEvent.blur(input)
expect(input).toHaveValue(1)
})
// Unsupported
it('should render no input for unsupported parameter type', () => {
render(<ParameterItem parameterRule={createRule({ type: 'unsupported' as unknown as string })} value={0.7} />)
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument()
})
describe('workflow variable reference', () => {
const mockNodesOutputVars: NodeOutPutVar[] = [
{ nodeId: 'node1', title: 'LLM Node', vars: [] },
]
const mockAvailableNodes: Node[] = [
{ id: 'node1', type: 'custom', position: { x: 0, y: 0 }, data: { title: 'LLM Node', type: BlockEnum.LLM } } as Node,
{ id: 'start', type: 'custom', position: { x: 0, y: 0 }, data: { title: 'Start', type: BlockEnum.Start } } as Node,
]
it('should build workflowNodesMap and render PromptEditor for string type', () => {
const onChange = vi.fn()
render(
<ParameterItem
parameterRule={createRule({ type: 'string', name: 'system_prompt' })}
value="hello {{#node1.output#}}"
onChange={onChange}
isInWorkflow
nodesOutputVars={mockNodesOutputVars}
availableNodes={mockAvailableNodes}
/>,
)
const editor = screen.getByTestId('prompt-editor')
expect(editor).toBeInTheDocument()
expect(editor).toHaveAttribute('data-has-workflow-vars', 'true')
expect(capturedWorkflowNodesMap).toBeDefined()
expect(capturedWorkflowNodesMap!.node1.title).toBe('LLM Node')
expect(capturedWorkflowNodesMap!.sys.title).toBe('workflow.blocks.start')
expect(capturedWorkflowNodesMap!.sys.type).toBe(BlockEnum.Start)
promptEditorOnChange?.('updated text')
expect(onChange).toHaveBeenCalledWith('updated text')
})
it('should build workflowNodesMap and render PromptEditor for text type', () => {
const onChange = vi.fn()
render(
<ParameterItem
parameterRule={createRule({ type: 'text', name: 'user_prompt' })}
value="some long text"
onChange={onChange}
isInWorkflow
nodesOutputVars={mockNodesOutputVars}
availableNodes={mockAvailableNodes}
/>,
)
const editor = screen.getByTestId('prompt-editor')
expect(editor).toBeInTheDocument()
expect(editor).toHaveAttribute('data-has-workflow-vars', 'true')
expect(capturedWorkflowNodesMap).toBeDefined()
promptEditorOnChange?.('new long text')
expect(onChange).toHaveBeenCalledWith('new long text')
})
it('should fall back to plain input when not in workflow mode for string type', () => {
render(
<ParameterItem
parameterRule={createRule({ type: 'string', name: 'system_prompt' })}
value="plain"
/>,
)
expect(screen.queryByTestId('prompt-editor')).not.toBeInTheDocument()
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should return undefined workflowNodesMap when not in workflow mode', () => {
render(
<ParameterItem
parameterRule={createRule({ type: 'string', name: 'system_prompt' })}
value="plain"
availableNodes={mockAvailableNodes}
/>,
)
expect(capturedWorkflowNodesMap).toBeUndefined()
})
})
})

View File

@ -9,6 +9,10 @@ import type {
} from '../declarations'
import type { ParameterValue } from './parameter-item'
import type { TriggerProps } from './trigger'
import type {
Node,
NodeOutPutVar,
} from '@/app/components/workflow/types'
import { useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ArrowNarrowLeft } from '@/app/components/base/icons/src/vender/line/arrows'
@ -45,6 +49,8 @@ export type ModelParameterModalProps = {
readonly?: boolean
isInWorkflow?: boolean
scope?: string
nodesOutputVars?: NodeOutPutVar[]
availableNodes?: Node[]
}
const ModelParameterModal: FC<ModelParameterModalProps> = ({
@ -61,11 +67,18 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
renderTrigger,
readonly,
isInWorkflow,
nodesOutputVars,
availableNodes,
}) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const settingsIconRef = useRef<HTMLDivElement>(null)
const { data: parameterRulesData, isLoading } = useModelParameterRules(provider, modelId)
const {
data: parameterRulesData,
isPending,
isLoading,
} = useModelParameterRules(provider, modelId)
const isRulesLoading = isPending || isLoading
const {
currentProvider,
currentModel,
@ -191,7 +204,7 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
}
</div>
{
isLoading
isRulesLoading
? <div className="py-5"><Loading /></div>
: (
[
@ -205,6 +218,8 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
onChange={v => handleParamChange(parameter.name, v)}
onSwitch={(checked, assignValue) => handleSwitch(parameter.name, checked, assignValue)}
isInWorkflow={isInWorkflow}
nodesOutputVars={nodesOutputVars}
availableNodes={availableNodes}
/>
))
)
@ -213,7 +228,7 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
)
}
{
!parameterRules.length && isLoading && (
!parameterRules.length && isRulesLoading && (
<div className="px-4 py-5"><Loading /></div>
)
}

View File

@ -1,11 +1,18 @@
import type { ModelParameterRule } from '../declarations'
import { useEffect, useRef, useState } from 'react'
import type {
Node,
NodeOutPutVar,
} from '@/app/components/workflow/types'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import PromptEditor from '@/app/components/base/prompt-editor'
import Radio from '@/app/components/base/radio'
import Slider from '@/app/components/base/slider'
import Switch from '@/app/components/base/switch'
import TagInput from '@/app/components/base/tag-input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/app/components/base/ui/select'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import { BlockEnum } from '@/app/components/workflow/types'
import { cn } from '@/utils/classnames'
import { useLanguage } from '../hooks'
import { isNullOrUndefined } from '../utils'
@ -18,18 +25,43 @@ type ParameterItemProps = {
onChange?: (value: ParameterValue) => void
onSwitch?: (checked: boolean, assignValue: ParameterValue) => void
isInWorkflow?: boolean
nodesOutputVars?: NodeOutPutVar[]
availableNodes?: Node[]
}
function ParameterItem({
parameterRule,
value,
onChange,
onSwitch,
isInWorkflow,
nodesOutputVars,
availableNodes = [],
}: ParameterItemProps) {
const { t } = useTranslation()
const language = useLanguage()
const [localValue, setLocalValue] = useState(value)
const numberInputRef = useRef<HTMLInputElement>(null)
const workflowNodesMap = useMemo(() => {
if (!isInWorkflow || !availableNodes.length)
return undefined
return availableNodes.reduce<Record<string, Pick<Node['data'], 'title' | 'type'>>>((acc, node) => {
acc[node.id] = {
title: node.data.title,
type: node.data.type,
}
if (node.data.type === BlockEnum.Start) {
acc.sys = {
title: t('blocks.start', { ns: 'workflow' }),
type: BlockEnum.Start,
}
}
return acc
}, {})
}, [availableNodes, isInWorkflow, t])
const getDefaultValue = () => {
let defaultValue: ParameterValue
@ -196,6 +228,25 @@ function ParameterItem({
}
if (parameterRule.type === 'string' && !parameterRule.options?.length) {
if (isInWorkflow && nodesOutputVars) {
return (
<div className="ml-4 w-[200px] rounded-lg bg-components-input-bg-normal px-2 py-1">
<PromptEditor
compact
className="min-h-[22px] text-[13px]"
value={renderValue as string}
onChange={(text) => { handleInputChange(text) }}
workflowVariableBlock={{
show: true,
variables: nodesOutputVars,
workflowNodesMap,
}}
editable
/>
</div>
)
}
return (
<input
className={cn(isInWorkflow ? 'w-[150px]' : 'w-full', 'ml-4 flex h-8 appearance-none items-center rounded-lg bg-components-input-bg-normal px-3 text-components-input-text-filled outline-none system-sm-regular')}
@ -206,6 +257,25 @@ function ParameterItem({
}
if (parameterRule.type === 'text') {
if (isInWorkflow && nodesOutputVars) {
return (
<div className="ml-4 w-full rounded-lg bg-components-input-bg-normal px-2 py-1">
<PromptEditor
compact
className="min-h-[56px] text-[13px]"
value={renderValue as string}
onChange={(text) => { handleInputChange(text) }}
workflowVariableBlock={{
show: true,
variables: nodesOutputVars,
workflowNodesMap,
}}
editable
/>
</div>
)
}
return (
<textarea
className="ml-4 h-20 w-full rounded-lg bg-components-input-bg-normal px-1 text-components-input-text-filled system-sm-regular"
@ -215,7 +285,7 @@ function ParameterItem({
)
}
if (parameterRule.type === 'string' && !!parameterRule?.options?.length) {
if (parameterRule.type === 'string' && !!parameterRule.options?.length) {
return (
<Select
value={renderValue as string}

View File

@ -9,16 +9,18 @@ import { flatten } from 'es-toolkit/compat'
import { produce } from 'immer'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import CreateAppTemplateDialog from '@/app/components/app/create-app-dialog'
import CreateAppModal from '@/app/components/app/create-app-modal'
import CreateFromDSLModal from '@/app/components/app/create-from-dsl-modal'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useAppContext } from '@/context/app-context'
import dynamic from '@/next/dynamic'
import { useParams } from '@/next/navigation'
import { useInfiniteAppList } from '@/service/use-apps'
import { AppModeEnum } from '@/types/app'
import Nav from '../nav'
const CreateAppTemplateDialog = dynamic(() => import('@/app/components/app/create-app-dialog'), { ssr: false })
const CreateAppModal = dynamic(() => import('@/app/components/app/create-app-modal'), { ssr: false })
const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-from-dsl-modal'), { ssr: false })
const AppNav = () => {
const { t } = useTranslation()
const { appId } = useParams()

View File

@ -0,0 +1,16 @@
'use client'
import { IS_DEV } from '@/config'
import { env } from '@/env'
import dynamic from '@/next/dynamic'
const SentryInitializer = dynamic(() => import('./sentry-initializer'), { ssr: false })
const LazySentryInitializer = () => {
if (IS_DEV || !env.NEXT_PUBLIC_SENTRY_DSN)
return null
return <SentryInitializer />
}
export default LazySentryInitializer

View File

@ -2,13 +2,10 @@
import * as Sentry from '@sentry/react'
import { useEffect } from 'react'
import { IS_DEV } from '@/config'
import { env } from '@/env'
const SentryInitializer = ({
children,
}: { children: React.ReactElement }) => {
const SentryInitializer = () => {
useEffect(() => {
const SENTRY_DSN = env.NEXT_PUBLIC_SENTRY_DSN
if (!IS_DEV && SENTRY_DSN) {
@ -24,7 +21,7 @@ const SentryInitializer = ({
})
}
}, [])
return children
return null
}
export default SentryInitializer

View File

@ -0,0 +1,276 @@
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import Tabs from '../tabs'
import { TabsEnum } from '../types'
const {
mockSetState,
mockInvalidateBuiltInTools,
mockToolsState,
} = vi.hoisted(() => ({
mockSetState: vi.fn(),
mockInvalidateBuiltInTools: vi.fn(),
mockToolsState: {
buildInTools: [{ icon: '/tool.svg', name: 'tool' }] as Array<{ icon: string | Record<string, string>, name: string }> | undefined,
customTools: [] as Array<{ icon: string | Record<string, string>, name: string }> | undefined,
workflowTools: [] as Array<{ icon: string | Record<string, string>, name: string }> | undefined,
mcpTools: [] as Array<{ icon: string | Record<string, string>, name: string }> | undefined,
},
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({
children,
popupContent,
}: {
children: React.ReactNode
popupContent: React.ReactNode
}) => (
<div>
<span>{popupContent}</span>
{children}
</div>
),
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: (selector: (state: { systemFeatures: { enable_marketplace: boolean } }) => unknown) => selector({
systemFeatures: { enable_marketplace: true },
}),
}))
vi.mock('@/service/use-plugins', () => ({
useFeaturedToolsRecommendations: () => ({
plugins: [],
isLoading: false,
}),
}))
vi.mock('@/service/use-tools', () => ({
useAllBuiltInTools: () => ({ data: mockToolsState.buildInTools }),
useAllCustomTools: () => ({ data: mockToolsState.customTools }),
useAllWorkflowTools: () => ({ data: mockToolsState.workflowTools }),
useAllMCPTools: () => ({ data: mockToolsState.mcpTools }),
useInvalidateAllBuiltInTools: () => mockInvalidateBuiltInTools,
}))
vi.mock('@/utils/var', () => ({
basePath: '/console',
}))
vi.mock('../../store', () => ({
useWorkflowStore: () => ({
setState: mockSetState,
}),
}))
vi.mock('../all-start-blocks', () => ({
default: () => <div>start-content</div>,
}))
vi.mock('../blocks', () => ({
default: () => <div>blocks-content</div>,
}))
vi.mock('../data-sources', () => ({
default: () => <div>sources-content</div>,
}))
vi.mock('../all-tools', () => ({
default: (props: {
buildInTools: Array<{ icon: string | Record<string, string> }>
showFeatured: boolean
featuredLoading: boolean
onFeaturedInstallSuccess: () => Promise<void>
}) => (
<div>
tools-content
{props.buildInTools.map((tool, index) => (
<span key={index}>
{typeof tool.icon === 'string' ? tool.icon : 'object-icon'}
</span>
))}
<span>{props.showFeatured ? 'featured-on' : 'featured-off'}</span>
<span>{props.featuredLoading ? 'featured-loading' : 'featured-idle'}</span>
<button onClick={() => props.onFeaturedInstallSuccess()}>Install featured tool</button>
</div>
),
}))
describe('Tabs', () => {
beforeEach(() => {
vi.clearAllMocks()
mockToolsState.buildInTools = [{ icon: '/tool.svg', name: 'tool' }]
mockToolsState.customTools = []
mockToolsState.workflowTools = []
mockToolsState.mcpTools = []
})
const baseProps = {
activeTab: TabsEnum.Start,
onActiveTabChange: vi.fn(),
searchText: '',
tags: [],
onTagsChange: vi.fn(),
onSelect: vi.fn(),
blocks: [],
tabs: [
{ key: TabsEnum.Start, name: 'Start' },
{ key: TabsEnum.Blocks, name: 'Blocks', disabled: true },
{ key: TabsEnum.Tools, name: 'Tools' },
],
filterElem: <div>filter</div>,
}
it('should render start content and disabled tab tooltip text', () => {
render(<Tabs {...baseProps} />)
expect(screen.getByText('start-content')).toBeInTheDocument()
expect(screen.getByText('workflow.tabs.startDisabledTip')).toBeInTheDocument()
})
it('should switch tabs through click handlers and render tools content with normalized icons', () => {
const onActiveTabChange = vi.fn()
render(
<Tabs
{...baseProps}
activeTab={TabsEnum.Tools}
onActiveTabChange={onActiveTabChange}
/>,
)
fireEvent.click(screen.getByText('Start'))
expect(onActiveTabChange).toHaveBeenCalledWith(TabsEnum.Start)
expect(screen.getByText('tools-content')).toBeInTheDocument()
expect(screen.getByText('/console/tool.svg')).toBeInTheDocument()
expect(screen.getByText('featured-on')).toBeInTheDocument()
expect(screen.getByText('featured-idle')).toBeInTheDocument()
})
it('should sync normalized tools into workflow store state', () => {
render(<Tabs {...baseProps} activeTab={TabsEnum.Tools} />)
expect(mockSetState).toHaveBeenCalled()
})
it('should ignore clicks on disabled and already active tabs', async () => {
const user = userEvent.setup()
const onActiveTabChange = vi.fn()
render(
<Tabs
{...baseProps}
activeTab={TabsEnum.Start}
onActiveTabChange={onActiveTabChange}
/>,
)
await user.click(screen.getByText('Start'))
await user.click(screen.getByText('Blocks'))
expect(onActiveTabChange).not.toHaveBeenCalled()
})
it('should render sources content when the sources tab is active and data sources are provided', () => {
render(
<Tabs
{...baseProps}
activeTab={TabsEnum.Sources}
dataSources={[{ name: 'dataset', icon: '/dataset.svg' } as never]}
/>,
)
expect(screen.getByText('sources-content')).toBeInTheDocument()
})
it('should keep the previous workflow store state when tool references do not change', () => {
mockToolsState.buildInTools = [{ icon: '/console/already-prefixed.svg', name: 'tool' }]
render(<Tabs {...baseProps} activeTab={TabsEnum.Tools} />)
const previousState = {
buildInTools: mockToolsState.buildInTools,
customTools: mockToolsState.customTools,
workflowTools: mockToolsState.workflowTools,
mcpTools: mockToolsState.mcpTools,
}
const updateState = mockSetState.mock.calls[0][0] as (state: typeof previousState) => typeof previousState
expect(updateState(previousState)).toBe(previousState)
})
it('should normalize every tool collection and merge updates into workflow store state', () => {
mockToolsState.buildInTools = [{ icon: { light: '/tool.svg' }, name: 'tool' }]
mockToolsState.customTools = [{ icon: '/custom.svg', name: 'custom' }]
mockToolsState.workflowTools = [{ icon: '/workflow.svg', name: 'workflow' }]
mockToolsState.mcpTools = [{ icon: '/mcp.svg', name: 'mcp' }]
render(<Tabs {...baseProps} activeTab={TabsEnum.Tools} />)
expect(screen.getByText('object-icon')).toBeInTheDocument()
const updateState = mockSetState.mock.calls[0][0] as (state: {
buildInTools?: Array<{ icon: string | Record<string, string>, name: string }>
customTools?: Array<{ icon: string | Record<string, string>, name: string }>
workflowTools?: Array<{ icon: string | Record<string, string>, name: string }>
mcpTools?: Array<{ icon: string | Record<string, string>, name: string }>
}) => {
buildInTools?: Array<{ icon: string | Record<string, string>, name: string }>
customTools?: Array<{ icon: string | Record<string, string>, name: string }>
workflowTools?: Array<{ icon: string | Record<string, string>, name: string }>
mcpTools?: Array<{ icon: string | Record<string, string>, name: string }>
}
expect(updateState({
buildInTools: [],
customTools: [],
workflowTools: [],
mcpTools: [],
})).toEqual({
buildInTools: [{ icon: { light: '/tool.svg' }, name: 'tool' }],
customTools: [{ icon: '/console/custom.svg', name: 'custom' }],
workflowTools: [{ icon: '/console/workflow.svg', name: 'workflow' }],
mcpTools: [{ icon: '/console/mcp.svg', name: 'mcp' }],
})
})
it('should skip normalization when a tool list is undefined', () => {
mockToolsState.buildInTools = undefined
render(<Tabs {...baseProps} activeTab={TabsEnum.Tools} />)
expect(screen.getByText('tools-content')).toBeInTheDocument()
})
it('should force start content to render and invalidate built-in tools after featured installs', async () => {
const user = userEvent.setup()
render(
<Tabs
{...baseProps}
activeTab={TabsEnum.Tools}
/>,
)
await user.click(screen.getByRole('button', { name: 'Install featured tool' }))
expect(screen.getByText('tools-content')).toBeInTheDocument()
expect(mockInvalidateBuiltInTools).toHaveBeenCalledTimes(1)
})
it('should render start content when blocks are hidden but forceShowStartContent is enabled', () => {
render(
<Tabs
{...baseProps}
activeTab={TabsEnum.Start}
noBlocks
forceShowStartContent
/>,
)
expect(screen.getByText('start-content')).toBeInTheDocument()
})
})

View File

@ -41,6 +41,122 @@ export type TabsProps = {
forceShowStartContent?: boolean // Force show Start content even when noBlocks=true
allowStartNodeSelection?: boolean // Allow user input option even when trigger node already exists (e.g. change-node flow or when no Start node yet).
}
const normalizeToolList = (list: ToolWithProvider[] | undefined, currentBasePath?: string) => {
if (!list || !currentBasePath)
return list
let changed = false
const normalized = list.map((provider) => {
if (typeof provider.icon !== 'string')
return provider
const shouldPrefix = provider.icon.startsWith('/')
&& !provider.icon.startsWith(`${currentBasePath}/`)
if (!shouldPrefix)
return provider
changed = true
return {
...provider,
icon: `${currentBasePath}${provider.icon}`,
}
})
return changed ? normalized : list
}
const getStoreToolUpdates = ({
state,
buildInTools,
customTools,
workflowTools,
mcpTools,
}: {
state: {
buildInTools?: ToolWithProvider[]
customTools?: ToolWithProvider[]
workflowTools?: ToolWithProvider[]
mcpTools?: ToolWithProvider[]
}
buildInTools?: ToolWithProvider[]
customTools?: ToolWithProvider[]
workflowTools?: ToolWithProvider[]
mcpTools?: ToolWithProvider[]
}) => {
const updates: Partial<typeof state> = {}
if (buildInTools !== undefined && state.buildInTools !== buildInTools)
updates.buildInTools = buildInTools
if (customTools !== undefined && state.customTools !== customTools)
updates.customTools = customTools
if (workflowTools !== undefined && state.workflowTools !== workflowTools)
updates.workflowTools = workflowTools
if (mcpTools !== undefined && state.mcpTools !== mcpTools)
updates.mcpTools = mcpTools
return updates
}
const TabHeaderItem = ({
tab,
activeTab,
onActiveTabChange,
disabledTip,
}: {
tab: TabsProps['tabs'][number]
activeTab: TabsEnum
onActiveTabChange: (activeTab: TabsEnum) => void
disabledTip: string
}) => {
const className = cn(
'relative mr-0.5 flex h-8 items-center rounded-t-lg px-3 system-sm-medium',
tab.disabled
? 'cursor-not-allowed text-text-disabled opacity-60'
: activeTab === tab.key
// eslint-disable-next-line tailwindcss/no-unknown-classes
? 'sm-no-bottom cursor-default bg-components-panel-bg text-text-accent'
: 'cursor-pointer text-text-tertiary',
)
const handleClick = () => {
if (tab.disabled || activeTab === tab.key)
return
onActiveTabChange(tab.key)
}
if (tab.disabled) {
return (
<Tooltip
key={tab.key}
position="top"
popupClassName="max-w-[200px]"
popupContent={disabledTip}
>
<div
className={className}
aria-disabled={tab.disabled}
onClick={handleClick}
>
{tab.name}
</div>
</Tooltip>
)
}
return (
<div
key={tab.key}
className={className}
aria-disabled={tab.disabled}
onClick={handleClick}
>
{tab.name}
</div>
)
}
const Tabs: FC<TabsProps> = ({
activeTab,
onActiveTabChange,
@ -71,51 +187,21 @@ const Tabs: FC<TabsProps> = ({
plugins: featuredPlugins = [],
isLoading: isFeaturedLoading,
} = useFeaturedToolsRecommendations(enable_marketplace && !inRAGPipeline)
const normalizeToolList = useMemo(() => {
return (list?: ToolWithProvider[]) => {
if (!list)
return list
if (!basePath)
return list
let changed = false
const normalized = list.map((provider) => {
if (typeof provider.icon === 'string') {
const icon = provider.icon
const shouldPrefix = Boolean(basePath)
&& icon.startsWith('/')
&& !icon.startsWith(`${basePath}/`)
if (shouldPrefix) {
changed = true
return {
...provider,
icon: `${basePath}${icon}`,
}
}
}
return provider
})
return changed ? normalized : list
}
}, [basePath])
const normalizedBuiltInTools = useMemo(() => normalizeToolList(buildInTools, basePath), [buildInTools])
const normalizedCustomTools = useMemo(() => normalizeToolList(customTools, basePath), [customTools])
const normalizedWorkflowTools = useMemo(() => normalizeToolList(workflowTools, basePath), [workflowTools])
const normalizedMcpTools = useMemo(() => normalizeToolList(mcpTools, basePath), [mcpTools])
const disabledTip = t('tabs.startDisabledTip', { ns: 'workflow' })
useEffect(() => {
workflowStore.setState((state) => {
const updates: Partial<typeof state> = {}
const normalizedBuiltIn = normalizeToolList(buildInTools)
const normalizedCustom = normalizeToolList(customTools)
const normalizedWorkflow = normalizeToolList(workflowTools)
const normalizedMCP = normalizeToolList(mcpTools)
if (normalizedBuiltIn !== undefined && state.buildInTools !== normalizedBuiltIn)
updates.buildInTools = normalizedBuiltIn
if (normalizedCustom !== undefined && state.customTools !== normalizedCustom)
updates.customTools = normalizedCustom
if (normalizedWorkflow !== undefined && state.workflowTools !== normalizedWorkflow)
updates.workflowTools = normalizedWorkflow
if (normalizedMCP !== undefined && state.mcpTools !== normalizedMCP)
updates.mcpTools = normalizedMCP
const updates = getStoreToolUpdates({
state,
buildInTools: normalizedBuiltInTools,
customTools: normalizedCustomTools,
workflowTools: normalizedWorkflowTools,
mcpTools: normalizedMcpTools,
})
if (!Object.keys(updates).length)
return state
return {
@ -123,7 +209,7 @@ const Tabs: FC<TabsProps> = ({
...updates,
}
})
}, [workflowStore, normalizeToolList, buildInTools, customTools, workflowTools, mcpTools])
}, [normalizedBuiltInTools, normalizedCustomTools, normalizedMcpTools, normalizedWorkflowTools, workflowStore])
return (
<div onClick={e => e.stopPropagation()}>
@ -131,46 +217,15 @@ const Tabs: FC<TabsProps> = ({
!noBlocks && (
<div className="relative flex bg-background-section-burn pl-1 pt-1">
{
tabs.map((tab) => {
const commonProps = {
'className': cn(
'system-sm-medium relative mr-0.5 flex h-8 items-center rounded-t-lg px-3',
tab.disabled
? 'cursor-not-allowed text-text-disabled opacity-60'
: activeTab === tab.key
? 'sm-no-bottom cursor-default bg-components-panel-bg text-text-accent'
: 'cursor-pointer text-text-tertiary',
),
'aria-disabled': tab.disabled,
'onClick': () => {
if (tab.disabled || activeTab === tab.key)
return
onActiveTabChange(tab.key)
},
} as const
if (tab.disabled) {
return (
<Tooltip
key={tab.key}
position="top"
popupClassName="max-w-[200px]"
popupContent={t('tabs.startDisabledTip', { ns: 'workflow' })}
>
<div {...commonProps}>
{tab.name}
</div>
</Tooltip>
)
}
return (
<div
key={tab.key}
{...commonProps}
>
{tab.name}
</div>
)
})
tabs.map(tab => (
<TabHeaderItem
key={tab.key}
tab={tab}
activeTab={activeTab}
onActiveTabChange={onActiveTabChange}
disabledTip={disabledTip}
/>
))
}
</div>
)
@ -219,10 +274,10 @@ const Tabs: FC<TabsProps> = ({
onSelect={onSelect}
tags={tags}
canNotSelectMultiple
buildInTools={buildInTools || []}
customTools={customTools || []}
workflowTools={workflowTools || []}
mcpTools={mcpTools || []}
buildInTools={normalizedBuiltInTools || []}
customTools={normalizedCustomTools || []}
workflowTools={normalizedWorkflowTools || []}
mcpTools={normalizedMcpTools || []}
onTagsChange={onTagsChange}
isInRAGPipeline={inRAGPipeline}
featuredPlugins={featuredPlugins}

View File

@ -0,0 +1,128 @@
import type { TriggerOption } from '../test-run-menu'
import { fireEvent, render, renderHook, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { TriggerType } from '../test-run-menu'
import {
getNormalizedShortcutKey,
OptionRow,
SingleOptionTrigger,
useShortcutMenu,
} from '../test-run-menu-helpers'
vi.mock('../shortcuts-name', () => ({
default: ({ keys }: { keys: string[] }) => <span>{keys.join('+')}</span>,
}))
const createOption = (overrides: Partial<TriggerOption> = {}): TriggerOption => ({
id: 'user-input',
type: TriggerType.UserInput,
name: 'User Input',
icon: <span>icon</span>,
enabled: true,
...overrides,
})
describe('test-run-menu helpers', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should normalize shortcut keys and render option rows with clickable shortcuts', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
const option = createOption()
expect(getNormalizedShortcutKey(new KeyboardEvent('keydown', { key: '`' }))).toBe('~')
expect(getNormalizedShortcutKey(new KeyboardEvent('keydown', { key: '1' }))).toBe('1')
render(
<OptionRow
option={option}
shortcutKey="1"
onSelect={onSelect}
/>,
)
expect(screen.getByText('1')).toBeInTheDocument()
await user.click(screen.getByText('User Input'))
expect(onSelect).toHaveBeenCalledWith(option)
})
it('should handle shortcut key presses only when the menu is open and the event is eligible', () => {
const handleSelect = vi.fn()
const option = createOption({ id: 'run-all', type: TriggerType.All, name: 'Run All' })
const { rerender, unmount } = renderHook(({ open }) => useShortcutMenu({
open,
shortcutMappings: [{ option, shortcutKey: '~' }],
handleSelect,
}), {
initialProps: { open: true },
})
fireEvent.keyDown(window, { key: '`' })
fireEvent.keyDown(window, { key: '`', altKey: true })
fireEvent.keyDown(window, { key: '`', repeat: true })
const preventedEvent = new KeyboardEvent('keydown', { key: '`', cancelable: true })
preventedEvent.preventDefault()
window.dispatchEvent(preventedEvent)
expect(handleSelect).toHaveBeenCalledTimes(1)
expect(handleSelect).toHaveBeenCalledWith(option)
rerender({ open: false })
fireEvent.keyDown(window, { key: '`' })
expect(handleSelect).toHaveBeenCalledTimes(1)
unmount()
fireEvent.keyDown(window, { key: '`' })
expect(handleSelect).toHaveBeenCalledTimes(1)
})
it('should run single options for element and non-element children unless the click is prevented', async () => {
const user = userEvent.setup()
const runSoleOption = vi.fn()
const originalOnClick = vi.fn()
const { rerender } = render(
<SingleOptionTrigger runSoleOption={runSoleOption}>
Open directly
</SingleOptionTrigger>,
)
await user.click(screen.getByText('Open directly'))
expect(runSoleOption).toHaveBeenCalledTimes(1)
rerender(
<SingleOptionTrigger runSoleOption={runSoleOption}>
<button onClick={originalOnClick}>Child trigger</button>
</SingleOptionTrigger>,
)
await user.click(screen.getByRole('button', { name: 'Child trigger' }))
expect(originalOnClick).toHaveBeenCalledTimes(1)
expect(runSoleOption).toHaveBeenCalledTimes(2)
rerender(
<SingleOptionTrigger runSoleOption={runSoleOption}>
<button
onClick={(event) => {
event.preventDefault()
originalOnClick()
}}
>
Prevented child
</button>
</SingleOptionTrigger>,
)
await user.click(screen.getByRole('button', { name: 'Prevented child' }))
expect(originalOnClick).toHaveBeenCalledTimes(2)
expect(runSoleOption).toHaveBeenCalledTimes(2)
})
})

View File

@ -0,0 +1,125 @@
import type { TestRunMenuRef, TriggerOption } from '../test-run-menu'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { act } from 'react'
import * as React from 'react'
import TestRunMenu, { TriggerType } from '../test-run-menu'
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({
children,
}: {
children: React.ReactNode
}) => <div>{children}</div>,
PortalToFollowElemTrigger: ({
children,
onClick,
}: {
children: React.ReactNode
onClick?: () => void
}) => <div onClick={onClick}>{children}</div>,
PortalToFollowElemContent: ({
children,
}: {
children: React.ReactNode
}) => <div>{children}</div>,
}))
vi.mock('../shortcuts-name', () => ({
default: ({ keys }: { keys: string[] }) => <span>{keys.join('+')}</span>,
}))
const createOption = (overrides: Partial<TriggerOption> = {}): TriggerOption => ({
id: 'user-input',
type: TriggerType.UserInput,
name: 'User Input',
icon: <span>icon</span>,
enabled: true,
...overrides,
})
describe('TestRunMenu', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should run the only enabled option directly and preserve the child click handler', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
const originalOnClick = vi.fn()
render(
<TestRunMenu
options={{
userInput: createOption(),
triggers: [],
}}
onSelect={onSelect}
>
<button onClick={originalOnClick}>Run now</button>
</TestRunMenu>,
)
await user.click(screen.getByRole('button', { name: 'Run now' }))
expect(originalOnClick).toHaveBeenCalledTimes(1)
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'user-input' }))
})
it('should expose toggle via ref and select a shortcut when multiple options are available', () => {
const onSelect = vi.fn()
const Harness = () => {
const ref = React.useRef<TestRunMenuRef>(null)
return (
<>
<button onClick={() => ref.current?.toggle()}>Toggle via ref</button>
<TestRunMenu
ref={ref}
options={{
userInput: createOption(),
runAll: createOption({ id: 'run-all', type: TriggerType.All, name: 'Run All' }),
triggers: [createOption({ id: 'trigger-1', type: TriggerType.Webhook, name: 'Webhook Trigger' })],
}}
onSelect={onSelect}
>
<button>Open menu</button>
</TestRunMenu>
</>
)
}
render(<Harness />)
act(() => {
fireEvent.click(screen.getByRole('button', { name: 'Toggle via ref' }))
})
fireEvent.keyDown(window, { key: '0' })
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'run-all' }))
expect(screen.getByText('~')).toBeInTheDocument()
})
it('should ignore disabled options in the rendered menu', async () => {
const user = userEvent.setup()
render(
<TestRunMenu
options={{
userInput: createOption({ enabled: false }),
runAll: createOption({ id: 'run-all', type: TriggerType.All, name: 'Run All' }),
triggers: [createOption({ id: 'trigger-1', type: TriggerType.Webhook, name: 'Webhook Trigger' })],
}}
onSelect={vi.fn()}
>
<button>Open menu</button>
</TestRunMenu>,
)
await user.click(screen.getByRole('button', { name: 'Open menu' }))
expect(screen.queryByText('User Input')).not.toBeInTheDocument()
expect(screen.getByText('Webhook Trigger')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,118 @@
/* eslint-disable react-refresh/only-export-components */
import type { MouseEvent, MouseEventHandler, ReactElement } from 'react'
import type { TriggerOption } from './test-run-menu'
import {
cloneElement,
isValidElement,
useEffect,
} from 'react'
import ShortcutsName from '../shortcuts-name'
export type ShortcutMapping = {
option: TriggerOption
shortcutKey: string
}
export const getNormalizedShortcutKey = (event: KeyboardEvent) => {
return event.key === '`' ? '~' : event.key
}
export const OptionRow = ({
option,
shortcutKey,
onSelect,
}: {
option: TriggerOption
shortcutKey?: string
onSelect: (option: TriggerOption) => void
}) => {
return (
<div
key={option.id}
className="flex cursor-pointer items-center rounded-lg px-3 py-1.5 text-text-secondary system-md-regular hover:bg-state-base-hover"
onClick={() => onSelect(option)}
>
<div className="flex min-w-0 flex-1 items-center">
<div className="flex h-6 w-6 shrink-0 items-center justify-center">
{option.icon}
</div>
<span className="ml-2 truncate">{option.name}</span>
</div>
{shortcutKey && (
<ShortcutsName keys={[shortcutKey]} className="ml-2" textColor="secondary" />
)}
</div>
)
}
export const useShortcutMenu = ({
open,
shortcutMappings,
handleSelect,
}: {
open: boolean
shortcutMappings: ShortcutMapping[]
handleSelect: (option: TriggerOption) => void
}) => {
useEffect(() => {
if (!open)
return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented || event.repeat || event.altKey || event.ctrlKey || event.metaKey)
return
const normalizedKey = getNormalizedShortcutKey(event)
const mapping = shortcutMappings.find(({ shortcutKey }) => shortcutKey === normalizedKey)
if (mapping) {
event.preventDefault()
handleSelect(mapping.option)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => {
window.removeEventListener('keydown', handleKeyDown)
}
}, [handleSelect, open, shortcutMappings])
}
export const SingleOptionTrigger = ({
children,
runSoleOption,
}: {
children: React.ReactNode
runSoleOption: () => void
}) => {
const handleRunClick = (event?: MouseEvent<HTMLElement>) => {
if (event?.defaultPrevented)
return
runSoleOption()
}
if (isValidElement(children)) {
const childElement = children as ReactElement<{ onClick?: MouseEventHandler<HTMLElement> }>
const originalOnClick = childElement.props?.onClick
// eslint-disable-next-line react/no-clone-element
return cloneElement(childElement, {
onClick: (event: MouseEvent<HTMLElement>) => {
if (typeof originalOnClick === 'function')
originalOnClick(event)
if (event?.defaultPrevented)
return
runSoleOption()
},
})
}
return (
<span onClick={handleRunClick}>
{children}
</span>
)
}

View File

@ -1,22 +1,8 @@
import type { MouseEvent, MouseEventHandler, ReactElement } from 'react'
import {
cloneElement,
forwardRef,
isValidElement,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useState,
} from 'react'
import type { ShortcutMapping } from './test-run-menu-helpers'
import { forwardRef, useCallback, useImperativeHandle, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import ShortcutsName from '../shortcuts-name'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import { OptionRow, SingleOptionTrigger, useShortcutMenu } from './test-run-menu-helpers'
export enum TriggerType {
UserInput = 'user_input',
@ -52,9 +38,24 @@ export type TestRunMenuRef = {
toggle: () => void
}
type ShortcutMapping = {
option: TriggerOption
shortcutKey: string
const getEnabledOptions = (options: TestRunOptions) => {
const flattened: TriggerOption[] = []
if (options.userInput)
flattened.push(options.userInput)
if (options.runAll)
flattened.push(options.runAll)
flattened.push(...options.triggers)
return flattened.filter(option => option.enabled !== false)
}
const getMenuVisibility = (options: TestRunOptions) => {
return {
hasUserInput: Boolean(options.userInput?.enabled !== false && options.userInput),
hasTriggers: options.triggers.some(trigger => trigger.enabled !== false),
hasRunAll: Boolean(options.runAll?.enabled !== false && options.runAll),
}
}
const buildShortcutMappings = (options: TestRunOptions): ShortcutMapping[] => {
@ -76,6 +77,7 @@ const buildShortcutMappings = (options: TestRunOptions): ShortcutMapping[] => {
return mappings
}
// eslint-disable-next-line react/no-forward-ref
const TestRunMenu = forwardRef<TestRunMenuRef, TestRunMenuProps>(({
options,
onSelect,
@ -97,17 +99,7 @@ const TestRunMenu = forwardRef<TestRunMenuRef, TestRunMenuProps>(({
setOpen(false)
}, [onSelect])
const enabledOptions = useMemo(() => {
const flattened: TriggerOption[] = []
if (options.userInput)
flattened.push(options.userInput)
if (options.runAll)
flattened.push(options.runAll)
flattened.push(...options.triggers)
return flattened.filter(option => option.enabled !== false)
}, [options])
const enabledOptions = useMemo(() => getEnabledOptions(options), [options])
const hasSingleEnabledOption = enabledOptions.length === 1
const soleEnabledOption = hasSingleEnabledOption ? enabledOptions[0] : undefined
@ -117,6 +109,12 @@ const TestRunMenu = forwardRef<TestRunMenuRef, TestRunMenuProps>(({
handleSelect(soleEnabledOption)
}, [handleSelect, soleEnabledOption])
useShortcutMenu({
open,
shortcutMappings,
handleSelect,
})
useImperativeHandle(ref, () => ({
toggle: () => {
if (hasSingleEnabledOption) {
@ -128,84 +126,17 @@ const TestRunMenu = forwardRef<TestRunMenuRef, TestRunMenuProps>(({
},
}), [hasSingleEnabledOption, runSoleOption])
useEffect(() => {
if (!open)
return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented || event.repeat || event.altKey || event.ctrlKey || event.metaKey)
return
const normalizedKey = event.key === '`' ? '~' : event.key
const mapping = shortcutMappings.find(({ shortcutKey }) => shortcutKey === normalizedKey)
if (mapping) {
event.preventDefault()
handleSelect(mapping.option)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => {
window.removeEventListener('keydown', handleKeyDown)
}
}, [handleSelect, open, shortcutMappings])
const renderOption = (option: TriggerOption) => {
const shortcutKey = shortcutKeyById.get(option.id)
return (
<div
key={option.id}
className="system-md-regular flex cursor-pointer items-center rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover"
onClick={() => handleSelect(option)}
>
<div className="flex min-w-0 flex-1 items-center">
<div className="flex h-6 w-6 shrink-0 items-center justify-center">
{option.icon}
</div>
<span className="ml-2 truncate">{option.name}</span>
</div>
{shortcutKey && (
<ShortcutsName keys={[shortcutKey]} className="ml-2" textColor="secondary" />
)}
</div>
)
return <OptionRow option={option} shortcutKey={shortcutKeyById.get(option.id)} onSelect={handleSelect} />
}
const hasUserInput = !!options.userInput && options.userInput.enabled !== false
const hasTriggers = options.triggers.some(trigger => trigger.enabled !== false)
const hasRunAll = !!options.runAll && options.runAll.enabled !== false
const { hasUserInput, hasTriggers, hasRunAll } = useMemo(() => getMenuVisibility(options), [options])
if (hasSingleEnabledOption && soleEnabledOption) {
const handleRunClick = (event?: MouseEvent<HTMLElement>) => {
if (event?.defaultPrevented)
return
runSoleOption()
}
if (isValidElement(children)) {
const childElement = children as ReactElement<{ onClick?: MouseEventHandler<HTMLElement> }>
const originalOnClick = childElement.props?.onClick
return cloneElement(childElement, {
onClick: (event: MouseEvent<HTMLElement>) => {
if (typeof originalOnClick === 'function')
originalOnClick(event)
if (event?.defaultPrevented)
return
runSoleOption()
},
})
}
return (
<span onClick={handleRunClick}>
<SingleOptionTrigger runSoleOption={runSoleOption}>
{children}
</span>
</SingleOptionTrigger>
)
}

View File

@ -291,6 +291,17 @@ describe('useEdgesInteractions', () => {
expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete')
})
it('handleEdgeDeleteById should ignore unknown edge ids', () => {
const { result } = renderEdgesInteractions()
act(() => {
result.current.handleEdgeDeleteById('missing-edge')
})
expect(result.current.edges).toHaveLength(2)
expect(mockSaveStateToHistory).not.toHaveBeenCalled()
})
it('handleEdgeDeleteByDeleteBranch should remove edges for the given branch', async () => {
const { result, store } = renderEdgesInteractions({
initialStoreState: {
@ -335,6 +346,46 @@ describe('useEdgesInteractions', () => {
})
})
it('handleEdgeSourceHandleChange should clear edgeMenu and save history for affected edges', async () => {
const { result, store } = renderEdgesInteractions({
edges: [
createEdge({
id: 'n1-old-handle-n2-target',
source: 'n1',
target: 'n2',
sourceHandle: 'old-handle',
targetHandle: 'target',
data: {},
}),
],
initialStoreState: {
edgeMenu: { clientX: 120, clientY: 60, edgeId: 'n1-old-handle-n2-target' },
},
})
act(() => {
result.current.handleEdgeSourceHandleChange('n1', 'old-handle', 'new-handle')
})
await waitFor(() => {
expect(result.current.edges[0]?.sourceHandle).toBe('new-handle')
})
expect(store.getState().edgeMenu).toBeUndefined()
expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeSourceHandleChange')
})
it('handleEdgeSourceHandleChange should do nothing when no edges use the old handle', () => {
const { result } = renderEdgesInteractions()
act(() => {
result.current.handleEdgeSourceHandleChange('n1', 'missing-handle', 'new-handle')
})
expect(result.current.edges.map(edge => edge.id)).toEqual(['e1', 'e2'])
expect(mockSaveStateToHistory).not.toHaveBeenCalled()
})
describe('read-only mode', () => {
beforeEach(() => {
mockReadOnly = true
@ -412,5 +463,27 @@ describe('useEdgesInteractions', () => {
expect(result.current.edges).toHaveLength(2)
})
it('handleEdgeSourceHandleChange should do nothing', () => {
const { result } = renderEdgesInteractions({
edges: [
createEdge({
id: 'n1-old-handle-n2-target',
source: 'n1',
target: 'n2',
sourceHandle: 'old-handle',
targetHandle: 'target',
data: {},
}),
],
})
act(() => {
result.current.handleEdgeSourceHandleChange('n1', 'old-handle', 'new-handle')
})
expect(result.current.edges[0]?.sourceHandle).toBe('old-handle')
expect(mockSaveStateToHistory).not.toHaveBeenCalled()
})
})
})

View File

@ -191,4 +191,60 @@ describe('useHelpline', () => {
expect(store.getState().helpLineHorizontal).toBeUndefined()
})
it('should extend horizontal helpline when dragging node is before the first aligned node', () => {
rfState.nodes = [
{ id: 'a', position: { x: 300, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.LLM } },
{ id: 'b', position: { x: 600, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.LLM } },
]
const { result, store } = renderWorkflowHook(() => useHelpline())
result.current.handleSetHelpline(makeNode({ id: 'dragging', position: { x: 100, y: 100 } }))
expect(store.getState().helpLineHorizontal).toEqual({
top: 100,
left: 100,
width: 440,
})
})
it('should extend vertical helpline when dragging node is below the aligned nodes', () => {
rfState.nodes = [
{ id: 'a', position: { x: 120, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.LLM } },
{ id: 'b', position: { x: 120, y: 260 }, width: 240, height: 100, data: { type: BlockEnum.LLM } },
]
const { result, store } = renderWorkflowHook(() => useHelpline())
result.current.handleSetHelpline(makeNode({ id: 'dragging', position: { x: 120, y: 420 } }))
expect(store.getState().helpLineVertical).toEqual({
top: 100,
left: 120,
height: 420,
})
})
it('should extend horizontal helpline using entry node width when a start node is after the aligned nodes', () => {
rfState.nodes = [
{ id: 'aligned', position: { x: 100, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.LLM } },
]
const { result, store } = renderWorkflowHook(() => useHelpline())
result.current.handleSetHelpline(makeNode({
id: 'start-node',
position: { x: 500, y: 79 },
width: 240,
height: 100,
data: { type: BlockEnum.Start },
}))
expect(store.getState().helpLineHorizontal).toEqual({
top: 100,
left: 100,
width: 640,
})
})
})

View File

@ -11,6 +11,8 @@ vi.mock('@/service/use-tools', async () =>
(await import('../../__tests__/service-mock-factory')).createToolServiceMock({
buildInTools: [{ id: 'builtin-1', name: 'builtin', icon: '/builtin.svg', icon_dark: '/builtin-dark.svg', plugin_id: 'p1' }],
customTools: [{ id: 'custom-1', name: 'custom', icon: '/custom.svg', plugin_id: 'p2' }],
workflowTools: [{ id: 'workflow-1', name: 'workflow-tool', icon: '/workflow.svg', plugin_id: 'p3' }],
mcpTools: [{ id: 'mcp-1', name: 'mcp-tool', icon: '/mcp.svg', plugin_id: 'p4' }],
}))
vi.mock('@/service/use-triggers', async () =>
@ -18,8 +20,9 @@ vi.mock('@/service/use-triggers', async () =>
triggerPlugins: [{ id: 'trigger-1', icon: '/trigger.svg', icon_dark: '/trigger-dark.svg' }],
}))
let mockTheme = 'light'
vi.mock('@/hooks/use-theme', () => ({
default: () => ({ theme: 'light' }),
default: () => ({ theme: mockTheme }),
}))
vi.mock('@/utils', () => ({
@ -31,6 +34,7 @@ const baseNodeData = { title: '', desc: '' }
describe('useToolIcon', () => {
beforeEach(() => {
resetReactFlowMockState()
mockTheme = 'light'
})
it('should return empty string when no data', () => {
@ -79,6 +83,60 @@ describe('useToolIcon', () => {
expect(result.current).toBe('/custom.svg')
})
it('should use dark trigger and provider icons when available', () => {
mockTheme = 'dark'
const triggerData = {
...baseNodeData,
type: BlockEnum.TriggerPlugin,
plugin_id: 'trigger-1',
provider_id: 'trigger-1',
provider_name: 'trigger-1',
}
const providerFallbackData = {
...baseNodeData,
type: BlockEnum.Tool,
provider_type: CollectionType.builtIn,
provider_id: 'missing-provider',
provider_name: 'missing',
provider_icon: '/fallback.svg',
provider_icon_dark: '/fallback-dark.svg',
}
expect(renderWorkflowHook(() => useToolIcon(triggerData)).result.current).toBe('/trigger-dark.svg')
expect(renderWorkflowHook(() => useToolIcon(providerFallbackData)).result.current).toBe('/fallback-dark.svg')
})
it('should resolve workflow, mcp and datasource icons', () => {
const workflowData = {
...baseNodeData,
type: BlockEnum.Tool,
provider_type: CollectionType.workflow,
provider_id: 'workflow-1',
provider_name: 'workflow-tool',
}
const mcpData = {
...baseNodeData,
type: BlockEnum.Tool,
provider_type: CollectionType.mcp,
provider_id: 'mcp-1',
provider_name: 'mcp-tool',
}
const dataSourceData = {
...baseNodeData,
type: BlockEnum.DataSource,
plugin_id: 'datasource-1',
}
expect(renderWorkflowHook(() => useToolIcon(workflowData)).result.current).toBe('/workflow.svg')
expect(renderWorkflowHook(() => useToolIcon(mcpData)).result.current).toBe('/mcp.svg')
expect(renderWorkflowHook(() => useToolIcon(dataSourceData), {
initialStoreState: {
dataSourceList: [{ id: 'ds-1', plugin_id: 'datasource-1', icon: '/datasource.svg' }] as never,
},
}).result.current).toBe('/datasource.svg')
})
it('should fallback to provider_icon when no collection match', () => {
const data = {
...baseNodeData,
@ -157,6 +215,29 @@ describe('useGetToolIcon', () => {
expect(icon).toBe('/builtin.svg')
})
it('should prefer workflow store collections over query collections', () => {
const { result, store } = renderWorkflowHook(() => useGetToolIcon(), {
initialStoreState: {
buildInTools: [{ id: 'override-1', name: 'override', icon: '/override.svg', plugin_id: 'p1' }] as never,
dataSourceList: [{ id: 'ds-1', plugin_id: 'datasource-1', icon: '/datasource-store.svg' }] as never,
},
})
expect(result.current({
...baseNodeData,
type: BlockEnum.Tool,
provider_type: CollectionType.builtIn,
provider_id: 'override-1',
provider_name: 'override',
})).toBe('/override.svg')
expect(result.current({
...baseNodeData,
type: BlockEnum.DataSource,
plugin_id: 'datasource-1',
})).toBe('/datasource-store.svg')
expect(store.getState().buildInTools).toHaveLength(1)
})
it('should return undefined for unmatched node type', () => {
const { result } = renderWorkflowHook(() => useGetToolIcon())

View File

@ -0,0 +1,329 @@
import { act } from '@testing-library/react'
import {
createLoopNode,
createNode,
} from '../../__tests__/fixtures'
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { ControlMode } from '../../types'
import {
useWorkflowCanvasMaximize,
useWorkflowInteractions,
useWorkflowMoveMode,
useWorkflowOrganize,
useWorkflowUpdate,
useWorkflowZoom,
} from '../use-workflow-interactions'
import * as workflowInteractionExports from '../use-workflow-interactions'
const mockSetViewport = vi.hoisted(() => vi.fn())
const mockSetNodes = vi.hoisted(() => vi.fn())
const mockZoomIn = vi.hoisted(() => vi.fn())
const mockZoomOut = vi.hoisted(() => vi.fn())
const mockZoomTo = vi.hoisted(() => vi.fn())
const mockFitView = vi.hoisted(() => vi.fn())
const mockEventEmit = vi.hoisted(() => vi.fn())
const mockHandleSelectionCancel = vi.hoisted(() => vi.fn())
const mockHandleNodeCancelRunningStatus = vi.hoisted(() => vi.fn())
const mockHandleEdgeCancelRunningStatus = vi.hoisted(() => vi.fn())
const mockHandleSyncWorkflowDraft = vi.hoisted(() => vi.fn())
const mockSaveStateToHistory = vi.hoisted(() => vi.fn())
const mockGetLayoutForChildNodes = vi.hoisted(() => vi.fn())
const mockGetLayoutByDagre = vi.hoisted(() => vi.fn())
const mockInitialNodes = vi.hoisted(() => vi.fn((nodes: unknown[], _edges: unknown[]) => nodes))
const mockInitialEdges = vi.hoisted(() => vi.fn((edges: unknown[], _nodes: unknown[]) => edges))
const runtimeState = vi.hoisted(() => ({
nodes: [] as ReturnType<typeof createNode>[],
edges: [] as { id: string, source: string, target: string }[],
nodesReadOnly: false,
workflowReadOnly: false,
}))
vi.mock('reactflow', () => ({
Position: { Left: 'left', Right: 'right', Top: 'top', Bottom: 'bottom' },
useStoreApi: () => ({
getState: () => ({
getNodes: () => runtimeState.nodes,
edges: runtimeState.edges,
setNodes: mockSetNodes,
}),
setState: vi.fn(),
}),
useReactFlow: () => ({
setViewport: mockSetViewport,
zoomIn: mockZoomIn,
zoomOut: mockZoomOut,
zoomTo: mockZoomTo,
fitView: mockFitView,
}),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
emit: (...args: unknown[]) => mockEventEmit(...args),
},
}),
}))
vi.mock('../use-workflow', () => ({
useNodesReadOnly: () => ({
getNodesReadOnly: () => runtimeState.nodesReadOnly,
nodesReadOnly: runtimeState.nodesReadOnly,
}),
useWorkflowReadOnly: () => ({
getWorkflowReadOnly: () => runtimeState.workflowReadOnly,
}),
}))
vi.mock('../use-selection-interactions', () => ({
useSelectionInteractions: () => ({
handleSelectionCancel: (...args: unknown[]) => mockHandleSelectionCancel(...args),
}),
}))
vi.mock('../use-nodes-interactions-without-sync', () => ({
useNodesInteractionsWithoutSync: () => ({
handleNodeCancelRunningStatus: (...args: unknown[]) => mockHandleNodeCancelRunningStatus(...args),
}),
}))
vi.mock('../use-edges-interactions-without-sync', () => ({
useEdgesInteractionsWithoutSync: () => ({
handleEdgeCancelRunningStatus: (...args: unknown[]) => mockHandleEdgeCancelRunningStatus(...args),
}),
}))
vi.mock('../use-nodes-sync-draft', () => ({
useNodesSyncDraft: () => ({
handleSyncWorkflowDraft: (...args: unknown[]) => mockHandleSyncWorkflowDraft(...args),
}),
}))
vi.mock('../use-workflow-history', () => ({
useWorkflowHistory: () => ({
saveStateToHistory: (...args: unknown[]) => mockSaveStateToHistory(...args),
}),
WorkflowHistoryEvent: {
LayoutOrganize: 'LayoutOrganize',
},
}))
vi.mock('../../utils', async importOriginal => ({
...(await importOriginal<typeof import('../../utils')>()),
getLayoutForChildNodes: (...args: unknown[]) => mockGetLayoutForChildNodes(...args),
getLayoutByDagre: (...args: unknown[]) => mockGetLayoutByDagre(...args),
initialNodes: (nodes: unknown[], edges: unknown[]) => mockInitialNodes(nodes, edges),
initialEdges: (edges: unknown[], nodes: unknown[]) => mockInitialEdges(edges, nodes),
}))
describe('use-workflow-interactions exports', () => {
it('re-exports the split workflow interaction hooks', () => {
expect(workflowInteractionExports.useWorkflowInteractions).toBeTypeOf('function')
expect(workflowInteractionExports.useWorkflowMoveMode).toBeTypeOf('function')
expect(workflowInteractionExports.useWorkflowOrganize).toBeTypeOf('function')
expect(workflowInteractionExports.useWorkflowZoom).toBeTypeOf('function')
expect(workflowInteractionExports.useWorkflowUpdate).toBeTypeOf('function')
expect(workflowInteractionExports.useWorkflowCanvasMaximize).toBeTypeOf('function')
})
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
runtimeState.nodes = []
runtimeState.edges = []
runtimeState.nodesReadOnly = false
runtimeState.workflowReadOnly = false
})
afterEach(() => {
vi.useRealTimers()
})
it('useWorkflowInteractions should close debug panel and clear running status', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowInteractions(), {
initialStoreState: {
showDebugAndPreviewPanel: true,
workflowRunningData: { task_id: 'task-1' } as never,
},
})
act(() => {
result.current.handleCancelDebugAndPreviewPanel()
})
expect(store.getState().showDebugAndPreviewPanel).toBe(false)
expect(store.getState().workflowRunningData).toBeUndefined()
expect(mockHandleNodeCancelRunningStatus).toHaveBeenCalled()
expect(mockHandleEdgeCancelRunningStatus).toHaveBeenCalled()
})
it('useWorkflowMoveMode should switch pointer and hand modes when editable', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowMoveMode(), {
initialStoreState: {
controlMode: ControlMode.Pointer,
},
})
act(() => {
result.current.handleModeHand()
})
expect(store.getState().controlMode).toBe(ControlMode.Hand)
expect(mockHandleSelectionCancel).toHaveBeenCalled()
act(() => {
result.current.handleModePointer()
})
expect(store.getState().controlMode).toBe(ControlMode.Pointer)
})
it('useWorkflowOrganize should resize containers, layout nodes and sync draft', async () => {
runtimeState.nodes = [
createLoopNode({
id: 'loop-node',
width: 200,
height: 160,
}),
createNode({
id: 'loop-child',
parentId: 'loop-node',
position: { x: 20, y: 20 },
width: 100,
height: 60,
}),
createNode({
id: 'top-node',
position: { x: 400, y: 0 },
}),
]
runtimeState.edges = []
mockGetLayoutForChildNodes.mockResolvedValue({
bounds: { minX: 0, minY: 0, maxX: 320, maxY: 220 },
nodes: new Map([
['loop-child', { x: 40, y: 60, width: 100, height: 60 }],
]),
})
mockGetLayoutByDagre.mockResolvedValue({
nodes: new Map([
['loop-node', { x: 10, y: 20, width: 360, height: 260, layer: 0 }],
['top-node', { x: 500, y: 30, width: 240, height: 100, layer: 0 }],
]),
})
const { result } = renderWorkflowHook(() => useWorkflowOrganize())
await act(async () => {
await result.current.handleLayout()
})
act(() => {
vi.runAllTimers()
})
expect(mockSetNodes).toHaveBeenCalledTimes(1)
const nextNodes = mockSetNodes.mock.calls[0][0]
expect(nextNodes.find((node: { id: string }) => node.id === 'loop-node')).toEqual(expect.objectContaining({
width: expect.any(Number),
height: expect.any(Number),
position: { x: 10, y: 20 },
}))
expect(nextNodes.find((node: { id: string }) => node.id === 'loop-child')).toEqual(expect.objectContaining({
position: { x: 100, y: 120 },
}))
expect(mockSetViewport).toHaveBeenCalledWith({ x: 0, y: 0, zoom: 0.7 })
expect(mockSaveStateToHistory).toHaveBeenCalledWith('LayoutOrganize')
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalled()
})
it('useWorkflowZoom should run zoom actions and sync draft when editable', () => {
const { result } = renderWorkflowHook(() => useWorkflowZoom())
act(() => {
result.current.handleFitView()
result.current.handleBackToOriginalSize()
result.current.handleSizeToHalf()
result.current.handleZoomOut()
result.current.handleZoomIn()
})
expect(mockFitView).toHaveBeenCalled()
expect(mockZoomTo).toHaveBeenCalledWith(1)
expect(mockZoomTo).toHaveBeenCalledWith(0.5)
expect(mockZoomOut).toHaveBeenCalled()
expect(mockZoomIn).toHaveBeenCalled()
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledTimes(5)
})
it('should skip move, zoom, organize and maximize actions when read-only', async () => {
runtimeState.nodesReadOnly = true
runtimeState.workflowReadOnly = true
runtimeState.nodes = [createNode({ id: 'n1' })]
const moveMode = renderWorkflowHook(() => useWorkflowMoveMode(), {
initialStoreState: { controlMode: ControlMode.Pointer },
})
const zoom = renderWorkflowHook(() => useWorkflowZoom())
const organize = renderWorkflowHook(() => useWorkflowOrganize())
const maximize = renderWorkflowHook(() => useWorkflowCanvasMaximize())
act(() => {
moveMode.result.current.handleModeHand()
moveMode.result.current.handleModePointer()
zoom.result.current.handleFitView()
maximize.result.current.handleToggleMaximizeCanvas()
})
await act(async () => {
await organize.result.current.handleLayout()
})
expect(moveMode.store.getState().controlMode).toBe(ControlMode.Pointer)
expect(mockHandleSelectionCancel).not.toHaveBeenCalled()
expect(mockFitView).not.toHaveBeenCalled()
expect(mockSetViewport).not.toHaveBeenCalled()
expect(localStorage.getItem('workflow-canvas-maximize')).toBeNull()
})
it('useWorkflowUpdate should emit initialized data and only set valid viewport', () => {
const { result } = renderWorkflowHook(() => useWorkflowUpdate())
act(() => {
result.current.handleUpdateWorkflowCanvas({
nodes: [createNode({ id: 'n1' })],
edges: [],
viewport: { x: 10, y: 20, zoom: 0.5 },
} as never)
result.current.handleUpdateWorkflowCanvas({
nodes: [],
edges: [],
viewport: { x: 'bad' } as never,
})
})
expect(mockInitialNodes).toHaveBeenCalled()
expect(mockInitialEdges).toHaveBeenCalled()
expect(mockEventEmit).toHaveBeenCalledWith(expect.objectContaining({
type: 'WORKFLOW_DATA_UPDATE',
}))
expect(mockSetViewport).toHaveBeenCalledTimes(1)
expect(mockSetViewport).toHaveBeenCalledWith({ x: 10, y: 20, zoom: 0.5 })
})
it('useWorkflowCanvasMaximize should toggle store and emit event', () => {
localStorage.removeItem('workflow-canvas-maximize')
const { result, store } = renderWorkflowHook(() => useWorkflowCanvasMaximize(), {
initialStoreState: {
maximizeCanvas: false,
},
})
act(() => {
result.current.handleToggleMaximizeCanvas()
})
expect(store.getState().maximizeCanvas).toBe(true)
expect(localStorage.getItem('workflow-canvas-maximize')).toBe('true')
expect(mockEventEmit).toHaveBeenCalledWith({
type: 'workflow-canvas-maximize',
payload: true,
})
})
})

View File

@ -0,0 +1,123 @@
import { BlockEnum } from '../../types'
import {
applyContainerSizeChanges,
applyLayoutToNodes,
createLayerMap,
getContainerSizeChanges,
getLayoutContainerNodes,
} from '../use-workflow-organize.helpers'
type TestNode = {
id: string
type: string
parentId?: string
position: { x: number, y: number }
width: number
height: number
data: {
type: BlockEnum
title: string
desc: string
width?: number
height?: number
}
}
const createNode = (overrides: Record<string, unknown> = {}) => ({
id: 'node',
type: 'custom',
position: { x: 0, y: 0 },
width: 100,
height: 80,
data: { type: BlockEnum.Code, title: 'Code', desc: '' },
...overrides,
}) as TestNode
describe('use-workflow-organize helpers', () => {
it('filters top-level container nodes and computes size changes', () => {
const containers = getLayoutContainerNodes([
createNode({ id: 'loop', data: { type: BlockEnum.Loop } }),
createNode({ id: 'iteration', data: { type: BlockEnum.Iteration } }),
createNode({ id: 'nested-loop', parentId: 'loop', data: { type: BlockEnum.Loop } }),
createNode({ id: 'code', data: { type: BlockEnum.Code } }),
])
expect(containers.map(node => node.id)).toEqual(['loop', 'iteration'])
const sizeChanges = getContainerSizeChanges(containers, {
loop: {
bounds: { minX: 10, minY: 20, maxX: 180, maxY: 150 },
nodes: new Map([['child', { x: 10, y: 20, width: 50, height: 40 }]]),
} as unknown as Parameters<typeof getContainerSizeChanges>[1][string],
})
expect(sizeChanges.loop).toEqual({ width: 290, height: 250 })
expect(sizeChanges.iteration).toBeUndefined()
})
it('creates aligned layers and applies layout positions to root and child nodes', () => {
const rootNodes = [
createNode({ id: 'root-a' }),
createNode({ id: 'root-b' }),
createNode({ id: 'loop', data: { type: BlockEnum.Loop }, width: 200, height: 180 }),
createNode({ id: 'loop-child', parentId: 'loop' }),
]
const layout = {
bounds: { minX: 0, minY: 0, maxX: 400, maxY: 300 },
nodes: new Map([
['root-a', { x: 10, y: 100, width: 120, height: 40, layer: 0 }],
['root-b', { x: 210, y: 120, width: 80, height: 80, layer: 0 }],
['loop', { x: 320, y: 40, width: 200, height: 180, layer: 1 }],
]),
} as unknown as Parameters<typeof createLayerMap>[0]
const childLayoutsMap = {
loop: {
bounds: { minX: 50, minY: 25, maxX: 180, maxY: 90 },
nodes: new Map([['loop-child', { x: 100, y: 45, width: 80, height: 40 }]]),
},
} as unknown as Parameters<typeof applyLayoutToNodes>[0]['childLayoutsMap']
const layerMap = createLayerMap(layout)
expect(layerMap.get(0)).toEqual({ minY: 100, maxHeight: 80 })
const resized = applyContainerSizeChanges(rootNodes, { loop: { width: 260, height: 220 } })
expect(resized.find(node => node.id === 'loop')).toEqual(expect.objectContaining({
width: 260,
height: 220,
data: expect.objectContaining({ width: 260, height: 220 }),
}))
const laidOut = applyLayoutToNodes({
nodes: rootNodes,
layout,
parentNodes: [rootNodes[2]],
childLayoutsMap,
})
expect(laidOut.find(node => node.id === 'root-b')?.position).toEqual({ x: 210, y: 100 })
expect(laidOut.find(node => node.id === 'loop-child')?.position).toEqual({ x: 110, y: 80 })
})
it('keeps original positions when layer or child layout data is missing', () => {
const nodes = [
createNode({ id: 'root-a', position: { x: 1, y: 2 } }),
createNode({ id: 'root-b', position: { x: 3, y: 4 } }),
createNode({ id: 'loop', data: { type: BlockEnum.Loop }, position: { x: 5, y: 6 } }),
createNode({ id: 'loop-child', parentId: 'loop', position: { x: 7, y: 8 } }),
]
const layout = {
bounds: { minX: 0, minY: 0, maxX: 100, maxY: 100 },
nodes: new Map([
['root-a', { x: 20, y: 30, width: 50, height: 20 }],
]),
} as unknown as Parameters<typeof applyLayoutToNodes>[0]['layout']
const laidOut = applyLayoutToNodes({
nodes,
layout,
parentNodes: [nodes[2]],
childLayoutsMap: {},
})
expect(laidOut.find(node => node.id === 'root-a')?.position).toEqual({ x: 20, y: 30 })
expect(laidOut.find(node => node.id === 'root-b')?.position).toEqual({ x: 3, y: 4 })
expect(laidOut.find(node => node.id === 'loop-child')?.position).toEqual({ x: 7, y: 8 })
})
})

View File

@ -0,0 +1,77 @@
import type { Edge, EdgeChange } from 'reactflow'
import type { Node } from '../types'
import { produce } from 'immer'
import { getNodesConnectedSourceOrTargetHandleIdsMap } from '../utils'
export const applyConnectedHandleNodeData = (
nodes: Node[],
edgeChanges: Parameters<typeof getNodesConnectedSourceOrTargetHandleIdsMap>[0],
) => {
const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap(edgeChanges, nodes)
return produce(nodes, (draft: Node[]) => {
draft.forEach((node) => {
if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) {
node.data = {
...node.data,
...nodesConnectedSourceOrTargetHandleIdsMap[node.id],
}
}
})
})
}
export const clearEdgeMenuIfNeeded = ({
edgeMenu,
edgeIds,
}: {
edgeMenu?: {
edgeId: string
}
edgeIds: string[]
}) => {
return !!(edgeMenu && edgeIds.includes(edgeMenu.edgeId))
}
export const updateEdgeHoverState = (
edges: Edge[],
edgeId: string,
hovering: boolean,
) => produce(edges, (draft) => {
const currentEdge = draft.find(edge => edge.id === edgeId)
if (currentEdge)
currentEdge.data._hovering = hovering
})
export const updateEdgeSelectionState = (
edges: Edge[],
changes: EdgeChange[],
) => produce(edges, (draft) => {
changes.forEach((change) => {
if (change.type === 'select') {
const currentEdge = draft.find(edge => edge.id === change.id)
if (currentEdge)
currentEdge.selected = change.selected
}
})
})
export const buildContextMenuEdges = (
edges: Edge[],
edgeId: string,
) => produce(edges, (draft) => {
draft.forEach((item) => {
item.selected = item.id === edgeId
if (item.data._isBundled)
item.data._isBundled = false
})
})
export const clearNodeSelectionState = (nodes: Node[]) => produce(nodes, (draft: Node[]) => {
draft.forEach((node) => {
node.data.selected = false
if (node.data._isBundled)
node.data._isBundled = false
node.selected = false
})
})

View File

@ -2,16 +2,20 @@ import type {
EdgeMouseHandler,
OnEdgesChange,
} from 'reactflow'
import type {
Node,
} from '../types'
import { produce } from 'immer'
import { useCallback } from 'react'
import {
useStoreApi,
} from 'reactflow'
import { useWorkflowStore } from '../store'
import { getNodesConnectedSourceOrTargetHandleIdsMap } from '../utils'
import {
applyConnectedHandleNodeData,
buildContextMenuEdges,
clearEdgeMenuIfNeeded,
clearNodeSelectionState,
updateEdgeHoverState,
updateEdgeSelectionState,
} from './use-edges-interactions.helpers'
import { useNodesSyncDraft } from './use-nodes-sync-draft'
import { useNodesReadOnly } from './use-workflow'
import { useWorkflowHistory, WorkflowHistoryEvent } from './use-workflow-history'
@ -36,29 +40,13 @@ export const useEdgesInteractions = () => {
return
const currentEdge = edges[currentEdgeIndex]
const nodes = getNodes()
const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap(
[
{ type: 'remove', edge: currentEdge },
],
nodes,
)
const newNodes = produce(nodes, (draft: Node[]) => {
draft.forEach((node) => {
if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) {
node.data = {
...node.data,
...nodesConnectedSourceOrTargetHandleIdsMap[node.id],
}
}
})
})
const newNodes = applyConnectedHandleNodeData(nodes, [{ type: 'remove', edge: currentEdge }])
setNodes(newNodes)
const newEdges = produce(edges, (draft) => {
draft.splice(currentEdgeIndex, 1)
})
setEdges(newEdges)
const currentEdgeMenu = workflowStore.getState().edgeMenu
if (currentEdgeMenu?.edgeId === currentEdge.id)
if (clearEdgeMenuIfNeeded({ edgeMenu: workflowStore.getState().edgeMenu, edgeIds: [currentEdge.id] }))
workflowStore.setState({ edgeMenu: undefined })
handleSyncWorkflowDraft()
saveStateToHistory(WorkflowHistoryEvent.EdgeDelete)
@ -72,12 +60,7 @@ export const useEdgesInteractions = () => {
edges,
setEdges,
} = store.getState()
const newEdges = produce(edges, (draft) => {
const currentEdge = draft.find(e => e.id === edge.id)!
currentEdge.data._hovering = true
})
setEdges(newEdges)
setEdges(updateEdgeHoverState(edges, edge.id, true))
}, [store, getNodesReadOnly])
const handleEdgeLeave = useCallback<EdgeMouseHandler>((_, edge) => {
@ -88,12 +71,7 @@ export const useEdgesInteractions = () => {
edges,
setEdges,
} = store.getState()
const newEdges = produce(edges, (draft) => {
const currentEdge = draft.find(e => e.id === edge.id)!
currentEdge.data._hovering = false
})
setEdges(newEdges)
setEdges(updateEdgeHoverState(edges, edge.id, false))
}, [store, getNodesReadOnly])
const handleEdgeDeleteByDeleteBranch = useCallback((nodeId: string, branchId: string) => {
@ -112,28 +90,21 @@ export const useEdgesInteractions = () => {
return
const nodes = getNodes()
const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap(
edgeWillBeDeleted.map(edge => ({ type: 'remove', edge })),
const newNodes = applyConnectedHandleNodeData(
nodes,
edgeWillBeDeleted.map(edge => ({ type: 'remove' as const, edge })),
)
const newNodes = produce(nodes, (draft: Node[]) => {
draft.forEach((node) => {
if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) {
node.data = {
...node.data,
...nodesConnectedSourceOrTargetHandleIdsMap[node.id],
}
}
})
})
setNodes(newNodes)
const newEdges = produce(edges, (draft) => {
return draft.filter(edge => !edgeWillBeDeleted.find(e => e.id === edge.id))
})
setEdges(newEdges)
const currentEdgeMenu = workflowStore.getState().edgeMenu
if (currentEdgeMenu && edgeWillBeDeleted.some(edge => edge.id === currentEdgeMenu.edgeId))
if (clearEdgeMenuIfNeeded({
edgeMenu: workflowStore.getState().edgeMenu,
edgeIds: edgeWillBeDeleted.map(edge => edge.id),
})) {
workflowStore.setState({ edgeMenu: undefined })
}
handleSyncWorkflowDraft()
saveStateToHistory(WorkflowHistoryEvent.EdgeDeleteByDeleteBranch)
}, [getNodesReadOnly, store, workflowStore, handleSyncWorkflowDraft, saveStateToHistory])
@ -165,14 +136,7 @@ export const useEdgesInteractions = () => {
edges,
setEdges,
} = store.getState()
const newEdges = produce(edges, (draft) => {
changes.forEach((change) => {
if (change.type === 'select')
draft.find(edge => edge.id === change.id)!.selected = change.selected
})
})
setEdges(newEdges)
setEdges(updateEdgeSelectionState(edges, changes))
}, [store, getNodesReadOnly])
const handleEdgeSourceHandleChange = useCallback((nodeId: string, oldHandleId: string, newHandleId: string) => {
@ -191,27 +155,13 @@ export const useEdgesInteractions = () => {
return
// Update node metadata: remove old handle, add new handle
const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap(
[
...affectedEdges.map(edge => ({ type: 'remove', edge })),
...affectedEdges.map(edge => ({
type: 'add',
edge: { ...edge, sourceHandle: newHandleId },
})),
],
nodes,
)
const newNodes = produce(nodes, (draft: Node[]) => {
draft.forEach((node) => {
if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) {
node.data = {
...node.data,
...nodesConnectedSourceOrTargetHandleIdsMap[node.id],
}
}
})
})
const newNodes = applyConnectedHandleNodeData(nodes, [
...affectedEdges.map(edge => ({ type: 'remove' as const, edge })),
...affectedEdges.map(edge => ({
type: 'add' as const,
edge: { ...edge, sourceHandle: newHandleId },
})),
])
setNodes(newNodes)
// Update edges to use new sourceHandle and regenerate edge IDs
@ -224,9 +174,12 @@ export const useEdgesInteractions = () => {
})
})
setEdges(newEdges)
const currentEdgeMenu = workflowStore.getState().edgeMenu
if (currentEdgeMenu && !newEdges.some(edge => edge.id === currentEdgeMenu.edgeId))
if (clearEdgeMenuIfNeeded({
edgeMenu: workflowStore.getState().edgeMenu,
edgeIds: affectedEdges.map(edge => edge.id),
})) {
workflowStore.setState({ edgeMenu: undefined })
}
handleSyncWorkflowDraft()
saveStateToHistory(WorkflowHistoryEvent.EdgeSourceHandleChange)
}, [getNodesReadOnly, store, workflowStore, handleSyncWorkflowDraft, saveStateToHistory])
@ -238,25 +191,10 @@ export const useEdgesInteractions = () => {
e.preventDefault()
const { getNodes, setNodes, edges, setEdges } = store.getState()
const newEdges = produce(edges, (draft) => {
draft.forEach((item) => {
item.selected = item.id === edge.id
if (item.data._isBundled)
item.data._isBundled = false
})
})
setEdges(newEdges)
setEdges(buildContextMenuEdges(edges, edge.id))
const nodes = getNodes()
if (nodes.some(node => node.data.selected || node.selected || node.data._isBundled)) {
const newNodes = produce(nodes, (draft: Node[]) => {
draft.forEach((node) => {
node.data.selected = false
if (node.data._isBundled)
node.data._isBundled = false
node.selected = false
})
})
setNodes(newNodes)
setNodes(clearNodeSelectionState(nodes))
}
workflowStore.setState({

View File

@ -12,6 +12,132 @@ const ENTRY_NODE_WRAPPER_OFFSET = {
y: 21, // Actual measured: pt-0.5 (2px) + status bar height (~19px)
} as const
type HelpLineNodeCollections = {
showHorizontalHelpLineNodes: Node[]
showVerticalHelpLineNodes: Node[]
}
type NodeAlignPosition = {
x: number
y: number
}
const ALIGN_THRESHOLD = 5
const getEntryNodeDimension = (
node: Node,
dimension: 'width' | 'height',
) => {
const offset = dimension === 'width'
? ENTRY_NODE_WRAPPER_OFFSET.x
: ENTRY_NODE_WRAPPER_OFFSET.y
return (node[dimension] ?? 0) - offset
}
const getAlignedNodes = ({
nodes,
node,
nodeAlignPos,
axis,
getNodeAlignPosition,
}: {
nodes: Node[]
node: Node
nodeAlignPos: NodeAlignPosition
axis: 'x' | 'y'
getNodeAlignPosition: (node: Node) => NodeAlignPosition
}) => {
return nodes.filter((candidate) => {
if (candidate.id === node.id)
return false
if (candidate.data.isInIteration || candidate.data.isInLoop)
return false
const candidateAlignPos = getNodeAlignPosition(candidate)
const diff = Math.ceil(candidateAlignPos[axis]) - Math.ceil(nodeAlignPos[axis])
return diff < ALIGN_THRESHOLD && diff > -ALIGN_THRESHOLD
}).sort((a, b) => {
const aPos = getNodeAlignPosition(a)
const bPos = getNodeAlignPosition(b)
return aPos.x - bPos.x
})
}
const buildHorizontalHelpLine = ({
alignedNodes,
node,
nodeAlignPos,
getNodeAlignPosition,
isEntryNode,
}: {
alignedNodes: Node[]
node: Node
nodeAlignPos: NodeAlignPosition
getNodeAlignPosition: (node: Node) => NodeAlignPosition
isEntryNode: (node: Node) => boolean
}) => {
if (!alignedNodes.length)
return undefined
const first = alignedNodes[0]
const last = alignedNodes[alignedNodes.length - 1]
const firstPos = getNodeAlignPosition(first)
const lastPos = getNodeAlignPosition(last)
const helpLine = {
top: firstPos.y,
left: firstPos.x,
width: lastPos.x + (isEntryNode(last) ? getEntryNodeDimension(last, 'width') : last.width ?? 0) - firstPos.x,
}
if (nodeAlignPos.x < firstPos.x) {
helpLine.left = nodeAlignPos.x
helpLine.width = firstPos.x + (isEntryNode(first) ? getEntryNodeDimension(first, 'width') : first.width ?? 0) - nodeAlignPos.x
}
if (nodeAlignPos.x > lastPos.x)
helpLine.width = nodeAlignPos.x + (isEntryNode(node) ? getEntryNodeDimension(node, 'width') : node.width ?? 0) - firstPos.x
return helpLine
}
const buildVerticalHelpLine = ({
alignedNodes,
node,
nodeAlignPos,
getNodeAlignPosition,
isEntryNode,
}: {
alignedNodes: Node[]
node: Node
nodeAlignPos: NodeAlignPosition
getNodeAlignPosition: (node: Node) => NodeAlignPosition
isEntryNode: (node: Node) => boolean
}) => {
if (!alignedNodes.length)
return undefined
const first = alignedNodes[0]
const last = alignedNodes[alignedNodes.length - 1]
const firstPos = getNodeAlignPosition(first)
const lastPos = getNodeAlignPosition(last)
const helpLine = {
top: firstPos.y,
left: firstPos.x,
height: lastPos.y + (isEntryNode(last) ? getEntryNodeDimension(last, 'height') : last.height ?? 0) - firstPos.y,
}
if (nodeAlignPos.y < firstPos.y) {
helpLine.top = nodeAlignPos.y
helpLine.height = firstPos.y + (isEntryNode(first) ? getEntryNodeDimension(first, 'height') : first.height ?? 0) - nodeAlignPos.y
}
if (nodeAlignPos.y > lastPos.y)
helpLine.height = nodeAlignPos.y + (isEntryNode(node) ? getEntryNodeDimension(node, 'height') : node.height ?? 0) - firstPos.y
return helpLine
}
export const useHelpline = () => {
const store = useStoreApi()
const workflowStore = useWorkflowStore()
@ -60,135 +186,41 @@ export const useHelpline = () => {
// Get the actual alignment position for the dragging node
const nodeAlignPos = getNodeAlignPosition(node)
const showHorizontalHelpLineNodes = nodes.filter((n) => {
if (n.id === node.id)
return false
if (n.data.isInIteration)
return false
if (n.data.isInLoop)
return false
// Get actual alignment position for comparison node
const nAlignPos = getNodeAlignPosition(n)
const nY = Math.ceil(nAlignPos.y)
const nodeY = Math.ceil(nodeAlignPos.y)
if (nY - nodeY < 5 && nY - nodeY > -5)
return true
return false
}).sort((a, b) => {
const aPos = getNodeAlignPosition(a)
const bPos = getNodeAlignPosition(b)
return aPos.x - bPos.x
const showHorizontalHelpLineNodes = getAlignedNodes({
nodes,
node,
nodeAlignPos,
axis: 'y',
getNodeAlignPosition,
})
const showVerticalHelpLineNodes = getAlignedNodes({
nodes,
node,
nodeAlignPos,
axis: 'x',
getNodeAlignPosition,
})
const showHorizontalHelpLineNodesLength = showHorizontalHelpLineNodes.length
if (showHorizontalHelpLineNodesLength > 0) {
const first = showHorizontalHelpLineNodes[0]
const last = showHorizontalHelpLineNodes[showHorizontalHelpLineNodesLength - 1]
// Use actual alignment positions for help line rendering
const firstPos = getNodeAlignPosition(first)
const lastPos = getNodeAlignPosition(last)
// For entry nodes, we need to subtract the offset from width since lastPos already includes it
const lastIsEntryNode = isEntryNode(last)
const lastNodeWidth = lastIsEntryNode ? last.width! - ENTRY_NODE_WRAPPER_OFFSET.x : last.width!
const helpLine = {
top: firstPos.y,
left: firstPos.x,
width: lastPos.x + lastNodeWidth - firstPos.x,
}
if (nodeAlignPos.x < firstPos.x) {
const firstIsEntryNode = isEntryNode(first)
const firstNodeWidth = firstIsEntryNode ? first.width! - ENTRY_NODE_WRAPPER_OFFSET.x : first.width!
helpLine.left = nodeAlignPos.x
helpLine.width = firstPos.x + firstNodeWidth - nodeAlignPos.x
}
if (nodeAlignPos.x > lastPos.x) {
const nodeIsEntryNode = isEntryNode(node)
const nodeWidth = nodeIsEntryNode ? node.width! - ENTRY_NODE_WRAPPER_OFFSET.x : node.width!
helpLine.width = nodeAlignPos.x + nodeWidth - firstPos.x
}
setHelpLineHorizontal(helpLine)
}
else {
setHelpLineHorizontal()
}
const showVerticalHelpLineNodes = nodes.filter((n) => {
if (n.id === node.id)
return false
if (n.data.isInIteration)
return false
if (n.data.isInLoop)
return false
// Get actual alignment position for comparison node
const nAlignPos = getNodeAlignPosition(n)
const nX = Math.ceil(nAlignPos.x)
const nodeX = Math.ceil(nodeAlignPos.x)
if (nX - nodeX < 5 && nX - nodeX > -5)
return true
return false
}).sort((a, b) => {
const aPos = getNodeAlignPosition(a)
const bPos = getNodeAlignPosition(b)
return aPos.x - bPos.x
})
const showVerticalHelpLineNodesLength = showVerticalHelpLineNodes.length
if (showVerticalHelpLineNodesLength > 0) {
const first = showVerticalHelpLineNodes[0]
const last = showVerticalHelpLineNodes[showVerticalHelpLineNodesLength - 1]
// Use actual alignment positions for help line rendering
const firstPos = getNodeAlignPosition(first)
const lastPos = getNodeAlignPosition(last)
// For entry nodes, we need to subtract the offset from height since lastPos already includes it
const lastIsEntryNode = isEntryNode(last)
const lastNodeHeight = lastIsEntryNode ? last.height! - ENTRY_NODE_WRAPPER_OFFSET.y : last.height!
const helpLine = {
top: firstPos.y,
left: firstPos.x,
height: lastPos.y + lastNodeHeight - firstPos.y,
}
if (nodeAlignPos.y < firstPos.y) {
const firstIsEntryNode = isEntryNode(first)
const firstNodeHeight = firstIsEntryNode ? first.height! - ENTRY_NODE_WRAPPER_OFFSET.y : first.height!
helpLine.top = nodeAlignPos.y
helpLine.height = firstPos.y + firstNodeHeight - nodeAlignPos.y
}
if (nodeAlignPos.y > lastPos.y) {
const nodeIsEntryNode = isEntryNode(node)
const nodeHeight = nodeIsEntryNode ? node.height! - ENTRY_NODE_WRAPPER_OFFSET.y : node.height!
helpLine.height = nodeAlignPos.y + nodeHeight - firstPos.y
}
setHelpLineVertical(helpLine)
}
else {
setHelpLineVertical()
}
setHelpLineHorizontal(buildHorizontalHelpLine({
alignedNodes: showHorizontalHelpLineNodes,
node,
nodeAlignPos,
getNodeAlignPosition,
isEntryNode,
}))
setHelpLineVertical(buildVerticalHelpLine({
alignedNodes: showVerticalHelpLineNodes,
node,
nodeAlignPos,
getNodeAlignPosition,
isEntryNode,
}))
return {
showHorizontalHelpLineNodes,
showVerticalHelpLineNodes,
}
}, [store, workflowStore, getNodeAlignPosition])
} satisfies HelpLineNodeCollections
}, [store, workflowStore, getNodeAlignPosition, isEntryNode])
return {
handleSetHelpline,

View File

@ -24,6 +24,12 @@ const isToolNode = (data: Node['data']): data is ToolNodeType => data.type === B
const isDataSourceNode = (data: Node['data']): data is DataSourceNodeType => data.type === BlockEnum.DataSource
type IconValue = ToolWithProvider['icon']
type ToolCollections = {
buildInTools?: ToolWithProvider[]
customTools?: ToolWithProvider[]
workflowTools?: ToolWithProvider[]
mcpTools?: ToolWithProvider[]
}
const resolveIconByTheme = (
currentTheme: string | undefined,
@ -51,6 +57,121 @@ const findTriggerPluginIcon = (
return undefined
}
const getPrimaryToolCollection = (
providerType: CollectionType | undefined,
collections: ToolCollections,
) => {
switch (providerType) {
case CollectionType.custom:
return collections.customTools
case CollectionType.mcp:
return collections.mcpTools
case CollectionType.workflow:
return collections.workflowTools
case CollectionType.builtIn:
default:
return collections.buildInTools
}
}
const getCollectionsToSearch = (
providerType: CollectionType | undefined,
collections: ToolCollections,
) => {
return [
getPrimaryToolCollection(providerType, collections),
collections.buildInTools,
collections.customTools,
collections.workflowTools,
collections.mcpTools,
] as Array<ToolWithProvider[] | undefined>
}
const findToolInCollections = (
collections: Array<ToolWithProvider[] | undefined>,
data: ToolNodeType,
) => {
const seen = new Set<ToolWithProvider[]>()
for (const collection of collections) {
if (!collection || seen.has(collection))
continue
seen.add(collection)
const matched = collection.find((toolWithProvider) => {
if (canFindTool(toolWithProvider.id, data.provider_id))
return true
if (data.plugin_id && toolWithProvider.plugin_id === data.plugin_id)
return true
return data.provider_name === toolWithProvider.name
})
if (matched)
return matched
}
return undefined
}
const findToolNodeIcon = ({
data,
collections,
theme,
}: {
data: ToolNodeType
collections: ToolCollections
theme?: string
}) => {
const matched = findToolInCollections(getCollectionsToSearch(data.provider_type, collections), data)
if (matched) {
const matchedIcon = resolveIconByTheme(theme, matched.icon, matched.icon_dark)
if (matchedIcon)
return matchedIcon
}
return resolveIconByTheme(theme, data.provider_icon, data.provider_icon_dark)
}
const findDataSourceIcon = (
data: DataSourceNodeType,
dataSourceList?: ToolWithProvider[],
) => {
return dataSourceList?.find(toolWithProvider => toolWithProvider.plugin_id === data.plugin_id)?.icon
}
const findNodeIcon = ({
data,
collections,
dataSourceList,
triggerPlugins,
theme,
}: {
data?: Node['data']
collections: ToolCollections
dataSourceList?: ToolWithProvider[]
triggerPlugins?: TriggerWithProvider[]
theme?: string
}) => {
if (!data)
return undefined
if (isTriggerPluginNode(data)) {
return findTriggerPluginIcon(
[data.plugin_id, data.provider_id, data.provider_name],
triggerPlugins,
theme,
)
}
if (isToolNode(data))
return findToolNodeIcon({ data, collections, theme })
if (isDataSourceNode(data))
return findDataSourceIcon(data, dataSourceList)
return undefined
}
export const useToolIcon = (data?: Node['data']) => {
const { data: buildInTools } = useAllBuiltInTools()
const { data: customTools } = useAllCustomTools()
@ -61,79 +182,18 @@ export const useToolIcon = (data?: Node['data']) => {
const { theme } = useTheme()
const toolIcon = useMemo(() => {
if (!data)
return ''
if (isTriggerPluginNode(data)) {
const icon = findTriggerPluginIcon(
[
data.plugin_id,
data.provider_id,
data.provider_name,
],
triggerPlugins,
theme,
)
if (icon)
return icon
}
if (isToolNode(data)) {
let primaryCollection: ToolWithProvider[] | undefined
switch (data.provider_type) {
case CollectionType.custom:
primaryCollection = customTools
break
case CollectionType.mcp:
primaryCollection = mcpTools
break
case CollectionType.workflow:
primaryCollection = workflowTools
break
case CollectionType.builtIn:
default:
primaryCollection = buildInTools
break
}
const collectionsToSearch = [
primaryCollection,
return findNodeIcon({
data,
collections: {
buildInTools,
customTools,
workflowTools,
mcpTools,
] as Array<ToolWithProvider[] | undefined>
const seen = new Set<ToolWithProvider[]>()
for (const collection of collectionsToSearch) {
if (!collection || seen.has(collection))
continue
seen.add(collection)
const matched = collection.find((toolWithProvider) => {
if (canFindTool(toolWithProvider.id, data.provider_id))
return true
if (data.plugin_id && toolWithProvider.plugin_id === data.plugin_id)
return true
return data.provider_name === toolWithProvider.name
})
if (matched) {
const icon = resolveIconByTheme(theme, matched.icon, matched.icon_dark)
if (icon)
return icon
}
}
const fallbackIcon = resolveIconByTheme(theme, data.provider_icon, data.provider_icon_dark)
if (fallbackIcon)
return fallbackIcon
return ''
}
if (isDataSourceNode(data))
return dataSourceList?.find(toolWithProvider => toolWithProvider.plugin_id === data.plugin_id)?.icon || ''
return ''
},
dataSourceList,
triggerPlugins,
theme,
}) || ''
}, [data, dataSourceList, buildInTools, customTools, workflowTools, mcpTools, triggerPlugins, theme])
return toolIcon
@ -157,71 +217,18 @@ export const useGetToolIcon = () => {
dataSourceList,
} = workflowStore.getState()
if (isTriggerPluginNode(data)) {
return findTriggerPluginIcon(
[
data.plugin_id,
data.provider_id,
data.provider_name,
],
triggerPlugins,
theme,
)
}
if (isToolNode(data)) {
const primaryCollection = (() => {
switch (data.provider_type) {
case CollectionType.custom:
return storeCustomTools ?? customTools
case CollectionType.mcp:
return storeMcpTools ?? mcpTools
case CollectionType.workflow:
return storeWorkflowTools ?? workflowTools
case CollectionType.builtIn:
default:
return storeBuiltInTools ?? buildInTools
}
})()
const collectionsToSearch = [
primaryCollection,
storeBuiltInTools ?? buildInTools,
storeCustomTools ?? customTools,
storeWorkflowTools ?? workflowTools,
storeMcpTools ?? mcpTools,
] as Array<ToolWithProvider[] | undefined>
const seen = new Set<ToolWithProvider[]>()
for (const collection of collectionsToSearch) {
if (!collection || seen.has(collection))
continue
seen.add(collection)
const matched = collection.find((toolWithProvider) => {
if (canFindTool(toolWithProvider.id, data.provider_id))
return true
if (data.plugin_id && toolWithProvider.plugin_id === data.plugin_id)
return true
return data.provider_name === toolWithProvider.name
})
if (matched) {
const icon = resolveIconByTheme(theme, matched.icon, matched.icon_dark)
if (icon)
return icon
}
}
const fallbackIcon = resolveIconByTheme(theme, data.provider_icon, data.provider_icon_dark)
if (fallbackIcon)
return fallbackIcon
return undefined
}
if (isDataSourceNode(data))
return dataSourceList?.find(toolWithProvider => toolWithProvider.plugin_id === data.plugin_id)?.icon
return undefined
return findNodeIcon({
data,
collections: {
buildInTools: storeBuiltInTools ?? buildInTools,
customTools: storeCustomTools ?? customTools,
workflowTools: storeWorkflowTools ?? workflowTools,
mcpTools: storeMcpTools ?? mcpTools,
},
dataSourceList,
triggerPlugins,
theme,
})
}, [workflowStore, triggerPlugins, buildInTools, customTools, workflowTools, mcpTools, theme])
return getToolIcon

View File

@ -0,0 +1,28 @@
import { useCallback } from 'react'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { useStore } from '../store'
import { useNodesReadOnly } from './use-workflow'
export const useWorkflowCanvasMaximize = () => {
const { eventEmitter } = useEventEmitterContextContext()
const maximizeCanvas = useStore(s => s.maximizeCanvas)
const setMaximizeCanvas = useStore(s => s.setMaximizeCanvas)
const { getNodesReadOnly } = useNodesReadOnly()
const handleToggleMaximizeCanvas = useCallback(() => {
if (getNodesReadOnly())
return
const nextValue = !maximizeCanvas
setMaximizeCanvas(nextValue)
localStorage.setItem('workflow-canvas-maximize', String(nextValue))
eventEmitter?.emit({
type: 'workflow-canvas-maximize',
payload: nextValue,
} as never)
}, [eventEmitter, getNodesReadOnly, maximizeCanvas, setMaximizeCanvas])
return {
handleToggleMaximizeCanvas,
}
}

View File

@ -1,355 +1,5 @@
import type { WorkflowDataUpdater } from '../types'
import type { LayoutResult } from '../utils'
import { produce } from 'immer'
import {
useCallback,
} from 'react'
import { useReactFlow, useStoreApi } from 'reactflow'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import {
CUSTOM_NODE,
NODE_LAYOUT_HORIZONTAL_PADDING,
NODE_LAYOUT_VERTICAL_PADDING,
WORKFLOW_DATA_UPDATE,
} from '../constants'
import {
useNodesReadOnly,
useSelectionInteractions,
useWorkflowReadOnly,
} from '../hooks'
import { useStore, useWorkflowStore } from '../store'
import { BlockEnum, ControlMode } from '../types'
import {
getLayoutByDagre,
getLayoutForChildNodes,
initialEdges,
initialNodes,
} from '../utils'
import { useEdgesInteractionsWithoutSync } from './use-edges-interactions-without-sync'
import { useNodesInteractionsWithoutSync } from './use-nodes-interactions-without-sync'
import { useNodesSyncDraft } from './use-nodes-sync-draft'
import { useWorkflowHistory, WorkflowHistoryEvent } from './use-workflow-history'
export const useWorkflowInteractions = () => {
const workflowStore = useWorkflowStore()
const { handleNodeCancelRunningStatus } = useNodesInteractionsWithoutSync()
const { handleEdgeCancelRunningStatus } = useEdgesInteractionsWithoutSync()
const handleCancelDebugAndPreviewPanel = useCallback(() => {
workflowStore.setState({
showDebugAndPreviewPanel: false,
workflowRunningData: undefined,
})
handleNodeCancelRunningStatus()
handleEdgeCancelRunningStatus()
}, [workflowStore, handleNodeCancelRunningStatus, handleEdgeCancelRunningStatus])
return {
handleCancelDebugAndPreviewPanel,
}
}
export const useWorkflowMoveMode = () => {
const setControlMode = useStore(s => s.setControlMode)
const {
getNodesReadOnly,
} = useNodesReadOnly()
const { handleSelectionCancel } = useSelectionInteractions()
const handleModePointer = useCallback(() => {
if (getNodesReadOnly())
return
setControlMode(ControlMode.Pointer)
}, [getNodesReadOnly, setControlMode])
const handleModeHand = useCallback(() => {
if (getNodesReadOnly())
return
setControlMode(ControlMode.Hand)
handleSelectionCancel()
}, [getNodesReadOnly, setControlMode, handleSelectionCancel])
return {
handleModePointer,
handleModeHand,
}
}
export const useWorkflowOrganize = () => {
const workflowStore = useWorkflowStore()
const store = useStoreApi()
const reactflow = useReactFlow()
const { getNodesReadOnly } = useNodesReadOnly()
const { saveStateToHistory } = useWorkflowHistory()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const handleLayout = useCallback(async () => {
if (getNodesReadOnly())
return
workflowStore.setState({ nodeAnimation: true })
const {
getNodes,
edges,
setNodes,
} = store.getState()
const { setViewport } = reactflow
const nodes = getNodes()
const loopAndIterationNodes = nodes.filter(
node => (node.data.type === BlockEnum.Loop || node.data.type === BlockEnum.Iteration)
&& !node.parentId
&& node.type === CUSTOM_NODE,
)
const childLayoutEntries = await Promise.all(
loopAndIterationNodes.map(async node => [
node.id,
await getLayoutForChildNodes(node.id, nodes, edges),
] as const),
)
const childLayoutsMap = childLayoutEntries.reduce((acc, [nodeId, layout]) => {
if (layout)
acc[nodeId] = layout
return acc
}, {} as Record<string, LayoutResult>)
const containerSizeChanges: Record<string, { width: number, height: number }> = {}
loopAndIterationNodes.forEach((parentNode) => {
const childLayout = childLayoutsMap[parentNode.id]
if (!childLayout)
return
const {
bounds,
nodes: layoutNodes,
} = childLayout
if (!layoutNodes.size)
return
const requiredWidth = (bounds.maxX - bounds.minX) + NODE_LAYOUT_HORIZONTAL_PADDING * 2
const requiredHeight = (bounds.maxY - bounds.minY) + NODE_LAYOUT_VERTICAL_PADDING * 2
containerSizeChanges[parentNode.id] = {
width: Math.max(parentNode.width || 0, requiredWidth),
height: Math.max(parentNode.height || 0, requiredHeight),
}
})
const nodesWithUpdatedSizes = produce(nodes, (draft) => {
draft.forEach((node) => {
if ((node.data.type === BlockEnum.Loop || node.data.type === BlockEnum.Iteration)
&& containerSizeChanges[node.id]) {
node.width = containerSizeChanges[node.id].width
node.height = containerSizeChanges[node.id].height
if (node.data.type === BlockEnum.Loop) {
node.data.width = containerSizeChanges[node.id].width
node.data.height = containerSizeChanges[node.id].height
}
else if (node.data.type === BlockEnum.Iteration) {
node.data.width = containerSizeChanges[node.id].width
node.data.height = containerSizeChanges[node.id].height
}
}
})
})
const layout = await getLayoutByDagre(nodesWithUpdatedSizes, edges)
// Build layer map for vertical alignment - nodes in the same layer should align
const layerMap = new Map<number, { minY: number, maxHeight: number }>()
layout.nodes.forEach((layoutInfo) => {
if (layoutInfo.layer !== undefined) {
const existing = layerMap.get(layoutInfo.layer)
const newLayerInfo = {
minY: existing ? Math.min(existing.minY, layoutInfo.y) : layoutInfo.y,
maxHeight: existing ? Math.max(existing.maxHeight, layoutInfo.height) : layoutInfo.height,
}
layerMap.set(layoutInfo.layer, newLayerInfo)
}
})
const newNodes = produce(nodesWithUpdatedSizes, (draft) => {
draft.forEach((node) => {
if (!node.parentId && node.type === CUSTOM_NODE) {
const layoutInfo = layout.nodes.get(node.id)
if (!layoutInfo)
return
// Calculate vertical position with layer alignment
let yPosition = layoutInfo.y
if (layoutInfo.layer !== undefined) {
const layerInfo = layerMap.get(layoutInfo.layer)
if (layerInfo) {
// Align to the center of the tallest node in this layer
const layerCenterY = layerInfo.minY + layerInfo.maxHeight / 2
yPosition = layerCenterY - layoutInfo.height / 2
}
}
node.position = {
x: layoutInfo.x,
y: yPosition,
}
}
})
loopAndIterationNodes.forEach((parentNode) => {
const childLayout = childLayoutsMap[parentNode.id]
if (!childLayout)
return
const childNodes = draft.filter(node => node.parentId === parentNode.id)
const {
bounds,
nodes: layoutNodes,
} = childLayout
childNodes.forEach((childNode) => {
const layoutInfo = layoutNodes.get(childNode.id)
if (!layoutInfo)
return
childNode.position = {
x: NODE_LAYOUT_HORIZONTAL_PADDING + (layoutInfo.x - bounds.minX),
y: NODE_LAYOUT_VERTICAL_PADDING + (layoutInfo.y - bounds.minY),
}
})
})
})
setNodes(newNodes)
const zoom = 0.7
setViewport({
x: 0,
y: 0,
zoom,
})
saveStateToHistory(WorkflowHistoryEvent.LayoutOrganize)
setTimeout(() => {
handleSyncWorkflowDraft()
})
}, [getNodesReadOnly, store, reactflow, workflowStore, handleSyncWorkflowDraft, saveStateToHistory])
return {
handleLayout,
}
}
export const useWorkflowZoom = () => {
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const { getWorkflowReadOnly } = useWorkflowReadOnly()
const {
zoomIn,
zoomOut,
zoomTo,
fitView,
} = useReactFlow()
const handleFitView = useCallback(() => {
if (getWorkflowReadOnly())
return
fitView()
handleSyncWorkflowDraft()
}, [getWorkflowReadOnly, fitView, handleSyncWorkflowDraft])
const handleBackToOriginalSize = useCallback(() => {
if (getWorkflowReadOnly())
return
zoomTo(1)
handleSyncWorkflowDraft()
}, [getWorkflowReadOnly, zoomTo, handleSyncWorkflowDraft])
const handleSizeToHalf = useCallback(() => {
if (getWorkflowReadOnly())
return
zoomTo(0.5)
handleSyncWorkflowDraft()
}, [getWorkflowReadOnly, zoomTo, handleSyncWorkflowDraft])
const handleZoomOut = useCallback(() => {
if (getWorkflowReadOnly())
return
zoomOut()
handleSyncWorkflowDraft()
}, [getWorkflowReadOnly, zoomOut, handleSyncWorkflowDraft])
const handleZoomIn = useCallback(() => {
if (getWorkflowReadOnly())
return
zoomIn()
handleSyncWorkflowDraft()
}, [getWorkflowReadOnly, zoomIn, handleSyncWorkflowDraft])
return {
handleFitView,
handleBackToOriginalSize,
handleSizeToHalf,
handleZoomOut,
handleZoomIn,
}
}
export const useWorkflowUpdate = () => {
const reactflow = useReactFlow()
const { eventEmitter } = useEventEmitterContextContext()
const handleUpdateWorkflowCanvas = useCallback((payload: WorkflowDataUpdater) => {
const {
nodes,
edges,
viewport,
} = payload
const { setViewport } = reactflow
eventEmitter?.emit({
type: WORKFLOW_DATA_UPDATE,
payload: {
nodes: initialNodes(nodes, edges),
edges: initialEdges(edges, nodes),
},
} as any)
// Only set viewport if it exists and is valid
if (viewport && typeof viewport.x === 'number' && typeof viewport.y === 'number' && typeof viewport.zoom === 'number')
setViewport(viewport)
}, [eventEmitter, reactflow])
return {
handleUpdateWorkflowCanvas,
}
}
export const useWorkflowCanvasMaximize = () => {
const { eventEmitter } = useEventEmitterContextContext()
const maximizeCanvas = useStore(s => s.maximizeCanvas)
const setMaximizeCanvas = useStore(s => s.setMaximizeCanvas)
const {
getNodesReadOnly,
} = useNodesReadOnly()
const handleToggleMaximizeCanvas = useCallback(() => {
if (getNodesReadOnly())
return
setMaximizeCanvas(!maximizeCanvas)
localStorage.setItem('workflow-canvas-maximize', String(!maximizeCanvas))
eventEmitter?.emit({
type: 'workflow-canvas-maximize',
payload: !maximizeCanvas,
} as any)
}, [eventEmitter, getNodesReadOnly, maximizeCanvas, setMaximizeCanvas])
return {
handleToggleMaximizeCanvas,
}
}
export { useWorkflowCanvasMaximize } from './use-workflow-canvas-maximize'
export { useWorkflowOrganize } from './use-workflow-organize'
export { useWorkflowInteractions, useWorkflowMoveMode } from './use-workflow-panel-interactions'
export { useWorkflowUpdate } from './use-workflow-update'
export { useWorkflowZoom } from './use-workflow-zoom'

View File

@ -0,0 +1,138 @@
import type { Node } from '../types'
import type { LayoutResult } from '../utils'
import { produce } from 'immer'
import {
CUSTOM_NODE,
NODE_LAYOUT_HORIZONTAL_PADDING,
NODE_LAYOUT_VERTICAL_PADDING,
} from '../constants'
import { BlockEnum } from '../types'
type ContainerSizeChange = {
width: number
height: number
}
type LayerInfo = {
minY: number
maxHeight: number
}
export const getLayoutContainerNodes = (nodes: Node[]) => {
return nodes.filter(
node => (node.data.type === BlockEnum.Loop || node.data.type === BlockEnum.Iteration)
&& !node.parentId
&& node.type === CUSTOM_NODE,
)
}
export const getContainerSizeChanges = (
parentNodes: Node[],
childLayoutsMap: Record<string, LayoutResult>,
) => {
return parentNodes.reduce<Record<string, ContainerSizeChange>>((acc, parentNode) => {
const childLayout = childLayoutsMap[parentNode.id]
if (!childLayout || !childLayout.nodes.size)
return acc
const requiredWidth = (childLayout.bounds.maxX - childLayout.bounds.minX) + NODE_LAYOUT_HORIZONTAL_PADDING * 2
const requiredHeight = (childLayout.bounds.maxY - childLayout.bounds.minY) + NODE_LAYOUT_VERTICAL_PADDING * 2
acc[parentNode.id] = {
width: Math.max(parentNode.width || 0, requiredWidth),
height: Math.max(parentNode.height || 0, requiredHeight),
}
return acc
}, {})
}
export const applyContainerSizeChanges = (
nodes: Node[],
containerSizeChanges: Record<string, ContainerSizeChange>,
) => produce(nodes, (draft) => {
draft.forEach((node) => {
const nextSize = containerSizeChanges[node.id]
if ((node.data.type === BlockEnum.Loop || node.data.type === BlockEnum.Iteration) && nextSize) {
node.width = nextSize.width
node.height = nextSize.height
node.data.width = nextSize.width
node.data.height = nextSize.height
}
})
})
export const createLayerMap = (layout: LayoutResult) => {
return Array.from(layout.nodes.values()).reduce<Map<number, LayerInfo>>((acc, layoutInfo) => {
if (layoutInfo.layer === undefined)
return acc
const existing = acc.get(layoutInfo.layer)
acc.set(layoutInfo.layer, {
minY: existing ? Math.min(existing.minY, layoutInfo.y) : layoutInfo.y,
maxHeight: existing ? Math.max(existing.maxHeight, layoutInfo.height) : layoutInfo.height,
})
return acc
}, new Map<number, LayerInfo>())
}
const getAlignedYPosition = (
layoutInfo: LayoutResult['nodes'] extends Map<string, infer T> ? T : never,
layerMap: Map<number, LayerInfo>,
) => {
if (layoutInfo.layer === undefined)
return layoutInfo.y
const layerInfo = layerMap.get(layoutInfo.layer)
if (!layerInfo)
return layoutInfo.y
return (layerInfo.minY + layerInfo.maxHeight / 2) - layoutInfo.height / 2
}
export const applyLayoutToNodes = ({
nodes,
layout,
parentNodes,
childLayoutsMap,
}: {
nodes: Node[]
layout: LayoutResult
parentNodes: Node[]
childLayoutsMap: Record<string, LayoutResult>
}) => {
const layerMap = createLayerMap(layout)
return produce(nodes, (draft) => {
draft.forEach((node) => {
if (!node.parentId && node.type === CUSTOM_NODE) {
const layoutInfo = layout.nodes.get(node.id)
if (!layoutInfo)
return
node.position = {
x: layoutInfo.x,
y: getAlignedYPosition(layoutInfo, layerMap),
}
}
})
parentNodes.forEach((parentNode) => {
const childLayout = childLayoutsMap[parentNode.id]
if (!childLayout)
return
draft
.filter(node => node.parentId === parentNode.id)
.forEach((childNode) => {
const layoutInfo = childLayout.nodes.get(childNode.id)
if (!layoutInfo)
return
childNode.position = {
x: NODE_LAYOUT_HORIZONTAL_PADDING + (layoutInfo.x - childLayout.bounds.minX),
y: NODE_LAYOUT_VERTICAL_PADDING + (layoutInfo.y - childLayout.bounds.minY),
}
})
})
})
}

View File

@ -0,0 +1,71 @@
import { useCallback } from 'react'
import { useReactFlow, useStoreApi } from 'reactflow'
import { useWorkflowStore } from '../store'
import {
getLayoutByDagre,
getLayoutForChildNodes,
} from '../utils'
import { useNodesSyncDraft } from './use-nodes-sync-draft'
import { useNodesReadOnly } from './use-workflow'
import { useWorkflowHistory, WorkflowHistoryEvent } from './use-workflow-history'
import {
applyContainerSizeChanges,
applyLayoutToNodes,
getContainerSizeChanges,
getLayoutContainerNodes,
} from './use-workflow-organize.helpers'
export const useWorkflowOrganize = () => {
const workflowStore = useWorkflowStore()
const store = useStoreApi()
const reactflow = useReactFlow()
const { getNodesReadOnly } = useNodesReadOnly()
const { saveStateToHistory } = useWorkflowHistory()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const handleLayout = useCallback(async () => {
if (getNodesReadOnly())
return
workflowStore.setState({ nodeAnimation: true })
const {
getNodes,
edges,
setNodes,
} = store.getState()
const nodes = getNodes()
const parentNodes = getLayoutContainerNodes(nodes)
const childLayoutEntries = await Promise.all(
parentNodes.map(async node => [node.id, await getLayoutForChildNodes(node.id, nodes, edges)] as const),
)
const childLayoutsMap = childLayoutEntries.reduce((acc, [nodeId, layout]) => {
if (layout)
acc[nodeId] = layout
return acc
}, {} as Record<string, NonNullable<Awaited<ReturnType<typeof getLayoutForChildNodes>>>>)
const nodesWithUpdatedSizes = applyContainerSizeChanges(
nodes,
getContainerSizeChanges(parentNodes, childLayoutsMap),
)
const layout = await getLayoutByDagre(nodesWithUpdatedSizes, edges)
const nextNodes = applyLayoutToNodes({
nodes: nodesWithUpdatedSizes,
layout,
parentNodes,
childLayoutsMap,
})
setNodes(nextNodes)
reactflow.setViewport({ x: 0, y: 0, zoom: 0.7 })
saveStateToHistory(WorkflowHistoryEvent.LayoutOrganize)
setTimeout(() => {
handleSyncWorkflowDraft()
})
}, [getNodesReadOnly, handleSyncWorkflowDraft, reactflow, saveStateToHistory, store, workflowStore])
return {
handleLayout,
}
}

View File

@ -0,0 +1,52 @@
import { useCallback } from 'react'
import { useStore, useWorkflowStore } from '../store'
import { ControlMode } from '../types'
import { useEdgesInteractionsWithoutSync } from './use-edges-interactions-without-sync'
import { useNodesInteractionsWithoutSync } from './use-nodes-interactions-without-sync'
import { useSelectionInteractions } from './use-selection-interactions'
import { useNodesReadOnly } from './use-workflow'
export const useWorkflowInteractions = () => {
const workflowStore = useWorkflowStore()
const { handleNodeCancelRunningStatus } = useNodesInteractionsWithoutSync()
const { handleEdgeCancelRunningStatus } = useEdgesInteractionsWithoutSync()
const handleCancelDebugAndPreviewPanel = useCallback(() => {
workflowStore.setState({
showDebugAndPreviewPanel: false,
workflowRunningData: undefined,
})
handleNodeCancelRunningStatus()
handleEdgeCancelRunningStatus()
}, [workflowStore, handleNodeCancelRunningStatus, handleEdgeCancelRunningStatus])
return {
handleCancelDebugAndPreviewPanel,
}
}
export const useWorkflowMoveMode = () => {
const setControlMode = useStore(s => s.setControlMode)
const { getNodesReadOnly } = useNodesReadOnly()
const { handleSelectionCancel } = useSelectionInteractions()
const handleModePointer = useCallback(() => {
if (getNodesReadOnly())
return
setControlMode(ControlMode.Pointer)
}, [getNodesReadOnly, setControlMode])
const handleModeHand = useCallback(() => {
if (getNodesReadOnly())
return
setControlMode(ControlMode.Hand)
handleSelectionCancel()
}, [getNodesReadOnly, handleSelectionCancel, setControlMode])
return {
handleModePointer,
handleModeHand,
}
}

View File

@ -0,0 +1,37 @@
import type { WorkflowDataUpdater } from '../types'
import { useCallback } from 'react'
import { useReactFlow } from 'reactflow'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { WORKFLOW_DATA_UPDATE } from '../constants'
import {
initialEdges,
initialNodes,
} from '../utils'
export const useWorkflowUpdate = () => {
const reactflow = useReactFlow()
const { eventEmitter } = useEventEmitterContextContext()
const handleUpdateWorkflowCanvas = useCallback((payload: WorkflowDataUpdater) => {
const {
nodes,
edges,
viewport,
} = payload
eventEmitter?.emit({
type: WORKFLOW_DATA_UPDATE,
payload: {
nodes: initialNodes(nodes, edges),
edges: initialEdges(edges, nodes),
},
} as never)
if (viewport && typeof viewport.x === 'number' && typeof viewport.y === 'number' && typeof viewport.zoom === 'number')
reactflow.setViewport(viewport)
}, [eventEmitter, reactflow])
return {
handleUpdateWorkflowCanvas,
}
}

View File

@ -0,0 +1,31 @@
import { useCallback } from 'react'
import { useReactFlow } from 'reactflow'
import { useNodesSyncDraft } from './use-nodes-sync-draft'
import { useWorkflowReadOnly } from './use-workflow'
export const useWorkflowZoom = () => {
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const { getWorkflowReadOnly } = useWorkflowReadOnly()
const {
zoomIn,
zoomOut,
zoomTo,
fitView,
} = useReactFlow()
const runZoomAction = useCallback((action: () => void) => {
if (getWorkflowReadOnly())
return
action()
handleSyncWorkflowDraft()
}, [getWorkflowReadOnly, handleSyncWorkflowDraft])
return {
handleFitView: useCallback(() => runZoomAction(fitView), [fitView, runZoomAction]),
handleBackToOriginalSize: useCallback(() => runZoomAction(() => zoomTo(1)), [runZoomAction, zoomTo]),
handleSizeToHalf: useCallback(() => runZoomAction(() => zoomTo(0.5)), [runZoomAction, zoomTo]),
handleZoomOut: useCallback(() => runZoomAction(zoomOut), [runZoomAction, zoomOut]),
handleZoomIn: useCallback(() => runZoomAction(zoomIn), [runZoomAction, zoomIn]),
}
}

View File

@ -0,0 +1,24 @@
import { createNodeCrudModuleMock, createUuidModuleMock } from './use-config-test-utils'
describe('use-config-test-utils', () => {
it('createUuidModuleMock should return stable ids from the provided factory', () => {
const mockUuid = vi.fn(() => 'generated-id')
const moduleMock = createUuidModuleMock(mockUuid)
expect(moduleMock.v4()).toBe('generated-id')
expect(mockUuid).toHaveBeenCalledTimes(1)
})
it('createNodeCrudModuleMock should expose inputs and setInputs through the default export', () => {
const setInputs = vi.fn()
const payload = { title: 'Node', type: 'code' }
const moduleMock = createNodeCrudModuleMock<typeof payload>(setInputs)
const result = moduleMock.default('node-1', payload)
expect(moduleMock.__esModule).toBe(true)
expect(result.inputs).toBe(payload)
result.setInputs({ next: true })
expect(setInputs).toHaveBeenCalledWith({ next: true })
})
})

View File

@ -0,0 +1,13 @@
type SetInputsMock = (value: unknown) => void
export const createUuidModuleMock = (getId: () => string) => ({
v4: () => getId(),
})
export const createNodeCrudModuleMock = <T>(setInputs: SetInputsMock) => ({
__esModule: true as const,
default: (_id: string, data: T) => ({
inputs: data,
setInputs,
}),
})

View File

@ -0,0 +1,68 @@
import type { AssignerNodeType } from '../types'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import { AssignerNodeInputType, WriteMode } from '../types'
import {
canAssignToVar,
canAssignVar,
ensureAssignerVersion,
filterVarByType,
normalizeAssignedVarType,
updateOperationItems,
} from '../use-config.helpers'
const createInputs = (version: AssignerNodeType['version'] = '1'): AssignerNodeType => ({
title: 'Assigner',
desc: '',
type: BlockEnum.Assigner,
version,
items: [{
variable_selector: ['conversation', 'count'],
input_type: AssignerNodeInputType.variable,
operation: WriteMode.overwrite,
value: ['node-1', 'value'],
}],
})
describe('assigner use-config helpers', () => {
it('filters vars and selectors by supported targets', () => {
expect(filterVarByType(VarType.any)({ type: VarType.string } as never)).toBe(true)
expect(filterVarByType(VarType.number)({ type: VarType.any } as never)).toBe(true)
expect(filterVarByType(VarType.number)({ type: VarType.string } as never)).toBe(false)
expect(canAssignVar({} as never, ['conversation', 'total'])).toBe(true)
expect(canAssignVar({} as never, ['sys', 'total'])).toBe(false)
})
it('normalizes assigned variable types for append and passthrough write modes', () => {
expect(normalizeAssignedVarType(VarType.arrayString, WriteMode.append)).toBe(VarType.string)
expect(normalizeAssignedVarType(VarType.arrayNumber, WriteMode.append)).toBe(VarType.number)
expect(normalizeAssignedVarType(VarType.arrayObject, WriteMode.append)).toBe(VarType.object)
expect(normalizeAssignedVarType(VarType.number, WriteMode.append)).toBe(VarType.string)
expect(normalizeAssignedVarType(VarType.number, WriteMode.increment)).toBe(VarType.number)
expect(normalizeAssignedVarType(VarType.string, WriteMode.clear)).toBe(VarType.string)
})
it('validates assignment targets for append, arithmetic and fallback modes', () => {
expect(canAssignToVar({ type: VarType.number } as never, VarType.number, WriteMode.multiply)).toBe(true)
expect(canAssignToVar({ type: VarType.string } as never, VarType.number, WriteMode.multiply)).toBe(false)
expect(canAssignToVar({ type: VarType.string } as never, VarType.arrayString, WriteMode.append)).toBe(true)
expect(canAssignToVar({ type: VarType.number } as never, VarType.arrayNumber, WriteMode.append)).toBe(true)
expect(canAssignToVar({ type: VarType.object } as never, VarType.arrayObject, WriteMode.append)).toBe(true)
expect(canAssignToVar({ type: VarType.boolean } as never, VarType.arrayString, WriteMode.append)).toBe(false)
expect(canAssignToVar({ type: VarType.string } as never, VarType.string, WriteMode.set)).toBe(true)
})
it('ensures version 2 and replaces operation items immutably', () => {
const legacyInputs = createInputs('1')
const nextItems = [{
variable_selector: ['conversation', 'total'],
input_type: AssignerNodeInputType.constant,
operation: WriteMode.clear,
value: '0',
}]
expect(ensureAssignerVersion(legacyInputs).version).toBe('2')
expect(ensureAssignerVersion(createInputs('2')).version).toBe('2')
expect(updateOperationItems(legacyInputs, nextItems).items).toEqual(nextItems)
expect(legacyInputs.items).toHaveLength(1)
})
})

View File

@ -0,0 +1,98 @@
import type { AssignerNodeOperation, AssignerNodeType } from '../types'
import { renderHook } from '@testing-library/react'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import { createNodeCrudModuleMock } from '../../__tests__/use-config-test-utils'
import { AssignerNodeInputType, WriteMode, writeModeTypesNum } from '../types'
import useConfig from '../use-config'
const mockSetInputs = vi.hoisted(() => vi.fn())
const mockGetAvailableVars = vi.hoisted(() => vi.fn())
const mockGetCurrentVariableType = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesReadOnly: () => ({ nodesReadOnly: false }),
useIsChatMode: () => false,
useWorkflow: () => ({
getBeforeNodesInSameBranchIncludeParent: () => [
{ id: 'start-node', data: { title: 'Start', type: BlockEnum.Start } },
],
}),
useWorkflowVariables: () => ({
getCurrentVariableType: (...args: unknown[]) => mockGetCurrentVariableType(...args),
}),
}))
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
...createNodeCrudModuleMock<AssignerNodeType>(mockSetInputs),
}))
vi.mock('../hooks', () => ({
useGetAvailableVars: () => mockGetAvailableVars,
}))
vi.mock('reactflow', async () => {
const actual = await vi.importActual<typeof import('reactflow')>('reactflow')
return {
...actual,
useStoreApi: () => ({
getState: () => ({
getNodes: () => [
{ id: 'assigner-node', parentId: 'iteration-parent' },
{ id: 'iteration-parent', data: { title: 'Iteration', type: BlockEnum.Iteration } },
],
}),
}),
}
})
const createOperation = (overrides: Partial<AssignerNodeOperation> = {}): AssignerNodeOperation => ({
variable_selector: ['conversation', 'count'],
input_type: AssignerNodeInputType.variable,
operation: WriteMode.overwrite,
value: ['node-2', 'result'],
...overrides,
})
const createPayload = (overrides: Partial<AssignerNodeType> = {}): AssignerNodeType => ({
title: 'Assigner',
desc: '',
type: BlockEnum.Assigner,
version: '1',
items: [createOperation()],
...overrides,
})
describe('useConfig', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetCurrentVariableType.mockReturnValue(VarType.arrayString)
mockGetAvailableVars.mockReturnValue([])
})
it('should normalize legacy payloads, expose write mode groups and derive assigned variable types', () => {
const { result } = renderHook(() => useConfig('assigner-node', createPayload()))
expect(result.current.readOnly).toBe(false)
expect(result.current.writeModeTypes).toEqual([WriteMode.overwrite, WriteMode.clear, WriteMode.set])
expect(result.current.writeModeTypesNum).toEqual(writeModeTypesNum)
expect(result.current.getAssignedVarType(['conversation', 'count'])).toBe(VarType.arrayString)
expect(result.current.getToAssignedVarType(VarType.arrayString, WriteMode.append)).toBe(VarType.string)
expect(result.current.filterVar(VarType.string)({ type: VarType.any } as never)).toBe(true)
})
it('should update operation lists with version 2 payloads and apply assignment filters', () => {
const { result } = renderHook(() => useConfig('assigner-node', createPayload()))
const nextItems = [createOperation({ operation: WriteMode.append })]
result.current.handleOperationListChanges(nextItems)
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
version: '2',
items: nextItems,
}))
expect(result.current.filterAssignedVar({ isLoopVariable: true } as never, ['node', 'value'])).toBe(true)
expect(result.current.filterAssignedVar({} as never, ['conversation', 'name'])).toBe(true)
expect(result.current.filterToAssignedVar({ type: VarType.string } as never, VarType.arrayString, WriteMode.append)).toBe(true)
expect(result.current.filterToAssignedVar({ type: VarType.number } as never, VarType.arrayString, WriteMode.append)).toBe(false)
})
})

View File

@ -0,0 +1,90 @@
import type { ValueSelector, Var } from '../../types'
import type { AssignerNodeOperation, AssignerNodeType } from './types'
import { produce } from 'immer'
import { VarType } from '../../types'
import { WriteMode } from './types'
export const filterVarByType = (varType: VarType) => {
return (variable: Var) => {
if (varType === VarType.any || variable.type === VarType.any)
return true
return variable.type === varType
}
}
export const normalizeAssignedVarType = (assignedVarType: VarType, writeMode: WriteMode) => {
if (
writeMode === WriteMode.overwrite
|| writeMode === WriteMode.increment
|| writeMode === WriteMode.decrement
|| writeMode === WriteMode.multiply
|| writeMode === WriteMode.divide
|| writeMode === WriteMode.extend
) {
return assignedVarType
}
if (writeMode === WriteMode.append) {
switch (assignedVarType) {
case VarType.arrayString:
return VarType.string
case VarType.arrayNumber:
return VarType.number
case VarType.arrayObject:
return VarType.object
default:
return VarType.string
}
}
return VarType.string
}
export const canAssignVar = (_varPayload: Var, selector: ValueSelector) => {
return selector.join('.').startsWith('conversation')
}
export const canAssignToVar = (
varPayload: Var,
assignedVarType: VarType,
writeMode: WriteMode,
) => {
if (
writeMode === WriteMode.overwrite
|| writeMode === WriteMode.extend
|| writeMode === WriteMode.increment
|| writeMode === WriteMode.decrement
|| writeMode === WriteMode.multiply
|| writeMode === WriteMode.divide
) {
return varPayload.type === assignedVarType
}
if (writeMode === WriteMode.append) {
switch (assignedVarType) {
case VarType.arrayString:
return varPayload.type === VarType.string
case VarType.arrayNumber:
return varPayload.type === VarType.number
case VarType.arrayObject:
return varPayload.type === VarType.object
default:
return false
}
}
return true
}
export const ensureAssignerVersion = (newInputs: AssignerNodeType) => produce(newInputs, (draft) => {
if (draft.version !== '2')
draft.version = '2'
})
export const updateOperationItems = (
inputs: AssignerNodeType,
items: AssignerNodeOperation[],
) => produce(inputs, (draft) => {
draft.items = [...items]
})

View File

@ -1,6 +1,5 @@
import type { ValueSelector, Var } from '../../types'
import type { AssignerNodeOperation, AssignerNodeType } from './types'
import { produce } from 'immer'
import { useCallback, useMemo } from 'react'
import { useStoreApi } from 'reactflow'
import {
@ -10,9 +9,16 @@ import {
useWorkflowVariables,
} from '@/app/components/workflow/hooks'
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
import { VarType } from '../../types'
import { useGetAvailableVars } from './hooks'
import { WriteMode, writeModeTypesNum } from './types'
import {
canAssignToVar,
canAssignVar,
ensureAssignerVersion,
filterVarByType,
normalizeAssignedVarType,
updateOperationItems,
} from './use-config.helpers'
import { convertV1ToV2 } from './utils'
const useConfig = (id: string, rawPayload: AssignerNodeType) => {
@ -20,15 +26,6 @@ const useConfig = (id: string, rawPayload: AssignerNodeType) => {
const { nodesReadOnly: readOnly } = useNodesReadOnly()
const isChatMode = useIsChatMode()
const getAvailableVars = useGetAvailableVars()
const filterVar = (varType: VarType) => {
return (v: Var) => {
if (varType === VarType.any)
return true
if (v.type === VarType.any)
return true
return v.type === varType
}
}
const store = useStoreApi()
const { getBeforeNodesInSameBranchIncludeParent } = useWorkflow()
@ -44,11 +41,7 @@ const useConfig = (id: string, rawPayload: AssignerNodeType) => {
}, [getBeforeNodesInSameBranchIncludeParent, id])
const { inputs, setInputs } = useNodeCrud<AssignerNodeType>(id, payload)
const newSetInputs = useCallback((newInputs: AssignerNodeType) => {
const finalInputs = produce(newInputs, (draft) => {
if (draft.version !== '2')
draft.version = '2'
})
setInputs(finalInputs)
setInputs(ensureAssignerVersion(newInputs))
}, [setInputs])
const { getCurrentVariableType } = useWorkflowVariables()
@ -63,56 +56,21 @@ const useConfig = (id: string, rawPayload: AssignerNodeType) => {
}, [getCurrentVariableType, isInIteration, iterationNode, availableNodes, isChatMode])
const handleOperationListChanges = useCallback((items: AssignerNodeOperation[]) => {
const newInputs = produce(inputs, (draft) => {
draft.items = [...items]
})
newSetInputs(newInputs)
newSetInputs(updateOperationItems(inputs, items))
}, [inputs, newSetInputs])
const writeModeTypesArr = [WriteMode.overwrite, WriteMode.clear, WriteMode.append, WriteMode.extend, WriteMode.removeFirst, WriteMode.removeLast]
const writeModeTypes = [WriteMode.overwrite, WriteMode.clear, WriteMode.set]
const getToAssignedVarType = useCallback((assignedVarType: VarType, write_mode: WriteMode) => {
if (write_mode === WriteMode.overwrite || write_mode === WriteMode.increment || write_mode === WriteMode.decrement
|| write_mode === WriteMode.multiply || write_mode === WriteMode.divide || write_mode === WriteMode.extend) {
return assignedVarType
}
if (write_mode === WriteMode.append) {
if (assignedVarType === VarType.arrayString)
return VarType.string
if (assignedVarType === VarType.arrayNumber)
return VarType.number
if (assignedVarType === VarType.arrayObject)
return VarType.object
}
return VarType.string
}, [])
const getToAssignedVarType = useCallback(normalizeAssignedVarType, [])
const filterAssignedVar = useCallback((varPayload: Var, selector: ValueSelector) => {
if (varPayload.isLoopVariable)
return true
return selector.join('.').startsWith('conversation')
return canAssignVar(varPayload, selector)
}, [])
const filterToAssignedVar = useCallback((varPayload: Var, assignedVarType: VarType, write_mode: WriteMode) => {
if (write_mode === WriteMode.overwrite || write_mode === WriteMode.extend || write_mode === WriteMode.increment
|| write_mode === WriteMode.decrement || write_mode === WriteMode.multiply || write_mode === WriteMode.divide) {
return varPayload.type === assignedVarType
}
else if (write_mode === WriteMode.append) {
switch (assignedVarType) {
case VarType.arrayString:
return varPayload.type === VarType.string
case VarType.arrayNumber:
return varPayload.type === VarType.number
case VarType.arrayObject:
return varPayload.type === VarType.object
default:
return false
}
}
return true
}, [])
const filterToAssignedVar = useCallback(canAssignToVar, [])
return {
readOnly,
@ -126,7 +84,7 @@ const useConfig = (id: string, rawPayload: AssignerNodeType) => {
filterAssignedVar,
filterToAssignedVar,
getAvailableVars,
filterVar,
filterVar: filterVarByType,
}
}

View File

@ -0,0 +1,165 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BodyPayloadValueType, BodyType } from '../../types'
import CurlPanel from '../curl-panel'
import * as curlParser from '../curl-parser'
const {
mockHandleNodeSelect,
mockNotify,
} = vi.hoisted(() => ({
mockHandleNodeSelect: vi.fn(),
mockNotify: vi.fn(),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesInteractions: () => ({
handleNodeSelect: mockHandleNodeSelect,
}),
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: mockNotify,
},
}))
describe('curl-panel', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('parseCurl', () => {
it('should parse method, headers, json body, and query params from a valid curl command', () => {
const { node, error } = curlParser.parseCurl('curl -X POST -H \"Authorization: Bearer token\" --json \"{\"name\":\"openai\"}\" https://example.com/users?page=1&size=2')
expect(error).toBeNull()
expect(node).toMatchObject({
method: 'post',
url: 'https://example.com/users',
headers: 'Authorization: Bearer token',
params: 'page: 1\nsize: 2',
})
})
it('should return an error for invalid curl input', () => {
expect(curlParser.parseCurl('fetch https://example.com').error).toContain('Invalid cURL command')
})
it('should parse form data and attach typed content headers', () => {
const { node, error } = curlParser.parseCurl('curl --request POST --form "file=@report.txt;type=text/plain" --form "name=openai" https://example.com/upload')
expect(error).toBeNull()
expect(node).toMatchObject({
method: 'post',
url: 'https://example.com/upload',
headers: 'Content-Type: text/plain',
body: {
type: BodyType.formData,
data: 'file:@report.txt\nname:openai',
},
})
})
it('should parse raw payloads and preserve equals signs in the body value', () => {
const { node, error } = curlParser.parseCurl('curl --data-binary "token=abc=123" https://example.com/raw')
expect(error).toBeNull()
expect(node?.body).toEqual({
type: BodyType.rawText,
data: [{
type: BodyPayloadValueType.text,
value: 'token=abc=123',
}],
})
})
it.each([
['curl -X', 'Missing HTTP method after -X or --request.'],
['curl --header', 'Missing header value after -H or --header.'],
['curl --data-raw', 'Missing data value after -d, --data, --data-raw, or --data-binary.'],
['curl --form', 'Missing form data after -F or --form.'],
['curl --json', 'Missing JSON data after --json.'],
['curl --form "=broken" https://example.com/upload', 'Invalid form data format.'],
['curl -H "Accept: application/json"', 'Missing URL or url not start with http.'],
])('should return a descriptive error for %s', (command, expectedError) => {
expect(curlParser.parseCurl(command)).toEqual({
node: null,
error: expectedError,
})
})
})
describe('component actions', () => {
it('should import a parsed curl node and reselect the node after saving', async () => {
const user = userEvent.setup()
const onHide = vi.fn()
const handleCurlImport = vi.fn()
render(
<CurlPanel
nodeId="node-1"
isShow
onHide={onHide}
handleCurlImport={handleCurlImport}
/>,
)
await user.type(screen.getByRole('textbox'), 'curl https://example.com')
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onHide).toHaveBeenCalledTimes(1)
expect(handleCurlImport).toHaveBeenCalledWith(expect.objectContaining({
method: 'get',
url: 'https://example.com',
}))
expect(mockHandleNodeSelect).toHaveBeenNthCalledWith(1, 'node-1', true)
})
it('should notify the user when the curl command is invalid', async () => {
const user = userEvent.setup()
render(
<CurlPanel
nodeId="node-1"
isShow
onHide={vi.fn()}
handleCurlImport={vi.fn()}
/>,
)
await user.type(screen.getByRole('textbox'), 'invalid')
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
}))
})
it('should keep the panel open when parsing returns no node and no error', async () => {
const user = userEvent.setup()
const onHide = vi.fn()
const handleCurlImport = vi.fn()
vi.spyOn(curlParser, 'parseCurl').mockReturnValueOnce({
node: null,
error: null,
})
render(
<CurlPanel
nodeId="node-1"
isShow
onHide={onHide}
handleCurlImport={handleCurlImport}
/>,
)
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onHide).not.toHaveBeenCalled()
expect(handleCurlImport).not.toHaveBeenCalled()
expect(mockHandleNodeSelect).not.toHaveBeenCalled()
expect(mockNotify).not.toHaveBeenCalled()
})
})
})

View File

@ -9,7 +9,7 @@ import Modal from '@/app/components/base/modal'
import Textarea from '@/app/components/base/textarea'
import Toast from '@/app/components/base/toast'
import { useNodesInteractions } from '@/app/components/workflow/hooks'
import { BodyPayloadValueType, BodyType, Method } from '../types'
import { parseCurl } from './curl-parser'
type Props = {
nodeId: string
@ -18,104 +18,6 @@ type Props = {
handleCurlImport: (node: HttpNodeType) => void
}
const parseCurl = (curlCommand: string): { node: HttpNodeType | null, error: string | null } => {
if (!curlCommand.trim().toLowerCase().startsWith('curl'))
return { node: null, error: 'Invalid cURL command. Command must start with "curl".' }
const node: Partial<HttpNodeType> = {
title: 'HTTP Request',
desc: 'Imported from cURL',
method: undefined,
url: '',
headers: '',
params: '',
body: { type: BodyType.none, data: '' },
}
const args = curlCommand.match(/(?:[^\s"']|"[^"]*"|'[^']*')+/g) || []
let hasData = false
for (let i = 1; i < args.length; i++) {
const arg = args[i].replace(/^['"]|['"]$/g, '')
switch (arg) {
case '-X':
case '--request':
if (i + 1 >= args.length)
return { node: null, error: 'Missing HTTP method after -X or --request.' }
node.method = (args[++i].replace(/^['"]|['"]$/g, '').toLowerCase() as Method) || Method.get
hasData = true
break
case '-H':
case '--header':
if (i + 1 >= args.length)
return { node: null, error: 'Missing header value after -H or --header.' }
node.headers += (node.headers ? '\n' : '') + args[++i].replace(/^['"]|['"]$/g, '')
break
case '-d':
case '--data':
case '--data-raw':
case '--data-binary': {
if (i + 1 >= args.length)
return { node: null, error: 'Missing data value after -d, --data, --data-raw, or --data-binary.' }
const bodyPayload = [{
type: BodyPayloadValueType.text,
value: args[++i].replace(/^['"]|['"]$/g, ''),
}]
node.body = { type: BodyType.rawText, data: bodyPayload }
break
}
case '-F':
case '--form': {
if (i + 1 >= args.length)
return { node: null, error: 'Missing form data after -F or --form.' }
if (node.body?.type !== BodyType.formData)
node.body = { type: BodyType.formData, data: '' }
const formData = args[++i].replace(/^['"]|['"]$/g, '')
const [key, ...valueParts] = formData.split('=')
if (!key)
return { node: null, error: 'Invalid form data format.' }
let value = valueParts.join('=')
// To support command like `curl -F "file=@/path/to/file;type=application/zip"`
// the `;type=application/zip` should translate to `Content-Type: application/zip`
const typeRegex = /^(.+?);type=(.+)$/
const typeMatch = typeRegex.exec(value)
if (typeMatch) {
const [, actualValue, mimeType] = typeMatch
value = actualValue
node.headers += `${node.headers ? '\n' : ''}Content-Type: ${mimeType}`
}
node.body.data += `${node.body.data ? '\n' : ''}${key}:${value}`
break
}
case '--json':
if (i + 1 >= args.length)
return { node: null, error: 'Missing JSON data after --json.' }
node.body = { type: BodyType.json, data: args[++i].replace(/^['"]|['"]$/g, '') }
break
default:
if (arg.startsWith('http') && !node.url)
node.url = arg
break
}
}
// Determine final method
node.method = node.method || (hasData ? Method.post : Method.get)
if (!node.url)
return { node: null, error: 'Missing URL or url not start with http.' }
// Extract query params from URL
const urlParts = node.url?.split('?') || []
if (urlParts.length > 1) {
node.url = urlParts[0]
node.params = urlParts[1].replace(/&/g, '\n').replace(/=/g, ': ')
}
return { node: node as HttpNodeType, error: null }
}
const CurlPanel: FC<Props> = ({ nodeId, isShow, onHide, handleCurlImport }) => {
const [inputString, setInputString] = useState('')
const { handleNodeSelect } = useNodesInteractions()

View File

@ -0,0 +1,171 @@
import type { HttpNodeType } from '../types'
import { BodyPayloadValueType, BodyType, Method } from '../types'
const METHOD_ARG_FLAGS = new Set(['-X', '--request'])
const HEADER_ARG_FLAGS = new Set(['-H', '--header'])
const DATA_ARG_FLAGS = new Set(['-d', '--data', '--data-raw', '--data-binary'])
const FORM_ARG_FLAGS = new Set(['-F', '--form'])
type ParseStepResult = {
error: string | null
nextIndex: number
hasData?: boolean
}
const stripWrappedQuotes = (value: string) => {
return value.replace(/^['"]|['"]$/g, '')
}
const parseCurlArgs = (curlCommand: string) => {
return curlCommand.match(/(?:[^\s"']|"[^"]*"|'[^']*')+/g) || []
}
const buildDefaultNode = (): Partial<HttpNodeType> => ({
title: 'HTTP Request',
desc: 'Imported from cURL',
method: undefined,
url: '',
headers: '',
params: '',
body: { type: BodyType.none, data: '' },
})
const extractUrlParams = (url: string) => {
const urlParts = url.split('?')
if (urlParts.length <= 1)
return { url, params: '' }
return {
url: urlParts[0],
params: urlParts[1].replace(/&/g, '\n').replace(/=/g, ': '),
}
}
const getNextArg = (args: string[], index: number, error: string): { value: string, error: null } | { value: null, error: string } => {
if (index + 1 >= args.length)
return { value: null, error }
return {
value: stripWrappedQuotes(args[index + 1]),
error: null,
}
}
const applyMethodArg = (node: Partial<HttpNodeType>, args: string[], index: number): ParseStepResult => {
const nextArg = getNextArg(args, index, 'Missing HTTP method after -X or --request.')
if (nextArg.error || nextArg.value === null)
return { error: nextArg.error, nextIndex: index, hasData: false }
node.method = (nextArg.value.toLowerCase() as Method) || Method.get
return { error: null, nextIndex: index + 1, hasData: true }
}
const applyHeaderArg = (node: Partial<HttpNodeType>, args: string[], index: number): ParseStepResult => {
const nextArg = getNextArg(args, index, 'Missing header value after -H or --header.')
if (nextArg.error || nextArg.value === null)
return { error: nextArg.error, nextIndex: index }
node.headers += `${node.headers ? '\n' : ''}${nextArg.value}`
return { error: null, nextIndex: index + 1 }
}
const applyDataArg = (node: Partial<HttpNodeType>, args: string[], index: number): ParseStepResult => {
const nextArg = getNextArg(args, index, 'Missing data value after -d, --data, --data-raw, or --data-binary.')
if (nextArg.error || nextArg.value === null)
return { error: nextArg.error, nextIndex: index }
node.body = {
type: BodyType.rawText,
data: [{ type: BodyPayloadValueType.text, value: nextArg.value }],
}
return { error: null, nextIndex: index + 1 }
}
const applyFormArg = (node: Partial<HttpNodeType>, args: string[], index: number): ParseStepResult => {
const nextArg = getNextArg(args, index, 'Missing form data after -F or --form.')
if (nextArg.error || nextArg.value === null)
return { error: nextArg.error, nextIndex: index }
if (node.body?.type !== BodyType.formData)
node.body = { type: BodyType.formData, data: '' }
const [key, ...valueParts] = nextArg.value.split('=')
if (!key)
return { error: 'Invalid form data format.', nextIndex: index }
let value = valueParts.join('=')
const typeMatch = /^(.+?);type=(.+)$/.exec(value)
if (typeMatch) {
const [, actualValue, mimeType] = typeMatch
value = actualValue
node.headers += `${node.headers ? '\n' : ''}Content-Type: ${mimeType}`
}
node.body.data += `${node.body.data ? '\n' : ''}${key}:${value}`
return { error: null, nextIndex: index + 1 }
}
const applyJsonArg = (node: Partial<HttpNodeType>, args: string[], index: number): ParseStepResult => {
const nextArg = getNextArg(args, index, 'Missing JSON data after --json.')
if (nextArg.error || nextArg.value === null)
return { error: nextArg.error, nextIndex: index }
node.body = { type: BodyType.json, data: nextArg.value }
return { error: null, nextIndex: index + 1 }
}
const handleCurlArg = (
arg: string,
node: Partial<HttpNodeType>,
args: string[],
index: number,
): ParseStepResult => {
if (METHOD_ARG_FLAGS.has(arg))
return applyMethodArg(node, args, index)
if (HEADER_ARG_FLAGS.has(arg))
return applyHeaderArg(node, args, index)
if (DATA_ARG_FLAGS.has(arg))
return applyDataArg(node, args, index)
if (FORM_ARG_FLAGS.has(arg))
return applyFormArg(node, args, index)
if (arg === '--json')
return applyJsonArg(node, args, index)
if (arg.startsWith('http') && !node.url)
node.url = arg
return { error: null, nextIndex: index, hasData: false }
}
export const parseCurl = (curlCommand: string): { node: HttpNodeType | null, error: string | null } => {
if (!curlCommand.trim().toLowerCase().startsWith('curl'))
return { node: null, error: 'Invalid cURL command. Command must start with "curl".' }
const node = buildDefaultNode()
const args = parseCurlArgs(curlCommand)
let hasData = false
for (let i = 1; i < args.length; i++) {
const result = handleCurlArg(stripWrappedQuotes(args[i]), node, args, i)
if (result.error)
return { node: null, error: result.error }
hasData ||= Boolean(result.hasData)
i = result.nextIndex
}
node.method = node.method || (hasData ? Method.post : Method.get)
if (!node.url)
return { node: null, error: 'Missing URL or url not start with http.' }
const parsedUrl = extractUrlParams(node.url)
node.url = parsedUrl.url
node.params = parsedUrl.params
return { node: node as HttpNodeType, error: null }
}

View File

@ -0,0 +1,114 @@
import { render, screen } from '@testing-library/react'
import { Note, rehypeNotes, rehypeVariable, Variable } from '../variable-in-markdown'
describe('variable-in-markdown', () => {
describe('rehypeVariable', () => {
it('should replace variable tokens with variable elements and preserve surrounding text', () => {
const tree = {
children: [
{
type: 'text',
value: 'Hello {{#node.field#}} world',
},
],
}
rehypeVariable()(tree)
expect(tree.children).toEqual([
{ type: 'text', value: 'Hello ' },
{
type: 'element',
tagName: 'variable',
properties: { dataPath: '{{#node.field#}}' },
children: [],
},
{ type: 'text', value: ' world' },
])
})
it('should ignore note tokens while processing variable nodes', () => {
const tree = {
children: [
{
type: 'text',
value: 'Hello {{#$node.field#}} world',
},
],
}
rehypeVariable()(tree)
expect(tree.children).toEqual([
{
type: 'text',
value: 'Hello {{#$node.field#}} world',
},
])
})
})
describe('rehypeNotes', () => {
it('should replace note tokens with section nodes and update the parent tag name', () => {
const tree = {
tagName: 'p',
children: [
{
type: 'text',
value: 'See {{#$node.title#}} please',
},
],
}
rehypeNotes()(tree)
expect(tree.tagName).toBe('div')
expect(tree.children).toEqual([
{ type: 'text', value: 'See ' },
{
type: 'element',
tagName: 'section',
properties: { dataName: 'title' },
children: [],
},
{ type: 'text', value: ' please' },
])
})
})
describe('rendering', () => {
it('should format variable paths for display', () => {
render(<Variable path="{{#node.field#}}" />)
expect(screen.getByText('{{node/field}}')).toBeInTheDocument()
})
it('should render note values and replace node ids with labels for variable defaults', () => {
const { rerender } = render(
<Note
defaultInput={{
type: 'variable',
selector: ['node-1', 'output'],
value: '',
}}
nodeName={nodeId => nodeId === 'node-1' ? 'Start Node' : nodeId}
/>,
)
expect(screen.getByText('{{Start Node/output}}')).toBeInTheDocument()
rerender(
<Note
defaultInput={{
type: 'constant',
value: 'Plain value',
selector: [],
}}
nodeName={nodeId => nodeId}
/>,
)
expect(screen.getByText('Plain value')).toBeInTheDocument()
})
})
})

View File

@ -4,121 +4,130 @@ import type { FormInputItemDefault } from '../types'
const variableRegex = /\{\{#(.+?)#\}\}/g
const noteRegex = /\{\{#\$(.+?)#\}\}/g
export function rehypeVariable() {
return (tree: any) => {
const iterate = (node: any, index: number, parent: any) => {
const value = node.value
type MarkdownNode = {
type?: string
value?: string
tagName?: string
properties?: Record<string, string>
children?: MarkdownNode[]
}
type SplitMatchResult = {
tagName: string
properties: Record<string, string>
}
const splitTextNode = (
value: string,
regex: RegExp,
createMatchNode: (match: RegExpExecArray) => SplitMatchResult,
) => {
const parts: MarkdownNode[] = []
let lastIndex = 0
let match = regex.exec(value)
while (match !== null) {
if (match.index > lastIndex)
parts.push({ type: 'text', value: value.slice(lastIndex, match.index) })
const { tagName, properties } = createMatchNode(match)
parts.push({
type: 'element',
tagName,
properties,
children: [],
})
lastIndex = match.index + match[0].length
match = regex.exec(value)
}
if (!parts.length)
return parts
if (lastIndex < value.length)
parts.push({ type: 'text', value: value.slice(lastIndex) })
return parts
}
const visitTextNodes = (
node: MarkdownNode,
transform: (value: string, parent: MarkdownNode) => MarkdownNode[] | null,
) => {
if (!node.children)
return
let index = 0
while (index < node.children.length) {
const child = node.children[index]
if (child.type === 'text' && typeof child.value === 'string') {
const nextNodes = transform(child.value, node)
if (nextNodes) {
node.children.splice(index, 1, ...nextNodes)
index += nextNodes.length
continue
}
}
visitTextNodes(child, transform)
index++
}
}
const replaceNodeIdsWithNames = (path: string, nodeName: (nodeId: string) => string) => {
return path.replace(/#([^#.]+)([.#])/g, (_, nodeId: string, separator: string) => {
return `#${nodeName(nodeId)}${separator}`
})
}
const formatVariablePath = (path: string) => {
return path.replaceAll('.', '/')
.replace('{{#', '{{')
.replace('#}}', '}}')
}
export function rehypeVariable() {
return (tree: MarkdownNode) => {
visitTextNodes(tree, (value) => {
variableRegex.lastIndex = 0
noteRegex.lastIndex = 0
if (node.type === 'text' && variableRegex.test(value) && !noteRegex.test(value)) {
let m: RegExpExecArray | null
let last = 0
const parts: any[] = []
variableRegex.lastIndex = 0
m = variableRegex.exec(value)
while (m !== null) {
if (m.index > last)
parts.push({ type: 'text', value: value.slice(last, m.index) })
if (!variableRegex.test(value) || noteRegex.test(value))
return null
parts.push({
type: 'element',
tagName: 'variable',
properties: { dataPath: m[0].trim() },
children: [],
})
last = m.index + m[0].length
m = variableRegex.exec(value)
}
if (parts.length) {
if (last < value.length)
parts.push({ type: 'text', value: value.slice(last) })
parent.children.splice(index, 1, ...parts)
}
}
if (node.children) {
let i = 0
// Caution: can not use forEach. Because the length of tree.children may be changed because of change content: parent.children.splice(index, 1, ...parts)
while (i < node.children.length) {
iterate(node.children[i], i, node)
i++
}
}
}
let i = 0
// Caution: can not use forEach. Because the length of tree.children may be changed because of change content: parent.children.splice(index, 1, ...parts)
while (i < tree.children.length) {
iterate(tree.children[i], i, tree)
i++
}
variableRegex.lastIndex = 0
return splitTextNode(value, variableRegex, match => ({
tagName: 'variable',
properties: { dataPath: match[0].trim() },
}))
})
}
}
export function rehypeNotes() {
return (tree: any) => {
const iterate = (node: any, index: number, parent: any) => {
const value = node.value
return (tree: MarkdownNode) => {
visitTextNodes(tree, (value, parent) => {
noteRegex.lastIndex = 0
if (!noteRegex.test(value))
return null
noteRegex.lastIndex = 0
if (node.type === 'text' && noteRegex.test(value)) {
let m: RegExpExecArray | null
let last = 0
const parts: any[] = []
noteRegex.lastIndex = 0
m = noteRegex.exec(value)
while (m !== null) {
if (m.index > last)
parts.push({ type: 'text', value: value.slice(last, m.index) })
const name = m[0].split('.').slice(-1)[0].replace('#}}', '')
parts.push({
type: 'element',
tagName: 'section',
properties: { dataName: name },
children: [],
})
last = m.index + m[0].length
m = noteRegex.exec(value)
parent.tagName = 'div'
return splitTextNode(value, noteRegex, (match) => {
const name = match[0].split('.').slice(-1)[0].replace('#}}', '')
return {
tagName: 'section',
properties: { dataName: name },
}
if (parts.length) {
if (last < value.length)
parts.push({ type: 'text', value: value.slice(last) })
parent.children.splice(index, 1, ...parts)
parent.tagName = 'div' // h2 can not in p. In note content include the h2
}
}
if (node.children) {
let i = 0
// Caution: can not use forEach. Because the length of tree.children may be changed because of change content: parent.children.splice(index, 1, ...parts)
while (i < node.children.length) {
iterate(node.children[i], i, node)
i++
}
}
}
let i = 0
// Caution: can not use forEach. Because the length of tree.children may be changed because of change content: parent.children.splice(index, 1, ...parts)
while (i < tree.children.length) {
iterate(tree.children[i], i, tree)
i++
}
})
})
}
}
export const Variable: React.FC<{ path: string }> = ({ path }) => {
return (
<span className="text-text-accent">
{
path.replaceAll('.', '/')
.replace('{{#', '{{')
.replace('#}}', '}}')
}
{formatVariablePath(path)}
</span>
)
}
@ -126,12 +135,7 @@ export const Variable: React.FC<{ path: string }> = ({ path }) => {
export const Note: React.FC<{ defaultInput: FormInputItemDefault, nodeName: (nodeId: string) => string }> = ({ defaultInput, nodeName }) => {
const isVariable = defaultInput.type === 'variable'
const path = `{{#${defaultInput.selector.join('.')}#}}`
let newPath = path
if (path) {
newPath = path.replace(/#([^#.]+)([.#])/g, (match, nodeId, sep) => {
return `#${nodeName(nodeId)}${sep}`
})
}
const newPath = path ? replaceNodeIdsWithNames(path, nodeName) : path
return (
<div className="my-3 rounded-[10px] bg-components-input-bg-normal px-2.5 py-2">
{isVariable ? <Variable path={newPath} /> : <span>{defaultInput.value}</span>}

View File

@ -0,0 +1,172 @@
import type { IfElseNodeType } from '../types'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import { LogicalOperator } from '../types'
import {
addCase,
addCondition,
addSubVariableCondition,
filterAllVars,
filterNumberVars,
getVarsIsVarFileAttribute,
removeCase,
removeCondition,
removeSubVariableCondition,
sortCases,
toggleConditionLogicalOperator,
toggleSubVariableConditionLogicalOperator,
updateCondition,
updateSubVariableCondition,
} from '../use-config.helpers'
type TestIfElseInputs = ReturnType<typeof createInputs>
const createInputs = (): IfElseNodeType => ({
title: 'If/Else',
desc: '',
type: BlockEnum.IfElse,
cases: [{
case_id: 'case-1',
logical_operator: LogicalOperator.and,
conditions: [{
id: 'condition-1',
varType: VarType.string,
variable_selector: ['node', 'value'],
comparison_operator: 'contains',
value: '',
}],
}],
_targetBranches: [
{ id: 'case-1', name: 'Case 1' },
{ id: 'false', name: 'Else' },
],
} as unknown as IfElseNodeType)
describe('if-else use-config helpers', () => {
it('filters vars and derives file attribute flags', () => {
expect(filterAllVars()).toBe(true)
expect(filterNumberVars({ type: VarType.number } as never)).toBe(true)
expect(filterNumberVars({ type: VarType.string } as never)).toBe(false)
expect(getVarsIsVarFileAttribute(createInputs().cases, selector => selector[1] === 'value')).toEqual({
'condition-1': true,
})
})
it('adds, removes and sorts cases while keeping target branches aligned', () => {
const added = addCase(createInputs())
expect(added.cases).toHaveLength(2)
expect(added._targetBranches?.map(branch => branch.id)).toContain('false')
const removed = removeCase(added, 'case-1')
expect(removed.cases?.some(item => item.case_id === 'case-1')).toBe(false)
const sorted = sortCases(createInputs(), [
{ id: 'display-2', case_id: 'case-2', logical_operator: LogicalOperator.or, conditions: [] },
{ id: 'display-1', case_id: 'case-1', logical_operator: LogicalOperator.and, conditions: [] },
] as unknown as Parameters<typeof sortCases>[1])
expect(sorted.cases?.map(item => item.case_id)).toEqual(['case-2', 'case-1'])
expect(sorted._targetBranches?.map(branch => branch.id)).toEqual(['case-2', 'case-1', 'false'])
})
it('adds, updates, toggles and removes conditions and sub-conditions', () => {
const withCondition = addCondition({
inputs: createInputs(),
caseId: 'case-1',
valueSelector: ['node', 'flag'],
variable: { type: VarType.boolean } as never,
isVarFileAttribute: false,
})
expect(withCondition.cases?.[0]?.conditions).toHaveLength(2)
expect(withCondition.cases?.[0]?.conditions[1]).toEqual(expect.objectContaining({
value: false,
variable_selector: ['node', 'flag'],
}))
const updatedCondition = updateCondition(withCondition, 'case-1', 'condition-1', {
id: 'condition-1',
value: 'next',
comparison_operator: '=',
} as Parameters<typeof updateCondition>[3])
expect(updatedCondition.cases?.[0]?.conditions[0]).toEqual(expect.objectContaining({
value: 'next',
comparison_operator: '=',
}))
const toggled = toggleConditionLogicalOperator(updatedCondition, 'case-1')
expect(toggled.cases?.[0]?.logical_operator).toBe(LogicalOperator.or)
const withSubCondition = addSubVariableCondition(toggled, 'case-1', 'condition-1', 'name')
expect(withSubCondition.cases?.[0]?.conditions[0]?.sub_variable_condition?.conditions[0]).toEqual(expect.objectContaining({
key: 'name',
value: '',
}))
const firstSubConditionId = withSubCondition.cases?.[0]?.conditions[0]?.sub_variable_condition?.conditions[0]?.id
expect(firstSubConditionId).toBeTruthy()
const updatedSubCondition = updateSubVariableCondition(
withSubCondition,
'case-1',
'condition-1',
firstSubConditionId!,
{ key: 'size', comparison_operator: '>', value: '10' } as TestIfElseInputs['cases'][number]['conditions'][number],
)
expect(updatedSubCondition.cases?.[0]?.conditions[0]?.sub_variable_condition?.conditions[0]).toEqual(expect.objectContaining({
key: 'size',
value: '10',
}))
const toggledSub = toggleSubVariableConditionLogicalOperator(updatedSubCondition, 'case-1', 'condition-1')
expect(toggledSub.cases?.[0]?.conditions[0]?.sub_variable_condition?.logical_operator).toBe(LogicalOperator.or)
const removedSub = removeSubVariableCondition(
toggledSub,
'case-1',
'condition-1',
firstSubConditionId!,
)
expect(removedSub.cases?.[0]?.conditions[0]?.sub_variable_condition?.conditions).toEqual([])
const removedCondition = removeCondition(removedSub, 'case-1', 'condition-1')
expect(removedCondition.cases?.[0]?.conditions.some(item => item.id === 'condition-1')).toBe(false)
})
it('keeps inputs unchanged when guard branches short-circuit helper updates', () => {
const unchangedWithoutCases = addCase({
...createInputs(),
cases: undefined,
} as unknown as IfElseNodeType)
expect(unchangedWithoutCases.cases).toBeUndefined()
const withoutTargetBranches = addCase({
...createInputs(),
_targetBranches: undefined,
})
expect(withoutTargetBranches._targetBranches).toBeUndefined()
const withoutElseBranch = addCase({
...createInputs(),
_targetBranches: [{ id: 'case-1', name: 'Case 1' }],
})
expect(withoutElseBranch._targetBranches).toEqual([{ id: 'case-1', name: 'Case 1' }])
const unchangedWhenConditionMissing = addSubVariableCondition(createInputs(), 'case-1', 'missing-condition', 'name')
expect(unchangedWhenConditionMissing).toEqual(createInputs())
const unchangedWhenSubConditionMissing = removeSubVariableCondition(createInputs(), 'case-1', 'condition-1', 'missing-sub')
expect(unchangedWhenSubConditionMissing).toEqual(createInputs())
const unchangedWhenCaseIsMissingForCondition = addCondition({
inputs: createInputs(),
caseId: 'missing-case',
valueSelector: ['node', 'value'],
variable: { type: VarType.string } as never,
isVarFileAttribute: false,
})
expect(unchangedWhenCaseIsMissingForCondition).toEqual(createInputs())
const unchangedWhenCaseMissing = toggleConditionLogicalOperator(createInputs(), 'missing-case')
expect(unchangedWhenCaseMissing).toEqual(createInputs())
const unchangedWhenSubVariableGroupMissing = toggleSubVariableConditionLogicalOperator(createInputs(), 'case-1', 'condition-1')
expect(unchangedWhenSubVariableGroupMissing).toEqual(createInputs())
})
})

View File

@ -0,0 +1,266 @@
import type { IfElseNodeType } from '../types'
import { renderHook } from '@testing-library/react'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import {
createNodeCrudModuleMock,
createUuidModuleMock,
} from '../../__tests__/use-config-test-utils'
import { ComparisonOperator, LogicalOperator } from '../types'
import useConfig from '../use-config'
const mockSetInputs = vi.hoisted(() => vi.fn())
const mockHandleEdgeDeleteByDeleteBranch = vi.hoisted(() => vi.fn())
const mockUpdateNodeInternals = vi.hoisted(() => vi.fn())
const mockGetIsVarFileAttribute = vi.hoisted(() => vi.fn())
const mockUuid = vi.hoisted(() => vi.fn(() => 'generated-id'))
vi.mock('uuid', () => ({
...createUuidModuleMock(mockUuid),
}))
vi.mock('reactflow', async () => {
const actual = await vi.importActual<typeof import('reactflow')>('reactflow')
return {
...actual,
useUpdateNodeInternals: () => mockUpdateNodeInternals,
}
})
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesReadOnly: () => ({ nodesReadOnly: false }),
useEdgesInteractions: () => ({
handleEdgeDeleteByDeleteBranch: (...args: unknown[]) => mockHandleEdgeDeleteByDeleteBranch(...args),
}),
}))
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
...createNodeCrudModuleMock<IfElseNodeType>(mockSetInputs),
}))
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', () => ({
__esModule: true,
default: (_id: string, { filterVar }: { filterVar: (value: { type: VarType }) => boolean }) => ({
availableVars: filterVar({ type: VarType.number })
? [{ nodeId: 'node-1', title: 'Start', vars: [{ variable: 'score', type: VarType.number }] }]
: [{ nodeId: 'node-1', title: 'Start', vars: [{ variable: 'answer', type: VarType.string }] }],
availableNodesWithParent: [],
}),
}))
vi.mock('../use-is-var-file-attribute', () => ({
__esModule: true,
default: () => ({
getIsVarFileAttribute: (...args: unknown[]) => mockGetIsVarFileAttribute(...args),
}),
}))
const createPayload = (overrides: Partial<IfElseNodeType> = {}): IfElseNodeType => ({
title: 'If Else',
desc: '',
type: BlockEnum.IfElse,
isInIteration: false,
isInLoop: false,
cases: [{
case_id: 'case-1',
logical_operator: LogicalOperator.and,
conditions: [{
id: 'condition-1',
varType: VarType.string,
variable_selector: ['node-1', 'answer'],
comparison_operator: ComparisonOperator.contains,
value: 'hello',
}],
}],
_targetBranches: [
{ id: 'case-1', name: 'IF' },
{ id: 'false', name: 'ELSE' },
],
...overrides,
})
describe('useConfig', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetIsVarFileAttribute.mockReturnValue(false)
})
it('should expose derived vars and file-attribute flags', () => {
const { result } = renderHook(() => useConfig('if-node', createPayload()))
expect(result.current.readOnly).toBe(false)
expect(result.current.filterVar()).toBe(true)
expect(result.current.filterNumberVar({ type: VarType.number } as never)).toBe(true)
expect(result.current.filterNumberVar({ type: VarType.string } as never)).toBe(false)
expect(result.current.nodesOutputVars).toHaveLength(1)
expect(result.current.nodesOutputNumberVars).toHaveLength(1)
expect(result.current.varsIsVarFileAttribute).toEqual({ 'condition-1': false })
})
it('should manage cases and conditions', () => {
const { result } = renderHook(() => useConfig('if-node', createPayload()))
result.current.handleAddCase()
result.current.handleRemoveCase('generated-id')
result.current.handleAddCondition('case-1', ['node-1', 'score'], { type: VarType.number } as never)
result.current.handleUpdateCondition('case-1', 'condition-1', {
id: 'condition-1',
varType: VarType.number,
variable_selector: ['node-1', 'score'],
comparison_operator: ComparisonOperator.largerThan,
value: '3',
})
result.current.handleRemoveCondition('case-1', 'condition-1')
result.current.handleToggleConditionLogicalOperator('case-1')
result.current.handleSortCase([{
id: 'sortable-1',
case_id: 'case-1',
logical_operator: LogicalOperator.or,
conditions: [],
}])
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
cases: expect.arrayContaining([
expect.objectContaining({
case_id: 'generated-id',
logical_operator: LogicalOperator.and,
}),
]),
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
cases: [
expect.objectContaining({
case_id: 'case-1',
logical_operator: LogicalOperator.or,
}),
],
_targetBranches: [
{ id: 'case-1', name: 'IF' },
{ id: 'false', name: 'ELSE' },
],
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
cases: expect.arrayContaining([
expect.objectContaining({
conditions: expect.arrayContaining([
expect.objectContaining({
id: 'generated-id',
variable_selector: ['node-1', 'score'],
}),
]),
}),
]),
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
cases: expect.arrayContaining([
expect.objectContaining({
conditions: expect.arrayContaining([
expect.objectContaining({
id: 'condition-1',
comparison_operator: ComparisonOperator.largerThan,
value: '3',
}),
]),
}),
]),
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
cases: expect.arrayContaining([
expect.objectContaining({
logical_operator: LogicalOperator.or,
}),
]),
}))
expect(mockHandleEdgeDeleteByDeleteBranch).toHaveBeenCalledWith('if-node', 'generated-id')
expect(mockUpdateNodeInternals).toHaveBeenCalledWith('if-node')
})
it('should manage sub-variable conditions', () => {
const payload = createPayload({
cases: [{
case_id: 'case-1',
logical_operator: LogicalOperator.and,
conditions: [{
id: 'condition-1',
varType: VarType.file,
variable_selector: ['node-1', 'files'],
comparison_operator: ComparisonOperator.exists,
value: '',
sub_variable_condition: {
case_id: 'sub-case-1',
logical_operator: LogicalOperator.and,
conditions: [{
id: 'sub-1',
key: 'name',
varType: VarType.string,
comparison_operator: ComparisonOperator.contains,
value: '',
}],
},
}],
}],
})
const { result } = renderHook(() => useConfig('if-node', payload))
result.current.handleAddSubVariableCondition('case-1', 'condition-1', 'name')
result.current.handleUpdateSubVariableCondition('case-1', 'condition-1', 'sub-1', {
id: 'sub-1',
key: 'size',
varType: VarType.string,
comparison_operator: ComparisonOperator.is,
value: '2',
})
result.current.handleRemoveSubVariableCondition('case-1', 'condition-1', 'sub-1')
result.current.handleToggleSubVariableConditionLogicalOperator('case-1', 'condition-1')
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
cases: expect.arrayContaining([
expect.objectContaining({
conditions: expect.arrayContaining([
expect.objectContaining({
sub_variable_condition: expect.objectContaining({
conditions: expect.arrayContaining([
expect.objectContaining({
id: 'generated-id',
key: 'name',
}),
]),
}),
}),
]),
}),
]),
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
cases: expect.arrayContaining([
expect.objectContaining({
conditions: expect.arrayContaining([
expect.objectContaining({
sub_variable_condition: expect.objectContaining({
conditions: expect.arrayContaining([
expect.objectContaining({
id: 'sub-1',
key: 'size',
value: '2',
}),
]),
}),
}),
]),
}),
]),
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
cases: expect.arrayContaining([
expect.objectContaining({
conditions: expect.arrayContaining([
expect.objectContaining({
sub_variable_condition: expect.objectContaining({
logical_operator: LogicalOperator.or,
}),
}),
]),
}),
]),
}))
})
})

View File

@ -0,0 +1,237 @@
import type { Branch, Var } from '../../types'
import type { CaseItem, Condition, IfElseNodeType } from './types'
import { produce } from 'immer'
import { v4 as uuid4 } from 'uuid'
import { VarType } from '../../types'
import { LogicalOperator } from './types'
import {
branchNameCorrect,
getOperators,
} from './utils'
export const filterAllVars = () => true
export const filterNumberVars = (varPayload: Var) => varPayload.type === VarType.number
export const getVarsIsVarFileAttribute = (
cases: IfElseNodeType['cases'],
getIsVarFileAttribute: (valueSelector: string[]) => boolean,
) => {
const conditions: Record<string, boolean> = {}
cases?.forEach((caseItem) => {
caseItem.conditions.forEach((condition) => {
if (condition.variable_selector)
conditions[condition.id] = getIsVarFileAttribute(condition.variable_selector)
})
})
return conditions
}
const getTargetBranchesWithNewCase = (targetBranches: Branch[] | undefined, caseId: string) => {
if (!targetBranches)
return targetBranches
const elseCaseIndex = targetBranches.findIndex(branch => branch.id === 'false')
if (elseCaseIndex < 0)
return targetBranches
return branchNameCorrect([
...targetBranches.slice(0, elseCaseIndex),
{
id: caseId,
name: '',
},
...targetBranches.slice(elseCaseIndex),
])
}
export const addCase = (inputs: IfElseNodeType) => produce(inputs, (draft) => {
if (!draft.cases)
return
const caseId = uuid4()
draft.cases.push({
case_id: caseId,
logical_operator: LogicalOperator.and,
conditions: [],
})
draft._targetBranches = getTargetBranchesWithNewCase(draft._targetBranches, caseId)
})
export const removeCase = (
inputs: IfElseNodeType,
caseId: string,
) => produce(inputs, (draft) => {
draft.cases = draft.cases?.filter(item => item.case_id !== caseId)
if (draft._targetBranches)
draft._targetBranches = branchNameCorrect(draft._targetBranches.filter(branch => branch.id !== caseId))
})
export const sortCases = (
inputs: IfElseNodeType,
newCases: (CaseItem & { id: string })[],
) => produce(inputs, (draft) => {
draft.cases = newCases.filter(Boolean).map(item => ({
id: item.id,
case_id: item.case_id,
logical_operator: item.logical_operator,
conditions: item.conditions,
}))
draft._targetBranches = branchNameCorrect([
...newCases.filter(Boolean).map(item => ({ id: item.case_id, name: '' })),
{ id: 'false', name: '' },
])
})
export const addCondition = ({
inputs,
caseId,
valueSelector,
variable,
isVarFileAttribute,
}: {
inputs: IfElseNodeType
caseId: string
valueSelector: string[]
variable: Var
isVarFileAttribute: boolean
}) => produce(inputs, (draft) => {
const targetCase = draft.cases?.find(item => item.case_id === caseId)
if (!targetCase)
return
targetCase.conditions.push({
id: uuid4(),
varType: variable.type,
variable_selector: valueSelector,
comparison_operator: getOperators(variable.type, isVarFileAttribute ? { key: valueSelector.slice(-1)[0] } : undefined)[0],
value: (variable.type === VarType.boolean || variable.type === VarType.arrayBoolean) ? false : '',
})
})
export const removeCondition = (
inputs: IfElseNodeType,
caseId: string,
conditionId: string,
) => produce(inputs, (draft) => {
const targetCase = draft.cases?.find(item => item.case_id === caseId)
if (targetCase)
targetCase.conditions = targetCase.conditions.filter(item => item.id !== conditionId)
})
export const updateCondition = (
inputs: IfElseNodeType,
caseId: string,
conditionId: string,
nextCondition: Condition,
) => produce(inputs, (draft) => {
const targetCondition = draft.cases
?.find(item => item.case_id === caseId)
?.conditions
.find(item => item.id === conditionId)
if (targetCondition)
Object.assign(targetCondition, nextCondition)
})
export const toggleConditionLogicalOperator = (
inputs: IfElseNodeType,
caseId: string,
) => produce(inputs, (draft) => {
const targetCase = draft.cases?.find(item => item.case_id === caseId)
if (!targetCase)
return
targetCase.logical_operator = targetCase.logical_operator === LogicalOperator.and
? LogicalOperator.or
: LogicalOperator.and
})
export const addSubVariableCondition = (
inputs: IfElseNodeType,
caseId: string,
conditionId: string,
key?: string,
) => produce(inputs, (draft) => {
const condition = draft.cases
?.find(item => item.case_id === caseId)
?.conditions
.find(item => item.id === conditionId)
if (!condition)
return
if (!condition.sub_variable_condition) {
condition.sub_variable_condition = {
case_id: uuid4(),
logical_operator: LogicalOperator.and,
conditions: [],
}
}
condition.sub_variable_condition.conditions.push({
id: uuid4(),
key: key || '',
varType: VarType.string,
comparison_operator: undefined,
value: '',
})
})
export const removeSubVariableCondition = (
inputs: IfElseNodeType,
caseId: string,
conditionId: string,
subConditionId: string,
) => produce(inputs, (draft) => {
const subVariableCondition = draft.cases
?.find(item => item.case_id === caseId)
?.conditions
.find(item => item.id === conditionId)
?.sub_variable_condition
if (!subVariableCondition)
return
subVariableCondition.conditions = subVariableCondition.conditions.filter(item => item.id !== subConditionId)
})
export const updateSubVariableCondition = (
inputs: IfElseNodeType,
caseId: string,
conditionId: string,
subConditionId: string,
nextCondition: Condition,
) => produce(inputs, (draft) => {
const targetSubCondition = draft.cases
?.find(item => item.case_id === caseId)
?.conditions
.find(item => item.id === conditionId)
?.sub_variable_condition
?.conditions
.find(item => item.id === subConditionId)
if (targetSubCondition)
Object.assign(targetSubCondition, nextCondition)
})
export const toggleSubVariableConditionLogicalOperator = (
inputs: IfElseNodeType,
caseId: string,
conditionId: string,
) => produce(inputs, (draft) => {
const targetSubVariableCondition = draft.cases
?.find(item => item.case_id === caseId)
?.conditions
.find(item => item.id === conditionId)
?.sub_variable_condition
if (!targetSubVariableCondition)
return
targetSubVariableCondition.logical_operator = targetSubVariableCondition.logical_operator === LogicalOperator.and
? LogicalOperator.or
: LogicalOperator.and
})

View File

@ -12,33 +12,48 @@ import type {
HandleUpdateSubVariableCondition,
IfElseNodeType,
} from './types'
import { produce } from 'immer'
import { useCallback, useMemo } from 'react'
import {
useCallback,
useMemo,
useRef,
} from 'react'
import { useUpdateNodeInternals } from 'reactflow'
import { v4 as uuid4 } from 'uuid'
import {
useEdgesInteractions,
useNodesReadOnly,
} from '@/app/components/workflow/hooks'
import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list'
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
import { VarType } from '../../types'
import { LogicalOperator } from './types'
import useIsVarFileAttribute from './use-is-var-file-attribute'
import {
branchNameCorrect,
getOperators,
} from './utils'
addCase,
addCondition,
addSubVariableCondition,
filterAllVars,
filterNumberVars,
getVarsIsVarFileAttribute,
removeCase,
removeCondition,
removeSubVariableCondition,
sortCases,
toggleConditionLogicalOperator,
toggleSubVariableConditionLogicalOperator,
updateCondition,
updateSubVariableCondition,
} from './use-config.helpers'
import useIsVarFileAttribute from './use-is-var-file-attribute'
const useConfig = (id: string, payload: IfElseNodeType) => {
const updateNodeInternals = useUpdateNodeInternals()
const { nodesReadOnly: readOnly } = useNodesReadOnly()
const { handleEdgeDeleteByDeleteBranch } = useEdgesInteractions()
const { inputs, setInputs } = useNodeCrud<IfElseNodeType>(id, payload)
const inputsRef = useRef(inputs)
const handleInputsChange = useCallback((newInputs: IfElseNodeType) => {
inputsRef.current = newInputs
setInputs(newInputs)
}, [setInputs])
const filterVar = useCallback(() => {
return true
}, [])
const filterVar = useCallback(() => filterAllVars(), [])
const {
availableVars,
@ -48,9 +63,7 @@ const useConfig = (id: string, payload: IfElseNodeType) => {
filterVar,
})
const filterNumberVar = useCallback((varPayload: Var) => {
return varPayload.type === VarType.number
}, [])
const filterNumberVar = useCallback((varPayload: Var) => filterNumberVars(varPayload), [])
const {
getIsVarFileAttribute,
@ -61,13 +74,7 @@ const useConfig = (id: string, payload: IfElseNodeType) => {
})
const varsIsVarFileAttribute = useMemo(() => {
const conditions: Record<string, boolean> = {}
inputs.cases?.forEach((c) => {
c.conditions.forEach((condition) => {
conditions[condition.id] = getIsVarFileAttribute(condition.variable_selector!)
})
})
return conditions
return getVarsIsVarFileAttribute(inputs.cases, getIsVarFileAttribute)
}, [inputs.cases, getIsVarFileAttribute])
const {
@ -79,177 +86,56 @@ const useConfig = (id: string, payload: IfElseNodeType) => {
})
const handleAddCase = useCallback(() => {
const newInputs = produce(inputs, (draft) => {
if (draft.cases) {
const case_id = uuid4()
draft.cases.push({
case_id,
logical_operator: LogicalOperator.and,
conditions: [],
})
if (draft._targetBranches) {
const elseCaseIndex = draft._targetBranches.findIndex(branch => branch.id === 'false')
if (elseCaseIndex > -1) {
draft._targetBranches = branchNameCorrect([
...draft._targetBranches.slice(0, elseCaseIndex),
{
id: case_id,
name: '',
},
...draft._targetBranches.slice(elseCaseIndex),
])
}
}
}
})
setInputs(newInputs)
}, [inputs, setInputs])
handleInputsChange(addCase(inputsRef.current))
}, [handleInputsChange])
const handleRemoveCase = useCallback((caseId: string) => {
const newInputs = produce(inputs, (draft) => {
draft.cases = draft.cases?.filter(item => item.case_id !== caseId)
if (draft._targetBranches)
draft._targetBranches = branchNameCorrect(draft._targetBranches.filter(branch => branch.id !== caseId))
handleEdgeDeleteByDeleteBranch(id, caseId)
})
setInputs(newInputs)
}, [inputs, setInputs, id, handleEdgeDeleteByDeleteBranch])
handleEdgeDeleteByDeleteBranch(id, caseId)
handleInputsChange(removeCase(inputsRef.current, caseId))
}, [handleEdgeDeleteByDeleteBranch, handleInputsChange, id])
const handleSortCase = useCallback((newCases: (CaseItem & { id: string })[]) => {
const newInputs = produce(inputs, (draft) => {
draft.cases = newCases.filter(Boolean).map(item => ({
id: item.id,
case_id: item.case_id,
logical_operator: item.logical_operator,
conditions: item.conditions,
}))
draft._targetBranches = branchNameCorrect([
...newCases.filter(Boolean).map(item => ({ id: item.case_id, name: '' })),
{ id: 'false', name: '' },
])
})
setInputs(newInputs)
handleInputsChange(sortCases(inputsRef.current, newCases))
updateNodeInternals(id)
}, [id, inputs, setInputs, updateNodeInternals])
}, [handleInputsChange, id, updateNodeInternals])
const handleAddCondition = useCallback<HandleAddCondition>((caseId, valueSelector, varItem) => {
const newInputs = produce(inputs, (draft) => {
const targetCase = draft.cases?.find(item => item.case_id === caseId)
if (targetCase) {
targetCase.conditions.push({
id: uuid4(),
varType: varItem.type,
variable_selector: valueSelector,
comparison_operator: getOperators(varItem.type, getIsVarFileAttribute(valueSelector) ? { key: valueSelector.slice(-1)[0] } : undefined)[0],
value: (varItem.type === VarType.boolean || varItem.type === VarType.arrayBoolean) ? false : '',
})
}
})
setInputs(newInputs)
}, [getIsVarFileAttribute, inputs, setInputs])
handleInputsChange(addCondition({
inputs: inputsRef.current,
caseId,
valueSelector,
variable: varItem,
isVarFileAttribute: !!getIsVarFileAttribute(valueSelector),
}))
}, [getIsVarFileAttribute, handleInputsChange])
const handleRemoveCondition = useCallback<HandleRemoveCondition>((caseId, conditionId) => {
const newInputs = produce(inputs, (draft) => {
const targetCase = draft.cases?.find(item => item.case_id === caseId)
if (targetCase)
targetCase.conditions = targetCase.conditions.filter(item => item.id !== conditionId)
})
setInputs(newInputs)
}, [inputs, setInputs])
handleInputsChange(removeCondition(inputsRef.current, caseId, conditionId))
}, [handleInputsChange])
const handleUpdateCondition = useCallback<HandleUpdateCondition>((caseId, conditionId, newCondition) => {
const newInputs = produce(inputs, (draft) => {
const targetCase = draft.cases?.find(item => item.case_id === caseId)
if (targetCase) {
const targetCondition = targetCase.conditions.find(item => item.id === conditionId)
if (targetCondition)
Object.assign(targetCondition, newCondition)
}
})
setInputs(newInputs)
}, [inputs, setInputs])
handleInputsChange(updateCondition(inputsRef.current, caseId, conditionId, newCondition))
}, [handleInputsChange])
const handleToggleConditionLogicalOperator = useCallback<HandleToggleConditionLogicalOperator>((caseId) => {
const newInputs = produce(inputs, (draft) => {
const targetCase = draft.cases?.find(item => item.case_id === caseId)
if (targetCase)
targetCase.logical_operator = targetCase.logical_operator === LogicalOperator.and ? LogicalOperator.or : LogicalOperator.and
})
setInputs(newInputs)
}, [inputs, setInputs])
handleInputsChange(toggleConditionLogicalOperator(inputsRef.current, caseId))
}, [handleInputsChange])
const handleAddSubVariableCondition = useCallback<HandleAddSubVariableCondition>((caseId: string, conditionId: string, key?: string) => {
const newInputs = produce(inputs, (draft) => {
const condition = draft.cases?.find(item => item.case_id === caseId)?.conditions.find(item => item.id === conditionId)
if (!condition)
return
if (!condition?.sub_variable_condition) {
condition.sub_variable_condition = {
case_id: uuid4(),
logical_operator: LogicalOperator.and,
conditions: [],
}
}
const subVarCondition = condition.sub_variable_condition
if (subVarCondition) {
if (!subVarCondition.conditions)
subVarCondition.conditions = []
subVarCondition.conditions.push({
id: uuid4(),
key: key || '',
varType: VarType.string,
comparison_operator: undefined,
value: '',
})
}
})
setInputs(newInputs)
}, [inputs, setInputs])
handleInputsChange(addSubVariableCondition(inputsRef.current, caseId, conditionId, key))
}, [handleInputsChange])
const handleRemoveSubVariableCondition = useCallback((caseId: string, conditionId: string, subConditionId: string) => {
const newInputs = produce(inputs, (draft) => {
const condition = draft.cases?.find(item => item.case_id === caseId)?.conditions.find(item => item.id === conditionId)
if (!condition)
return
if (!condition?.sub_variable_condition)
return
const subVarCondition = condition.sub_variable_condition
if (subVarCondition)
subVarCondition.conditions = subVarCondition.conditions.filter(item => item.id !== subConditionId)
})
setInputs(newInputs)
}, [inputs, setInputs])
handleInputsChange(removeSubVariableCondition(inputsRef.current, caseId, conditionId, subConditionId))
}, [handleInputsChange])
const handleUpdateSubVariableCondition = useCallback<HandleUpdateSubVariableCondition>((caseId, conditionId, subConditionId, newSubCondition) => {
const newInputs = produce(inputs, (draft) => {
const targetCase = draft.cases?.find(item => item.case_id === caseId)
if (targetCase) {
const targetCondition = targetCase.conditions.find(item => item.id === conditionId)
if (targetCondition && targetCondition.sub_variable_condition) {
const targetSubCondition = targetCondition.sub_variable_condition.conditions.find(item => item.id === subConditionId)
if (targetSubCondition)
Object.assign(targetSubCondition, newSubCondition)
}
}
})
setInputs(newInputs)
}, [inputs, setInputs])
handleInputsChange(updateSubVariableCondition(inputsRef.current, caseId, conditionId, subConditionId, newSubCondition))
}, [handleInputsChange])
const handleToggleSubVariableConditionLogicalOperator = useCallback<HandleToggleSubVariableConditionLogicalOperator>((caseId, conditionId) => {
const newInputs = produce(inputs, (draft) => {
const targetCase = draft.cases?.find(item => item.case_id === caseId)
if (targetCase) {
const targetCondition = targetCase.conditions.find(item => item.id === conditionId)
if (targetCondition && targetCondition.sub_variable_condition)
targetCondition.sub_variable_condition.logical_operator = targetCondition.sub_variable_condition.logical_operator === LogicalOperator.and ? LogicalOperator.or : LogicalOperator.and
}
})
setInputs(newInputs)
}, [inputs, setInputs])
handleInputsChange(toggleSubVariableConditionLogicalOperator(inputsRef.current, caseId, conditionId))
}, [handleInputsChange])
return {
readOnly,

View File

@ -0,0 +1,111 @@
import type { Node } from '@/app/components/workflow/types'
import { BlockEnum } from '@/app/components/workflow/types'
import {
buildIterationChildCopy,
getIterationChildren,
getIterationContainerBounds,
getIterationContainerResize,
getNextChildNodeTypeCount,
getRestrictedIterationPosition,
} from '../use-interactions.helpers'
const createNode = (overrides: Record<string, unknown> = {}) => ({
id: 'node',
type: 'custom',
position: { x: 0, y: 0 },
width: 100,
height: 80,
data: { type: BlockEnum.Code, title: 'Code', desc: '' },
...overrides,
})
describe('iteration interaction helpers', () => {
it('calculates bounds, resize and drag restriction for iteration containers', () => {
const children = [
createNode({ id: 'a', position: { x: 20, y: 10 }, width: 80, height: 40 }),
createNode({ id: 'b', position: { x: 120, y: 60 }, width: 50, height: 30 }),
]
const bounds = getIterationContainerBounds(children as Node[])
expect(bounds.rightNode?.id).toBe('b')
expect(bounds.bottomNode?.id).toBe('b')
expect(getIterationContainerResize(createNode({ width: 120, height: 80 }) as Node, bounds)).toEqual({
width: 186,
height: 110,
})
expect(getRestrictedIterationPosition(
createNode({
position: { x: -10, y: 160 },
width: 80,
height: 40,
data: { isInIteration: true },
}),
createNode({ width: 200, height: 180 }) as Node,
)).toEqual({ x: 16, y: 120 })
expect(getRestrictedIterationPosition(
createNode({
position: { x: 180, y: -4 },
width: 40,
height: 30,
data: { isInIteration: true },
}),
createNode({ width: 200, height: 180 }) as Node,
)).toEqual({ x: 144, y: 65 })
})
it('filters iteration children and increments per-type counts', () => {
const typeCount = {} as Parameters<typeof getNextChildNodeTypeCount>[0]
expect(getNextChildNodeTypeCount(typeCount, BlockEnum.Code, 2)).toBe(3)
expect(getNextChildNodeTypeCount(typeCount, BlockEnum.Code, 2)).toBe(4)
expect(getIterationChildren([
createNode({ id: 'child', parentId: 'iteration-1' }),
createNode({ id: 'start', parentId: 'iteration-1', type: 'custom-iteration-start' }),
createNode({ id: 'other', parentId: 'other-iteration' }),
] as Node[], 'iteration-1').map(item => item.id)).toEqual(['child'])
})
it('keeps bounds, resize and positions empty when no container restriction applies', () => {
expect(getIterationContainerBounds([])).toEqual({})
expect(getIterationContainerResize(createNode({ width: 300, height: 240 }) as Node, {})).toEqual({
width: undefined,
height: undefined,
})
expect(getRestrictedIterationPosition(
createNode({ data: { isInIteration: true } }),
undefined,
)).toEqual({ x: undefined, y: undefined })
expect(getRestrictedIterationPosition(
createNode({ data: { isInIteration: false } }),
createNode({ width: 200, height: 180 }) as Node,
)).toEqual({ x: undefined, y: undefined })
})
it('builds copied iteration children with iteration metadata', () => {
const child = createNode({
id: 'child',
position: { x: 12, y: 24 },
positionAbsolute: { x: 12, y: 24 },
extent: 'parent',
zIndex: 7,
data: { type: BlockEnum.Code, title: 'Original', desc: 'child', selected: true },
})
const result = buildIterationChildCopy({
child: child as Node,
childNodeType: BlockEnum.Code,
defaultValue: { title: 'Code', desc: '', type: BlockEnum.Code } as Node['data'],
title: 'blocks.code 3',
newNodeId: 'iteration-2',
})
expect(result).toEqual(expect.objectContaining({
parentId: 'iteration-2',
zIndex: 7,
data: expect.objectContaining({
title: 'blocks.code 3',
iteration_id: 'iteration-2',
selected: false,
_isBundled: false,
}),
}))
})
})

View File

@ -0,0 +1,181 @@
import type { Node } from '@/app/components/workflow/types'
import { renderHook } from '@testing-library/react'
import {
createIterationNode,
createNode,
} from '@/app/components/workflow/__tests__/fixtures'
import { ITERATION_PADDING } from '@/app/components/workflow/constants'
import { BlockEnum } from '@/app/components/workflow/types'
import { useNodeIterationInteractions } from '../use-interactions'
const mockGetNodes = vi.hoisted(() => vi.fn())
const mockSetNodes = vi.hoisted(() => vi.fn())
const mockGenerateNewNode = vi.hoisted(() => vi.fn())
vi.mock('reactflow', async () => {
const actual = await vi.importActual<typeof import('reactflow')>('reactflow')
return {
...actual,
useStoreApi: () => ({
getState: () => ({
getNodes: mockGetNodes,
setNodes: mockSetNodes,
}),
}),
}
})
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesMetaData: () => ({
nodesMap: {
[BlockEnum.Code]: {
defaultValue: {
title: 'Code',
desc: '',
},
},
},
}),
}))
vi.mock('@/app/components/workflow/utils', () => ({
generateNewNode: (...args: unknown[]) => mockGenerateNewNode(...args),
getNodeCustomTypeByNodeDataType: () => 'custom',
}))
describe('useNodeIterationInteractions', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should expand the iteration node when children overflow the bounds', () => {
mockGetNodes.mockReturnValue([
createIterationNode({
id: 'iteration-node',
width: 120,
height: 80,
data: { width: 120, height: 80 },
}),
createNode({
id: 'child-node',
parentId: 'iteration-node',
position: { x: 100, y: 90 },
width: 60,
height: 40,
}),
])
const { result } = renderHook(() => useNodeIterationInteractions())
result.current.handleNodeIterationRerender('iteration-node')
expect(mockSetNodes).toHaveBeenCalledTimes(1)
const updatedNodes = mockSetNodes.mock.calls[0][0]
const updatedIterationNode = updatedNodes.find((node: Node) => node.id === 'iteration-node')
expect(updatedIterationNode.width).toBe(100 + 60 + ITERATION_PADDING.right)
expect(updatedIterationNode.height).toBe(90 + 40 + ITERATION_PADDING.bottom)
})
it('should restrict dragging to the iteration container padding', () => {
mockGetNodes.mockReturnValue([
createIterationNode({
id: 'iteration-node',
width: 200,
height: 180,
data: { width: 200, height: 180 },
}),
])
const { result } = renderHook(() => useNodeIterationInteractions())
const dragResult = result.current.handleNodeIterationChildDrag(createNode({
id: 'child-node',
parentId: 'iteration-node',
position: { x: -10, y: -5 },
width: 80,
height: 60,
data: { type: BlockEnum.Code, title: 'Child', desc: '', isInIteration: true },
}))
expect(dragResult.restrictPosition).toEqual({
x: ITERATION_PADDING.left,
y: ITERATION_PADDING.top,
})
})
it('should rerender the parent iteration node when a child size changes', () => {
mockGetNodes.mockReturnValue([
createIterationNode({
id: 'iteration-node',
width: 120,
height: 80,
data: { width: 120, height: 80 },
}),
createNode({
id: 'child-node',
parentId: 'iteration-node',
position: { x: 100, y: 90 },
width: 60,
height: 40,
}),
])
const { result } = renderHook(() => useNodeIterationInteractions())
result.current.handleNodeIterationChildSizeChange('child-node')
expect(mockSetNodes).toHaveBeenCalledTimes(1)
})
it('should skip iteration rerender when the resized node has no parent', () => {
mockGetNodes.mockReturnValue([
createNode({
id: 'standalone-node',
data: { type: BlockEnum.Code, title: 'Standalone', desc: '' },
}),
])
const { result } = renderHook(() => useNodeIterationInteractions())
result.current.handleNodeIterationChildSizeChange('standalone-node')
expect(mockSetNodes).not.toHaveBeenCalled()
})
it('should copy iteration children and remap ids', () => {
mockGetNodes.mockReturnValue([
createIterationNode({ id: 'iteration-node' }),
createNode({
id: 'child-node',
parentId: 'iteration-node',
data: { type: BlockEnum.Code, title: 'Child', desc: '' },
}),
createNode({
id: 'same-type-node',
data: { type: BlockEnum.Code, title: 'Code', desc: '' },
}),
])
mockGenerateNewNode.mockReturnValue({
newNode: createNode({
id: 'generated',
parentId: 'new-iteration',
data: { type: BlockEnum.Code, title: 'blocks.code 3', desc: '', iteration_id: 'new-iteration' },
}),
})
const { result } = renderHook(() => useNodeIterationInteractions())
const copyResult = result.current.handleNodeIterationChildrenCopy('iteration-node', 'new-iteration', { existing: 'mapped' })
expect(mockGenerateNewNode).toHaveBeenCalledWith(expect.objectContaining({
type: 'custom',
parentId: 'new-iteration',
}))
expect(copyResult.copyChildren).toHaveLength(1)
expect(copyResult.newIdMapping).toEqual({
'existing': 'mapped',
'child-node': 'new-iterationgenerated0',
})
})
})

View File

@ -0,0 +1,113 @@
import type {
BlockEnum,
ChildNodeTypeCount,
Node,
} from '../../types'
import {
ITERATION_PADDING,
} from '../../constants'
import { CUSTOM_ITERATION_START_NODE } from '../iteration-start/constants'
type ContainerBounds = {
rightNode?: Node
bottomNode?: Node
}
export const getIterationContainerBounds = (childrenNodes: Node[]): ContainerBounds => {
return childrenNodes.reduce<ContainerBounds>((acc, node) => {
const nextRightNode = !acc.rightNode || node.position.x + node.width! > acc.rightNode.position.x + acc.rightNode.width!
? node
: acc.rightNode
const nextBottomNode = !acc.bottomNode || node.position.y + node.height! > acc.bottomNode.position.y + acc.bottomNode.height!
? node
: acc.bottomNode
return {
rightNode: nextRightNode,
bottomNode: nextBottomNode,
}
}, {})
}
export const getIterationContainerResize = (currentNode: Node, bounds: ContainerBounds) => {
const width = bounds.rightNode && currentNode.width! < bounds.rightNode.position.x + bounds.rightNode.width!
? bounds.rightNode.position.x + bounds.rightNode.width! + ITERATION_PADDING.right
: undefined
const height = bounds.bottomNode && currentNode.height! < bounds.bottomNode.position.y + bounds.bottomNode.height!
? bounds.bottomNode.position.y + bounds.bottomNode.height! + ITERATION_PADDING.bottom
: undefined
return {
width,
height,
}
}
export const getRestrictedIterationPosition = (node: Node, parentNode?: Node) => {
const restrictPosition: { x?: number, y?: number } = { x: undefined, y: undefined }
if (!node.data.isInIteration || !parentNode)
return restrictPosition
if (node.position.y < ITERATION_PADDING.top)
restrictPosition.y = ITERATION_PADDING.top
if (node.position.x < ITERATION_PADDING.left)
restrictPosition.x = ITERATION_PADDING.left
if (node.position.x + node.width! > parentNode.width! - ITERATION_PADDING.right)
restrictPosition.x = parentNode.width! - ITERATION_PADDING.right - node.width!
if (node.position.y + node.height! > parentNode.height! - ITERATION_PADDING.bottom)
restrictPosition.y = parentNode.height! - ITERATION_PADDING.bottom - node.height!
return restrictPosition
}
export const getIterationChildren = (nodes: Node[], nodeId: string) => {
return nodes.filter(node => node.parentId === nodeId && node.type !== CUSTOM_ITERATION_START_NODE)
}
export const getNextChildNodeTypeCount = (
childNodeTypeCount: ChildNodeTypeCount,
childNodeType: BlockEnum,
nodesWithSameTypeCount: number,
) => {
if (!childNodeTypeCount[childNodeType])
childNodeTypeCount[childNodeType] = nodesWithSameTypeCount + 1
else
childNodeTypeCount[childNodeType] = childNodeTypeCount[childNodeType] + 1
return childNodeTypeCount[childNodeType]
}
export const buildIterationChildCopy = ({
child,
childNodeType,
defaultValue,
title,
newNodeId,
}: {
child: Node
childNodeType: BlockEnum
defaultValue: Node['data']
title: string
newNodeId: string
}) => {
return {
type: child.type!,
data: {
...defaultValue,
...child.data,
selected: false,
_isBundled: false,
_connectedSourceHandleIds: [],
_connectedTargetHandleIds: [],
title,
iteration_id: newNodeId,
type: childNodeType,
},
position: child.position,
positionAbsolute: child.positionAbsolute,
parentId: newNodeId,
extent: child.extent,
zIndex: child.zIndex,
}
}

View File

@ -8,14 +8,18 @@ import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useStoreApi } from 'reactflow'
import { useNodesMetaData } from '@/app/components/workflow/hooks'
import {
ITERATION_PADDING,
} from '../../constants'
import {
generateNewNode,
getNodeCustomTypeByNodeDataType,
} from '../../utils'
import { CUSTOM_ITERATION_START_NODE } from '../iteration-start/constants'
import {
buildIterationChildCopy,
getIterationChildren,
getIterationContainerBounds,
getIterationContainerResize,
getNextChildNodeTypeCount,
getRestrictedIterationPosition,
} from './use-interactions.helpers'
export const useNodeIterationInteractions = () => {
const { t } = useTranslation()
@ -31,40 +35,19 @@ export const useNodeIterationInteractions = () => {
const nodes = getNodes()
const currentNode = nodes.find(n => n.id === nodeId)!
const childrenNodes = nodes.filter(n => n.parentId === nodeId)
let rightNode: Node
let bottomNode: Node
const resize = getIterationContainerResize(currentNode, getIterationContainerBounds(childrenNodes))
childrenNodes.forEach((n) => {
if (rightNode) {
if (n.position.x + n.width! > rightNode.position.x + rightNode.width!)
rightNode = n
}
else {
rightNode = n
}
if (bottomNode) {
if (n.position.y + n.height! > bottomNode.position.y + bottomNode.height!)
bottomNode = n
}
else {
bottomNode = n
}
})
const widthShouldExtend = rightNode! && currentNode.width! < rightNode.position.x + rightNode.width!
const heightShouldExtend = bottomNode! && currentNode.height! < bottomNode.position.y + bottomNode.height!
if (widthShouldExtend || heightShouldExtend) {
if (resize.width || resize.height) {
const newNodes = produce(nodes, (draft) => {
draft.forEach((n) => {
if (n.id === nodeId) {
if (widthShouldExtend) {
n.data.width = rightNode.position.x + rightNode.width! + ITERATION_PADDING.right
n.width = rightNode.position.x + rightNode.width! + ITERATION_PADDING.right
if (resize.width) {
n.data.width = resize.width
n.width = resize.width
}
if (heightShouldExtend) {
n.data.height = bottomNode.position.y + bottomNode.height! + ITERATION_PADDING.bottom
n.height = bottomNode.position.y + bottomNode.height! + ITERATION_PADDING.bottom
if (resize.height) {
n.data.height = resize.height
n.height = resize.height
}
}
})
@ -78,25 +61,8 @@ export const useNodeIterationInteractions = () => {
const { getNodes } = store.getState()
const nodes = getNodes()
const restrictPosition: { x?: number, y?: number } = { x: undefined, y: undefined }
if (node.data.isInIteration) {
const parentNode = nodes.find(n => n.id === node.parentId)
if (parentNode) {
if (node.position.y < ITERATION_PADDING.top)
restrictPosition.y = ITERATION_PADDING.top
if (node.position.x < ITERATION_PADDING.left)
restrictPosition.x = ITERATION_PADDING.left
if (node.position.x + node.width! > parentNode!.width! - ITERATION_PADDING.right)
restrictPosition.x = parentNode!.width! - ITERATION_PADDING.right - node.width!
if (node.position.y + node.height! > parentNode!.height! - ITERATION_PADDING.bottom)
restrictPosition.y = parentNode!.height! - ITERATION_PADDING.bottom - node.height!
}
}
return {
restrictPosition,
restrictPosition: getRestrictedIterationPosition(node, nodes.find(n => n.id === node.parentId)),
}
}, [store])
@ -113,37 +79,27 @@ export const useNodeIterationInteractions = () => {
const handleNodeIterationChildrenCopy = useCallback((nodeId: string, newNodeId: string, idMapping: Record<string, string>) => {
const { getNodes } = store.getState()
const nodes = getNodes()
const childrenNodes = nodes.filter(n => n.parentId === nodeId && n.type !== CUSTOM_ITERATION_START_NODE)
const childrenNodes = getIterationChildren(nodes, nodeId)
const newIdMapping = { ...idMapping }
const childNodeTypeCount: ChildNodeTypeCount = {}
const copyChildren = childrenNodes.map((child, index) => {
const childNodeType = child.data.type as BlockEnum
const nodesWithSameType = nodes.filter(node => node.data.type === childNodeType)
if (!childNodeTypeCount[childNodeType])
childNodeTypeCount[childNodeType] = nodesWithSameType.length + 1
else
childNodeTypeCount[childNodeType] = childNodeTypeCount[childNodeType] + 1
const nextCount = getNextChildNodeTypeCount(childNodeTypeCount, childNodeType, nodesWithSameType.length)
const title = nodesWithSameType.length > 0
? `${t(`blocks.${childNodeType}`, { ns: 'workflow' })} ${nextCount}`
: t(`blocks.${childNodeType}`, { ns: 'workflow' })
const childCopy = buildIterationChildCopy({
child,
childNodeType,
defaultValue: nodesMetaDataMap![childNodeType].defaultValue as Node['data'],
title,
newNodeId,
})
const { newNode } = generateNewNode({
...childCopy,
type: getNodeCustomTypeByNodeDataType(childNodeType),
data: {
...nodesMetaDataMap![childNodeType].defaultValue,
...child.data,
selected: false,
_isBundled: false,
_connectedSourceHandleIds: [],
_connectedTargetHandleIds: [],
title: nodesWithSameType.length > 0 ? `${t(`blocks.${childNodeType}`, { ns: 'workflow' })} ${childNodeTypeCount[childNodeType]}` : t(`blocks.${childNodeType}`, { ns: 'workflow' }),
iteration_id: newNodeId,
type: childNodeType,
},
position: child.position,
positionAbsolute: child.positionAbsolute,
parentId: newNodeId,
extent: child.extent,
zIndex: child.zIndex,
})
newNode.id = `${newNodeId}${newNode.id + index}`
newIdMapping[child.id] = newNode.id
@ -154,7 +110,7 @@ export const useNodeIterationInteractions = () => {
copyChildren,
newIdMapping,
}
}, [store, t])
}, [nodesMetaDataMap, store, t])
return {
handleNodeIterationRerender,

View File

@ -0,0 +1,108 @@
import type { ListFilterNodeType } from '../types'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import { OrderBy } from '../types'
import {
buildFilterCondition,
canFilterVariable,
getItemVarType,
getItemVarTypeShowName,
supportsSubVariable,
updateExtractEnabled,
updateExtractSerial,
updateFilterCondition,
updateFilterEnabled,
updateLimit,
updateListFilterVariable,
updateOrderByEnabled,
updateOrderByKey,
updateOrderByType,
} from '../use-config.helpers'
const createInputs = (): ListFilterNodeType => ({
title: 'List Filter',
desc: '',
type: BlockEnum.ListFilter,
variable: ['node', 'list'],
var_type: VarType.arrayString,
item_var_type: VarType.string,
filter_by: {
enabled: false,
conditions: [{ key: '', comparison_operator: 'contains', value: '' }],
},
extract_by: {
enabled: false,
serial: '',
},
order_by: {
enabled: false,
key: '',
value: OrderBy.DESC,
},
limit: {
enabled: false,
size: 20,
},
} as unknown as ListFilterNodeType)
describe('list operator use-config helpers', () => {
it('maps item var types, labels and filter support', () => {
expect(getItemVarType(VarType.arrayNumber)).toBe(VarType.number)
expect(getItemVarType(VarType.arrayBoolean)).toBe(VarType.boolean)
expect(getItemVarType(undefined)).toBe(VarType.string)
expect(getItemVarTypeShowName(undefined, false)).toBe('?')
expect(getItemVarTypeShowName(VarType.number, true)).toBe('Number')
expect(supportsSubVariable(VarType.arrayFile)).toBe(true)
expect(supportsSubVariable(VarType.arrayString)).toBe(false)
expect(canFilterVariable({ type: VarType.arrayFile } as never)).toBe(true)
expect(canFilterVariable({ type: VarType.string } as never)).toBe(false)
})
it('builds default conditions and updates selected variable metadata', () => {
expect(buildFilterCondition({
itemVarType: VarType.boolean,
isFileArray: false,
})).toEqual(expect.objectContaining({
key: '',
value: false,
}))
expect(buildFilterCondition({
itemVarType: VarType.string,
isFileArray: true,
})).toEqual(expect.objectContaining({
key: 'name',
value: '',
}))
const nextInputs = updateListFilterVariable({
inputs: {
...createInputs(),
order_by: { enabled: true, key: '', value: OrderBy.DESC },
},
variable: ['node', 'files'],
varType: VarType.arrayFile,
itemVarType: VarType.file,
})
expect(nextInputs.var_type).toBe(VarType.arrayFile)
expect(nextInputs.filter_by.conditions[0]).toEqual(expect.objectContaining({ key: 'name' }))
expect(nextInputs.order_by.key).toBe('name')
})
it('updates filter, extract, limit and order by sections', () => {
const condition = { key: 'size', comparison_operator: '>', value: '10' }
expect(updateFilterEnabled(createInputs(), true).filter_by.enabled).toBe(true)
expect(updateFilterCondition(createInputs(), condition as ListFilterNodeType['filter_by']['conditions'][number]).filter_by.conditions[0]).toEqual(condition)
expect(updateLimit(createInputs(), { enabled: true, size: 10 }).limit).toEqual({ enabled: true, size: 10 })
expect(updateExtractEnabled(createInputs(), true).extract_by).toEqual({ enabled: true, serial: '1' })
expect(updateExtractSerial(createInputs(), '2').extract_by.serial).toBe('2')
const orderEnabled = updateOrderByEnabled(createInputs(), true, true)
expect(orderEnabled.order_by).toEqual(expect.objectContaining({
enabled: true,
key: 'name',
value: OrderBy.ASC,
}))
expect(updateOrderByKey(createInputs(), 'created_at').order_by.key).toBe('created_at')
expect(updateOrderByType(createInputs(), OrderBy.DESC).order_by.value).toBe(OrderBy.DESC)
})
})

View File

@ -0,0 +1,183 @@
import type { ListFilterNodeType } from '../types'
import { renderHook } from '@testing-library/react'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import { createNodeCrudModuleMock } from '../../__tests__/use-config-test-utils'
import { ComparisonOperator } from '../../if-else/types'
import { OrderBy } from '../types'
import useConfig from '../use-config'
const mockSetInputs = vi.hoisted(() => vi.fn())
const mockGetCurrentVariableType = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesReadOnly: () => ({ nodesReadOnly: false }),
useIsChatMode: () => false,
useWorkflow: () => ({
getBeforeNodesInSameBranch: () => [
{ id: 'start-node', data: { title: 'Start', type: BlockEnum.Start } },
],
}),
useWorkflowVariables: () => ({
getCurrentVariableType: (...args: unknown[]) => mockGetCurrentVariableType(...args),
}),
}))
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
...createNodeCrudModuleMock<ListFilterNodeType>(mockSetInputs),
}))
vi.mock('reactflow', async () => {
const actual = await vi.importActual<typeof import('reactflow')>('reactflow')
return {
...actual,
useStoreApi: () => ({
getState: () => ({
getNodes: () => [
{ id: 'list-node', parentId: 'iteration-parent' },
{ id: 'iteration-parent', data: { title: 'Iteration', type: BlockEnum.Iteration } },
],
}),
}),
}
})
const createPayload = (overrides: Partial<ListFilterNodeType> = {}): ListFilterNodeType => ({
title: 'List Filter',
desc: '',
type: BlockEnum.ListFilter,
variable: ['node-1', 'items'],
var_type: VarType.arrayString,
item_var_type: VarType.string,
filter_by: {
enabled: true,
conditions: [{
key: '',
comparison_operator: ComparisonOperator.equal,
value: '',
}],
},
extract_by: {
enabled: false,
serial: '',
},
order_by: {
enabled: false,
key: '',
value: OrderBy.DESC,
},
limit: {
enabled: false,
size: 10,
},
...overrides,
})
describe('useConfig', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetCurrentVariableType.mockReturnValue(VarType.arrayString)
})
it('should expose derived variable metadata and filter array-like vars', () => {
const { result } = renderHook(() => useConfig('list-node', createPayload()))
expect(result.current.readOnly).toBe(false)
expect(result.current.varType).toBe(VarType.arrayString)
expect(result.current.itemVarType).toBe(VarType.string)
expect(result.current.itemVarTypeShowName).toBe('String')
expect(result.current.hasSubVariable).toBe(false)
expect(result.current.filterVar({ type: VarType.arrayBoolean } as never)).toBe(true)
expect(result.current.filterVar({ type: VarType.object } as never)).toBe(false)
})
it('should reset filter conditions when the variable changes to file arrays', () => {
mockGetCurrentVariableType.mockReturnValue(VarType.arrayFile)
const payload = createPayload({
order_by: {
enabled: true,
key: '',
value: OrderBy.DESC,
},
})
const { result } = renderHook(() => useConfig('list-node', payload))
result.current.handleVarChanges(['node-2', 'files'])
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
variable: ['node-2', 'files'],
var_type: VarType.arrayFile,
item_var_type: VarType.file,
filter_by: {
enabled: true,
conditions: [{
key: 'name',
comparison_operator: ComparisonOperator.contains,
value: '',
}],
},
order_by: expect.objectContaining({
key: 'name',
}),
}))
})
it('should update filter, extract, limit and order-by settings', () => {
const { result } = renderHook(() => useConfig('list-node', createPayload()))
result.current.handleFilterEnabledChange(false)
result.current.handleFilterChange({
key: 'size',
comparison_operator: ComparisonOperator.largerThan,
value: 3,
})
result.current.handleLimitChange({ enabled: true, size: 5 })
result.current.handleExtractsEnabledChange(true)
result.current.handleExtractsChange('2')
result.current.handleOrderByEnabledChange(true)
result.current.handleOrderByKeyChange('size')
result.current.handleOrderByTypeChange(OrderBy.ASC)()
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
filter_by: expect.objectContaining({ enabled: false }),
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
filter_by: expect.objectContaining({
conditions: [{
key: 'size',
comparison_operator: ComparisonOperator.largerThan,
value: 3,
}],
}),
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
limit: { enabled: true, size: 5 },
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
extract_by: { enabled: true, serial: '1' },
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
extract_by: { enabled: false, serial: '2' },
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
order_by: expect.objectContaining({
enabled: true,
value: OrderBy.ASC,
key: '',
}),
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
order_by: expect.objectContaining({
enabled: false,
key: 'size',
value: OrderBy.DESC,
}),
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
order_by: expect.objectContaining({
enabled: false,
key: '',
value: OrderBy.ASC,
}),
}))
})
})

View File

@ -0,0 +1,310 @@
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { TransferMethod } from '@/types/app'
import { VarType } from '../../../../types'
import { ComparisonOperator } from '../../../if-else/types'
import FilterCondition from '../filter-condition'
const { mockUseAvailableVarList } = vi.hoisted(() => ({
mockUseAvailableVarList: vi.fn((_nodeId: string, _options: unknown) => ({
availableVars: [],
availableNodesWithParent: [],
})),
}))
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', () => ({
default: (nodeId: string, options: unknown) => mockUseAvailableVarList(nodeId, options),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/input-support-select-var', () => ({
default: ({
value,
onChange,
onFocusChange,
readOnly,
placeholder,
className,
}: {
value: string
onChange: (value: string) => void
onFocusChange?: (value: boolean) => void
readOnly?: boolean
placeholder?: string
className?: string
}) => (
<input
aria-label="variable-input"
className={className}
value={value}
onChange={e => onChange(e.target.value)}
onFocus={() => onFocusChange?.(true)}
onBlur={() => onFocusChange?.(false)}
readOnly={readOnly}
placeholder={placeholder}
/>
),
}))
vi.mock('../../../../panel/chat-variable-panel/components/bool-value', () => ({
default: ({ value, onChange }: { value: boolean, onChange: (value: boolean) => void }) => (
<button onClick={() => onChange(!value)}>{value ? 'true' : 'false'}</button>
),
}))
vi.mock('../../../if-else/components/condition-list/condition-operator', () => ({
default: ({
value,
onSelect,
}: {
value: string
onSelect: (value: string) => void
}) => (
<button onClick={() => onSelect(ComparisonOperator.notEqual)}>
operator:
{value}
</button>
),
}))
vi.mock('../sub-variable-picker', () => ({
default: ({
value,
onChange,
}: {
value: string
onChange: (value: string) => void
}) => (
<button onClick={() => onChange('size')}>
sub-variable:
{value}
</button>
),
}))
describe('FilterCondition', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseAvailableVarList.mockReturnValue({
availableVars: [],
availableNodesWithParent: [],
})
})
it('should render a select input for array-backed file conditions and update array values', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<FilterCondition
condition={{
key: 'type',
comparison_operator: ComparisonOperator.in,
value: ['document'],
}}
varType={VarType.file}
onChange={onChange}
hasSubVariable
readOnly={false}
nodeId="node-1"
/>,
)
expect(screen.getByText(/operator:/)).toBeInTheDocument()
expect(screen.getByText(/sub-variable:/)).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'workflow.nodes.ifElse.optionName.doc' }))
await user.click(screen.getByText('workflow.nodes.ifElse.optionName.image'))
expect(onChange).toHaveBeenCalledWith({
key: 'type',
comparison_operator: ComparisonOperator.in,
value: ['image'],
})
})
it('should render a boolean value control for boolean variables', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<FilterCondition
condition={{
key: 'enabled',
comparison_operator: ComparisonOperator.equal,
value: false,
}}
varType={VarType.boolean}
onChange={onChange}
hasSubVariable={false}
readOnly={false}
nodeId="node-1"
/>,
)
await user.click(screen.getByRole('button', { name: 'false' }))
expect(onChange).toHaveBeenCalledWith({
key: 'enabled',
comparison_operator: ComparisonOperator.equal,
value: true,
})
})
it('should render a supported variable input, apply focus styles, and filter vars by expected type', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<FilterCondition
condition={{
key: 'name',
comparison_operator: ComparisonOperator.equal,
value: 'draft',
}}
varType={VarType.file}
onChange={onChange}
hasSubVariable={false}
readOnly={false}
nodeId="node-1"
/>,
)
const variableInput = screen.getByRole('textbox', { name: 'variable-input' })
expect(variableInput).toHaveAttribute('placeholder', 'workflow.nodes.http.insertVarPlaceholder')
await user.click(variableInput)
expect(variableInput.className).toContain('border-components-input-border-active')
fireEvent.change(variableInput, { target: { value: 'draft next' } })
expect(onChange).toHaveBeenLastCalledWith({
key: 'name',
comparison_operator: ComparisonOperator.equal,
value: 'draft next',
})
const config = mockUseAvailableVarList.mock.calls[0]?.[1] as unknown as {
filterVar: (varPayload: { type: VarType }) => boolean
}
expect(config.filterVar({ type: VarType.string })).toBe(true)
expect(config.filterVar({ type: VarType.number })).toBe(false)
})
it('should reset operator and value when the sub variable changes', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<FilterCondition
condition={{
key: '',
comparison_operator: ComparisonOperator.equal,
value: '',
}}
varType={VarType.file}
onChange={onChange}
hasSubVariable
readOnly={false}
nodeId="node-1"
/>,
)
await user.click(screen.getByRole('button', { name: 'sub-variable:' }))
expect(onChange).toHaveBeenCalledWith({
key: 'size',
comparison_operator: ComparisonOperator.largerThan,
value: '',
})
})
it('should render fallback inputs for unsupported keys and hide value inputs for no-value operators', async () => {
const onChange = vi.fn()
const { rerender } = render(
<FilterCondition
condition={{
key: 'custom_field',
comparison_operator: ComparisonOperator.equal,
value: '',
}}
varType={VarType.number}
onChange={onChange}
hasSubVariable={false}
readOnly={false}
nodeId="node-1"
/>,
)
const numberInput = screen.getByRole('spinbutton')
fireEvent.change(numberInput, { target: { value: '42' } })
expect(onChange).toHaveBeenLastCalledWith({
key: 'custom_field',
comparison_operator: ComparisonOperator.equal,
value: '42',
})
rerender(
<FilterCondition
condition={{
key: 'custom_field',
comparison_operator: ComparisonOperator.empty,
value: '',
}}
varType={VarType.file}
onChange={onChange}
hasSubVariable={false}
readOnly={false}
nodeId="node-1"
/>,
)
expect(screen.queryByRole('textbox', { name: 'variable-input' })).not.toBeInTheDocument()
expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument()
})
it('should build transfer-method options and keep empty select option lists stable for unsupported keys', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const { rerender } = render(
<FilterCondition
condition={{
key: 'transfer_method',
comparison_operator: ComparisonOperator.in,
value: ['local_file'],
}}
varType={VarType.file}
onChange={onChange}
hasSubVariable={false}
readOnly={false}
nodeId="node-1"
/>,
)
await user.click(screen.getByRole('button', { name: 'workflow.nodes.ifElse.optionName.localUpload' }))
await user.click(screen.getByText('workflow.nodes.ifElse.optionName.url'))
expect(onChange).toHaveBeenCalledWith({
key: 'transfer_method',
comparison_operator: ComparisonOperator.in,
value: [TransferMethod.remote_url],
})
rerender(
<FilterCondition
condition={{
key: 'custom_field',
comparison_operator: ComparisonOperator.in,
value: '',
}}
varType={VarType.file}
onChange={onChange}
hasSubVariable={false}
readOnly={false}
nodeId="node-1"
/>,
)
expect(screen.getByRole('button', { name: 'Select value' })).toBeInTheDocument()
})
})

View File

@ -17,6 +17,8 @@ import { ComparisonOperator } from '../../if-else/types'
import { comparisonOperatorNotRequireValue, getOperators } from '../../if-else/utils'
import SubVariablePicker from './sub-variable-picker'
type VariableInputProps = React.ComponentProps<typeof Input>
const optionNameI18NPrefix = 'nodes.ifElse.optionName'
const VAR_INPUT_SUPPORTED_KEYS: Record<string, VarType> = {
@ -37,6 +39,147 @@ type Props = {
nodeId: string
}
const getExpectedVarType = (condition: Condition, varType: VarType) => {
return condition.key ? VAR_INPUT_SUPPORTED_KEYS[condition.key] : varType
}
const getSelectOptions = (
condition: Condition,
isSelect: boolean,
t: ReturnType<typeof useTranslation>['t'],
) => {
if (!isSelect)
return []
if (condition.key === 'type' || condition.comparison_operator === ComparisonOperator.allOf) {
return FILE_TYPE_OPTIONS.map(item => ({
name: t(`${optionNameI18NPrefix}.${item.i18nKey}`, { ns: 'workflow' }),
value: item.value,
}))
}
if (condition.key === 'transfer_method') {
return TRANSFER_METHOD.map(item => ({
name: t(`${optionNameI18NPrefix}.${item.i18nKey}`, { ns: 'workflow' }),
value: item.value,
}))
}
return []
}
const getFallbackInputType = ({
hasSubVariable,
condition,
varType,
}: {
hasSubVariable: boolean
condition: Condition
varType: VarType
}) => {
return ((hasSubVariable && condition.key === 'size') || (!hasSubVariable && varType === VarType.number))
? 'number'
: 'text'
}
const ValueInput = ({
comparisonOperator,
isSelect,
isArrayValue,
isBoolean,
supportVariableInput,
selectOptions,
condition,
readOnly,
availableVars,
availableNodesWithParent,
onFocusChange,
onChange,
hasSubVariable,
varType,
t,
}: {
comparisonOperator: ComparisonOperator
isSelect: boolean
isArrayValue: boolean
isBoolean: boolean
supportVariableInput: boolean
selectOptions: Array<{ name: string, value: string }>
condition: Condition
readOnly: boolean
availableVars: VariableInputProps['nodesOutputVars']
availableNodesWithParent: VariableInputProps['availableNodes']
onFocusChange: (value: boolean) => void
onChange: (value: unknown) => void
hasSubVariable: boolean
varType: VarType
t: ReturnType<typeof useTranslation>['t']
}) => {
const [isFocus, setIsFocus] = useState(false)
const handleFocusChange = (value: boolean) => {
setIsFocus(value)
onFocusChange(value)
}
if (comparisonOperatorNotRequireValue(comparisonOperator))
return null
if (isSelect) {
return (
<Select
items={selectOptions}
defaultValue={isArrayValue ? (condition.value as string[])[0] : condition.value as string}
onSelect={item => onChange(item.value)}
className="!text-[13px]"
wrapperClassName="grow h-8"
placeholder="Select value"
/>
)
}
if (isBoolean) {
return (
<BoolValue
value={condition.value as boolean}
onChange={onChange}
/>
)
}
if (supportVariableInput) {
return (
<Input
instanceId="filter-condition-input"
className={cn(
isFocus
? 'border-components-input-border-active bg-components-input-bg-active shadow-xs'
: 'border-components-input-border-hover bg-components-input-bg-normal',
'w-0 grow rounded-lg border px-3 py-[6px]',
)}
value={getConditionValueAsString(condition)}
onChange={onChange}
readOnly={readOnly}
nodesOutputVars={availableVars}
availableNodes={availableNodesWithParent}
onFocusChange={handleFocusChange}
placeholder={!readOnly ? t('nodes.http.insertVarPlaceholder', { ns: 'workflow' })! : ''}
placeholderClassName="!leading-[21px]"
/>
)
}
return (
<input
type={getFallbackInputType({ hasSubVariable, condition, varType })}
className="grow rounded-lg border border-components-input-border-hover bg-components-input-bg-normal px-3 py-[6px]"
value={getConditionValueAsString(condition)}
onChange={e => onChange(e.target.value)}
readOnly={readOnly}
/>
)
}
const FilterCondition: FC<Props> = ({
condition = { key: '', comparison_operator: ComparisonOperator.equal, value: '' },
varType,
@ -46,9 +189,8 @@ const FilterCondition: FC<Props> = ({
nodeId,
}) => {
const { t } = useTranslation()
const [isFocus, setIsFocus] = useState(false)
const expectedVarType = condition.key ? VAR_INPUT_SUPPORTED_KEYS[condition.key] : varType
const expectedVarType = getExpectedVarType(condition, varType)
const supportVariableInput = !!expectedVarType
const { availableVars, availableNodesWithParent } = useAvailableVarList(nodeId, {
@ -62,24 +204,7 @@ const FilterCondition: FC<Props> = ({
const isArrayValue = condition.key === 'transfer_method' || condition.key === 'type'
const isBoolean = varType === VarType.boolean
const selectOptions = useMemo(() => {
if (isSelect) {
if (condition.key === 'type' || condition.comparison_operator === ComparisonOperator.allOf) {
return FILE_TYPE_OPTIONS.map(item => ({
name: t(`${optionNameI18NPrefix}.${item.i18nKey}`, { ns: 'workflow' }),
value: item.value,
}))
}
if (condition.key === 'transfer_method') {
return TRANSFER_METHOD.map(item => ({
name: t(`${optionNameI18NPrefix}.${item.i18nKey}`, { ns: 'workflow' }),
value: item.value,
}))
}
return []
}
return []
}, [condition.comparison_operator, condition.key, isSelect, t])
const selectOptions = useMemo(() => getSelectOptions(condition, isSelect, t), [condition, isSelect, t])
const handleChange = useCallback((key: string) => {
return (value: any) => {
@ -100,67 +225,6 @@ const FilterCondition: FC<Props> = ({
})
}, [onChange, expectedVarType])
// Extract input rendering logic to avoid nested ternary
let inputElement: React.ReactNode = null
if (!comparisonOperatorNotRequireValue(condition.comparison_operator)) {
if (isSelect) {
inputElement = (
<Select
items={selectOptions}
defaultValue={isArrayValue ? (condition.value as string[])[0] : condition.value as string}
onSelect={item => handleChange('value')(item.value)}
className="!text-[13px]"
wrapperClassName="grow h-8"
placeholder="Select value"
/>
)
}
else if (isBoolean) {
inputElement = (
<BoolValue
value={condition.value as boolean}
onChange={handleChange('value')}
/>
)
}
else if (supportVariableInput) {
inputElement = (
<Input
instanceId="filter-condition-input"
className={cn(
isFocus
? 'border-components-input-border-active bg-components-input-bg-active shadow-xs'
: 'border-components-input-border-hover bg-components-input-bg-normal',
'w-0 grow rounded-lg border px-3 py-[6px]',
)}
value={
getConditionValueAsString(condition)
}
onChange={handleChange('value')}
readOnly={readOnly}
nodesOutputVars={availableVars}
availableNodes={availableNodesWithParent}
onFocusChange={setIsFocus}
placeholder={!readOnly ? t('nodes.http.insertVarPlaceholder', { ns: 'workflow' })! : ''}
placeholderClassName="!leading-[21px]"
/>
)
}
else {
inputElement = (
<input
type={((hasSubVariable && condition.key === 'size') || (!hasSubVariable && varType === VarType.number)) ? 'number' : 'text'}
className="grow rounded-lg border border-components-input-border-hover bg-components-input-bg-normal px-3 py-[6px]"
value={
getConditionValueAsString(condition)
}
onChange={e => handleChange('value')(e.target.value)}
readOnly={readOnly}
/>
)
}
}
return (
<div>
{hasSubVariable && (
@ -179,7 +243,23 @@ const FilterCondition: FC<Props> = ({
file={hasSubVariable ? { key: condition.key } : undefined}
disabled={readOnly}
/>
{inputElement}
<ValueInput
comparisonOperator={condition.comparison_operator}
isSelect={isSelect}
isArrayValue={isArrayValue}
isBoolean={isBoolean}
supportVariableInput={supportVariableInput}
selectOptions={selectOptions}
condition={condition}
readOnly={readOnly}
availableVars={availableVars}
availableNodesWithParent={availableNodesWithParent}
onFocusChange={(_value) => {}}
onChange={handleChange('value')}
hasSubVariable={hasSubVariable}
varType={varType}
t={t}
/>
</div>
</div>
)

View File

@ -0,0 +1,150 @@
import type { ValueSelector, Var, VarType } from '../../types'
import type { Condition, Limit, ListFilterNodeType } from './types'
import { produce } from 'immer'
import { VarType as WorkflowVarType } from '../../types'
import { getOperators } from '../if-else/utils'
import { OrderBy } from './types'
export const getItemVarType = (varType?: VarType) => {
switch (varType) {
case WorkflowVarType.arrayNumber:
return WorkflowVarType.number
case WorkflowVarType.arrayString:
return WorkflowVarType.string
case WorkflowVarType.arrayFile:
return WorkflowVarType.file
case WorkflowVarType.arrayObject:
return WorkflowVarType.object
case WorkflowVarType.arrayBoolean:
return WorkflowVarType.boolean
default:
return varType ?? WorkflowVarType.string
}
}
export const getItemVarTypeShowName = (itemVarType?: VarType, hasVariable?: boolean) => {
if (!hasVariable)
return '?'
const fallbackType = itemVarType || WorkflowVarType.string
return `${fallbackType.substring(0, 1).toUpperCase()}${fallbackType.substring(1)}`
}
export const supportsSubVariable = (varType?: VarType) => varType === WorkflowVarType.arrayFile
export const canFilterVariable = (varPayload: Var) => {
return [
WorkflowVarType.arrayNumber,
WorkflowVarType.arrayString,
WorkflowVarType.arrayBoolean,
WorkflowVarType.arrayFile,
].includes(varPayload.type)
}
export const buildFilterCondition = ({
itemVarType,
isFileArray,
existingKey,
}: {
itemVarType?: VarType
isFileArray: boolean
existingKey?: string
}): Condition => ({
key: (isFileArray && !existingKey) ? 'name' : '',
comparison_operator: getOperators(itemVarType, isFileArray ? { key: 'name' } : undefined)[0],
value: itemVarType === WorkflowVarType.boolean ? false : '',
})
export const updateListFilterVariable = ({
inputs,
variable,
varType,
itemVarType,
}: {
inputs: ListFilterNodeType
variable: ValueSelector
varType: VarType
itemVarType: VarType
}) => produce(inputs, (draft) => {
const isFileArray = varType === WorkflowVarType.arrayFile
draft.variable = variable
draft.var_type = varType
draft.item_var_type = itemVarType
draft.filter_by.conditions = [
buildFilterCondition({
itemVarType,
isFileArray,
existingKey: draft.filter_by.conditions[0]?.key,
}),
]
if (isFileArray && draft.order_by.enabled && !draft.order_by.key)
draft.order_by.key = 'name'
})
export const updateFilterEnabled = (
inputs: ListFilterNodeType,
enabled: boolean,
) => produce(inputs, (draft) => {
draft.filter_by.enabled = enabled
if (enabled && !draft.filter_by.conditions)
draft.filter_by.conditions = []
})
export const updateFilterCondition = (
inputs: ListFilterNodeType,
condition: Condition,
) => produce(inputs, (draft) => {
draft.filter_by.conditions[0] = condition
})
export const updateLimit = (
inputs: ListFilterNodeType,
limit: Limit,
) => produce(inputs, (draft) => {
draft.limit = limit
})
export const updateExtractEnabled = (
inputs: ListFilterNodeType,
enabled: boolean,
) => produce(inputs, (draft) => {
draft.extract_by.enabled = enabled
if (enabled)
draft.extract_by.serial = '1'
})
export const updateExtractSerial = (
inputs: ListFilterNodeType,
value: string,
) => produce(inputs, (draft) => {
draft.extract_by.serial = value
})
export const updateOrderByEnabled = (
inputs: ListFilterNodeType,
enabled: boolean,
hasSubVariable: boolean,
) => produce(inputs, (draft) => {
draft.order_by.enabled = enabled
if (enabled) {
draft.order_by.value = OrderBy.ASC
if (hasSubVariable && !draft.order_by.key)
draft.order_by.key = 'name'
}
})
export const updateOrderByKey = (
inputs: ListFilterNodeType,
key: string,
) => produce(inputs, (draft) => {
draft.order_by.key = key
})
export const updateOrderByType = (
inputs: ListFilterNodeType,
type: OrderBy,
) => produce(inputs, (draft) => {
draft.order_by.value = type
})

View File

@ -1,6 +1,5 @@
import type { ValueSelector, Var } from '../../types'
import type { Condition, Limit, ListFilterNodeType } from './types'
import { produce } from 'immer'
import type { Condition, Limit, ListFilterNodeType, OrderBy } from './types'
import { useCallback, useMemo } from 'react'
import { useStoreApi } from 'reactflow'
import {
@ -10,9 +9,21 @@ import {
useWorkflowVariables,
} from '@/app/components/workflow/hooks'
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
import { VarType } from '../../types'
import { getOperators } from '../if-else/utils'
import { OrderBy } from './types'
import {
canFilterVariable,
getItemVarType,
getItemVarTypeShowName,
supportsSubVariable,
updateExtractEnabled,
updateExtractSerial,
updateFilterCondition,
updateFilterEnabled,
updateLimit,
updateListFilterVariable,
updateOrderByEnabled,
updateOrderByKey,
updateOrderByType,
} from './use-config.helpers'
const useConfig = (id: string, payload: ListFilterNodeType) => {
const { nodesReadOnly: readOnly } = useNodesReadOnly()
@ -45,127 +56,59 @@ const useConfig = (id: string, payload: ListFilterNodeType) => {
isChatMode,
isConstant: false,
})
let itemVarType
switch (varType) {
case VarType.arrayNumber:
itemVarType = VarType.number
break
case VarType.arrayString:
itemVarType = VarType.string
break
case VarType.arrayFile:
itemVarType = VarType.file
break
case VarType.arrayObject:
itemVarType = VarType.object
break
case VarType.arrayBoolean:
itemVarType = VarType.boolean
break
default:
itemVarType = varType
}
const itemVarType = getItemVarType(varType)
return { varType, itemVarType }
}, [availableNodes, getCurrentVariableType, inputs.variable, isChatMode, isInIteration, iterationNode, loopNode])
const { varType, itemVarType } = getType()
const itemVarTypeShowName = useMemo(() => {
if (!inputs.variable)
return '?'
return [(itemVarType || VarType.string).substring(0, 1).toUpperCase(), (itemVarType || VarType.string).substring(1)].join('')
}, [inputs.variable, itemVarType])
const itemVarTypeShowName = useMemo(() => getItemVarTypeShowName(itemVarType, !!inputs.variable), [inputs.variable, itemVarType])
const hasSubVariable = [VarType.arrayFile].includes(varType)
const hasSubVariable = supportsSubVariable(varType)
const handleVarChanges = useCallback((variable: ValueSelector | string) => {
const newInputs = produce(inputs, (draft) => {
draft.variable = variable as ValueSelector
const { varType, itemVarType } = getType(draft.variable)
const isFileArray = varType === VarType.arrayFile
draft.var_type = varType
draft.item_var_type = itemVarType
draft.filter_by.conditions = [{
key: (isFileArray && !draft.filter_by.conditions[0]?.key) ? 'name' : '',
comparison_operator: getOperators(itemVarType, isFileArray ? { key: 'name' } : undefined)[0],
value: itemVarType === VarType.boolean ? false : '',
}]
if (isFileArray && draft.order_by.enabled && !draft.order_by.key)
draft.order_by.key = 'name'
})
setInputs(newInputs)
const nextType = getType(variable as ValueSelector)
setInputs(updateListFilterVariable({
inputs,
variable: variable as ValueSelector,
varType: nextType.varType,
itemVarType: nextType.itemVarType,
}))
}, [getType, inputs, setInputs])
const filterVar = useCallback((varPayload: Var) => {
// Don't know the item struct of VarType.arrayObject, so not support it
return [VarType.arrayNumber, VarType.arrayString, VarType.arrayBoolean, VarType.arrayFile].includes(varPayload.type)
}, [])
const filterVar = useCallback((varPayload: Var) => canFilterVariable(varPayload), [])
const handleFilterEnabledChange = useCallback((enabled: boolean) => {
const newInputs = produce(inputs, (draft) => {
draft.filter_by.enabled = enabled
if (enabled && !draft.filter_by.conditions)
draft.filter_by.conditions = []
})
setInputs(newInputs)
}, [hasSubVariable, inputs, setInputs])
setInputs(updateFilterEnabled(inputs, enabled))
}, [inputs, setInputs])
const handleFilterChange = useCallback((condition: Condition) => {
const newInputs = produce(inputs, (draft) => {
draft.filter_by.conditions[0] = condition
})
setInputs(newInputs)
setInputs(updateFilterCondition(inputs, condition))
}, [inputs, setInputs])
const handleLimitChange = useCallback((limit: Limit) => {
const newInputs = produce(inputs, (draft) => {
draft.limit = limit
})
setInputs(newInputs)
setInputs(updateLimit(inputs, limit))
}, [inputs, setInputs])
const handleExtractsEnabledChange = useCallback((enabled: boolean) => {
const newInputs = produce(inputs, (draft) => {
draft.extract_by.enabled = enabled
if (enabled)
draft.extract_by.serial = '1'
})
setInputs(newInputs)
setInputs(updateExtractEnabled(inputs, enabled))
}, [inputs, setInputs])
const handleExtractsChange = useCallback((value: string) => {
const newInputs = produce(inputs, (draft) => {
draft.extract_by.serial = value
})
setInputs(newInputs)
setInputs(updateExtractSerial(inputs, value))
}, [inputs, setInputs])
const handleOrderByEnabledChange = useCallback((enabled: boolean) => {
const newInputs = produce(inputs, (draft) => {
draft.order_by.enabled = enabled
if (enabled) {
draft.order_by.value = OrderBy.ASC
if (hasSubVariable && !draft.order_by.key)
draft.order_by.key = 'name'
}
})
setInputs(newInputs)
setInputs(updateOrderByEnabled(inputs, enabled, hasSubVariable))
}, [hasSubVariable, inputs, setInputs])
const handleOrderByKeyChange = useCallback((key: string) => {
const newInputs = produce(inputs, (draft) => {
draft.order_by.key = key
})
setInputs(newInputs)
setInputs(updateOrderByKey(inputs, key))
}, [inputs, setInputs])
const handleOrderByTypeChange = useCallback((type: OrderBy) => {
return () => {
const newInputs = produce(inputs, (draft) => {
draft.order_by.value = type
})
setInputs(newInputs)
setInputs(updateOrderByType(inputs, type))
}
}, [inputs, setInputs])

View File

@ -131,6 +131,8 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
hideDebugWithMultipleModel
debugWithMultipleModel={false}
readonly={readOnly}
nodesOutputVars={availableVars}
availableNodes={availableNodesWithParent}
/>
</Field>

View File

@ -0,0 +1,216 @@
import type { LoopNodeType } from '../types'
import { BlockEnum, ErrorHandleMode, ValueType, VarType } from '@/app/components/workflow/types'
import { createUuidModuleMock } from '../../__tests__/use-config-test-utils'
import { ComparisonOperator, LogicalOperator } from '../types'
import {
addBreakCondition,
addLoopVariable,
addSubVariableCondition,
canUseAsLoopInput,
removeBreakCondition,
removeLoopVariable,
removeSubVariableCondition,
toggleConditionOperator,
toggleSubVariableConditionOperator,
updateBreakCondition,
updateErrorHandleMode,
updateLoopCount,
updateLoopVariable,
updateSubVariableCondition,
} from '../use-config.helpers'
const mockUuid = vi.hoisted(() => vi.fn())
vi.mock('uuid', () => createUuidModuleMock(() => mockUuid()))
const createInputs = (overrides: Partial<LoopNodeType> = {}): LoopNodeType => ({
title: 'Loop',
desc: '',
type: BlockEnum.Loop,
start_node_id: 'start-node',
loop_count: 3,
error_handle_mode: ErrorHandleMode.Terminated,
logical_operator: LogicalOperator.and,
break_conditions: [],
loop_variables: [],
...overrides,
})
describe('loop use-config helpers', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('canUseAsLoopInput', () => {
it.each([
VarType.array,
VarType.arrayString,
VarType.arrayNumber,
VarType.arrayObject,
VarType.arrayFile,
])('should accept %s loop inputs', (type) => {
expect(canUseAsLoopInput({ type } as never)).toBe(true)
})
it('should reject non-array loop inputs', () => {
expect(canUseAsLoopInput({ type: VarType.string } as never)).toBe(false)
})
})
it('should update error handling, loop count and logical operators immutably', () => {
const inputs = createInputs()
const withMode = updateErrorHandleMode(inputs, ErrorHandleMode.ContinueOnError)
const withCount = updateLoopCount(withMode, 6)
const toggled = toggleConditionOperator(withCount)
const toggledBack = toggleConditionOperator(toggled)
expect(withMode.error_handle_mode).toBe(ErrorHandleMode.ContinueOnError)
expect(withCount.loop_count).toBe(6)
expect(toggled.logical_operator).toBe(LogicalOperator.or)
expect(toggledBack.logical_operator).toBe(LogicalOperator.and)
expect(inputs.error_handle_mode).toBe(ErrorHandleMode.Terminated)
expect(inputs.loop_count).toBe(3)
})
it('should add, update and remove break conditions for regular and file attributes', () => {
mockUuid
.mockReturnValueOnce('condition-1')
.mockReturnValueOnce('condition-2')
const withBooleanCondition = addBreakCondition({
inputs: createInputs({ break_conditions: undefined }),
valueSelector: ['tool-node', 'enabled'],
variable: { type: VarType.boolean },
isVarFileAttribute: false,
})
const withFileCondition = addBreakCondition({
inputs: withBooleanCondition,
valueSelector: ['tool-node', 'file', 'transfer_method'],
variable: { type: VarType.file },
isVarFileAttribute: true,
})
const updated = updateBreakCondition(withFileCondition, 'condition-2', {
id: 'condition-2',
varType: VarType.file,
key: 'transfer_method',
variable_selector: ['tool-node', 'file', 'transfer_method'],
comparison_operator: ComparisonOperator.notIn,
value: [VarType.file],
})
const removed = removeBreakCondition(updated, 'condition-1')
expect(withBooleanCondition.break_conditions).toEqual([
expect.objectContaining({
id: 'condition-1',
varType: VarType.boolean,
comparison_operator: ComparisonOperator.is,
value: 'false',
}),
])
expect(withFileCondition.break_conditions?.[1]).toEqual(expect.objectContaining({
id: 'condition-2',
varType: VarType.file,
comparison_operator: ComparisonOperator.in,
value: '',
}))
expect(updated.break_conditions?.[1]).toEqual(expect.objectContaining({
comparison_operator: ComparisonOperator.notIn,
value: [VarType.file],
}))
expect(removed.break_conditions).toEqual([
expect.objectContaining({ id: 'condition-2' }),
])
})
it('should manage nested sub-variable conditions and ignore missing targets', () => {
mockUuid
.mockReturnValueOnce('sub-condition-1')
.mockReturnValueOnce('sub-condition-2')
const inputs = createInputs({
break_conditions: [{
id: 'condition-1',
varType: VarType.file,
key: 'name',
variable_selector: ['tool-node', 'file'],
comparison_operator: ComparisonOperator.contains,
value: '',
}],
})
const untouched = addSubVariableCondition(inputs, 'missing-condition')
const withKeyedSubCondition = addSubVariableCondition(inputs, 'condition-1', 'transfer_method')
const withDefaultKeySubCondition = addSubVariableCondition(withKeyedSubCondition, 'condition-1')
const updated = updateSubVariableCondition(withDefaultKeySubCondition, 'condition-1', 'sub-condition-1', {
id: 'sub-condition-1',
key: 'transfer_method',
varType: VarType.string,
comparison_operator: ComparisonOperator.notIn,
value: ['remote_url'],
})
const toggled = toggleSubVariableConditionOperator(updated, 'condition-1')
const removed = removeSubVariableCondition(toggled, 'condition-1', 'sub-condition-1')
const unchangedAfterMissingRemove = removeSubVariableCondition(removed, 'missing-condition', 'sub-condition-2')
expect(untouched).toEqual(inputs)
expect(withKeyedSubCondition.break_conditions?.[0].sub_variable_condition).toEqual({
logical_operator: LogicalOperator.and,
conditions: [{
id: 'sub-condition-1',
key: 'transfer_method',
varType: VarType.string,
comparison_operator: ComparisonOperator.in,
value: '',
}],
})
expect(withDefaultKeySubCondition.break_conditions?.[0].sub_variable_condition?.conditions[1]).toEqual({
id: 'sub-condition-2',
key: '',
varType: VarType.string,
comparison_operator: undefined,
value: '',
})
expect(updated.break_conditions?.[0].sub_variable_condition?.conditions[0]).toEqual(expect.objectContaining({
comparison_operator: ComparisonOperator.notIn,
value: ['remote_url'],
}))
expect(toggled.break_conditions?.[0].sub_variable_condition?.logical_operator).toBe(LogicalOperator.or)
expect(removed.break_conditions?.[0].sub_variable_condition?.conditions).toEqual([
expect.objectContaining({ id: 'sub-condition-2' }),
])
expect(unchangedAfterMissingRemove).toEqual(removed)
})
it('should add, update and remove loop variables without mutating the source inputs', () => {
mockUuid.mockReturnValueOnce('loop-variable-1')
const inputs = createInputs({ loop_variables: undefined })
const added = addLoopVariable(inputs)
const updated = updateLoopVariable(added, 'loop-variable-1', {
label: 'Loop Value',
value_type: ValueType.variable,
value: ['tool-node', 'result'],
})
const unchanged = updateLoopVariable(updated, 'missing-loop-variable', { label: 'ignored' })
const removed = removeLoopVariable(unchanged, 'loop-variable-1')
expect(added.loop_variables).toEqual([{
id: 'loop-variable-1',
label: '',
var_type: VarType.string,
value_type: ValueType.constant,
value: '',
}])
expect(updated.loop_variables).toEqual([{
id: 'loop-variable-1',
label: 'Loop Value',
var_type: VarType.string,
value_type: ValueType.variable,
value: ['tool-node', 'result'],
}])
expect(unchanged).toEqual(updated)
expect(removed.loop_variables).toEqual([])
expect(inputs.loop_variables).toBeUndefined()
})
})

View File

@ -0,0 +1,221 @@
import type { LoopNodeType } from '../types'
import { renderHook } from '@testing-library/react'
import { BlockEnum, ErrorHandleMode, ValueType, VarType } from '@/app/components/workflow/types'
import {
createNodeCrudModuleMock,
createUuidModuleMock,
} from '../../__tests__/use-config-test-utils'
import { ComparisonOperator, LogicalOperator } from '../types'
import useConfig from '../use-config'
const mockSetInputs = vi.hoisted(() => vi.fn())
const mockGetLoopNodeChildren = vi.hoisted(() => vi.fn())
const mockGetIsVarFileAttribute = vi.hoisted(() => vi.fn())
const mockUuid = vi.hoisted(() => vi.fn(() => 'generated-id'))
vi.mock('uuid', () => ({
...createUuidModuleMock(mockUuid),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: { conversationVariables: unknown[], dataSourceList: unknown[] }) => unknown) => selector({
conversationVariables: [],
dataSourceList: [],
}),
}))
vi.mock('@/service/use-tools', () => ({
useAllBuiltInTools: () => ({ data: [] }),
useAllCustomTools: () => ({ data: [] }),
useAllWorkflowTools: () => ({ data: [] }),
useAllMCPTools: () => ({ data: [] }),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesReadOnly: () => ({ nodesReadOnly: false }),
useIsChatMode: () => false,
useWorkflow: () => ({
getLoopNodeChildren: (...args: unknown[]) => mockGetLoopNodeChildren(...args),
}),
}))
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
...createNodeCrudModuleMock<LoopNodeType>(mockSetInputs),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/utils', () => ({
toNodeOutputVars: () => [{ nodeId: 'child-node', title: 'Child', vars: [] }],
}))
vi.mock('../use-is-var-file-attribute', () => ({
__esModule: true,
default: () => ({
getIsVarFileAttribute: (...args: unknown[]) => mockGetIsVarFileAttribute(...args),
}),
}))
const createPayload = (overrides: Partial<LoopNodeType> = {}): LoopNodeType => ({
title: 'Loop',
desc: '',
type: BlockEnum.Loop,
start_node_id: 'start-node',
loop_id: 'loop-node',
logical_operator: LogicalOperator.and,
break_conditions: [{
id: 'condition-1',
varType: VarType.string,
variable_selector: ['node-1', 'answer'],
comparison_operator: ComparisonOperator.contains,
value: 'hello',
}],
loop_count: 3,
error_handle_mode: ErrorHandleMode.ContinueOnError,
loop_variables: [{
id: 'loop-var-1',
label: 'item',
var_type: VarType.string,
value_type: ValueType.constant,
value: 'value',
}],
...overrides,
})
describe('useConfig', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetLoopNodeChildren.mockReturnValue([])
mockGetIsVarFileAttribute.mockReturnValue(false)
})
it('should expose derived outputs and input variable filtering', () => {
const { result } = renderHook(() => useConfig('loop-node', createPayload()))
expect(result.current.readOnly).toBe(false)
expect(result.current.childrenNodeVars).toEqual([{ nodeId: 'child-node', title: 'Child', vars: [] }])
expect(result.current.loopChildrenNodes).toHaveLength(1)
expect(result.current.filterInputVar({ type: VarType.arrayNumber } as never)).toBe(true)
expect(result.current.filterInputVar({ type: VarType.string } as never)).toBe(false)
})
it('should update error mode, break conditions and logical operators', () => {
const { result } = renderHook(() => useConfig('loop-node', createPayload()))
result.current.changeErrorResponseMode({ value: ErrorHandleMode.Terminated })
result.current.handleAddCondition(['node-1', 'score'], { type: VarType.number } as never)
result.current.handleUpdateCondition('condition-1', {
id: 'condition-1',
varType: VarType.number,
variable_selector: ['node-1', 'score'],
comparison_operator: ComparisonOperator.largerThan,
value: '3',
})
result.current.handleRemoveCondition('condition-1')
result.current.handleToggleConditionLogicalOperator()
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
error_handle_mode: ErrorHandleMode.Terminated,
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
break_conditions: expect.arrayContaining([
expect.objectContaining({
id: 'generated-id',
variable_selector: ['node-1', 'score'],
varType: VarType.number,
}),
]),
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
break_conditions: expect.arrayContaining([
expect.objectContaining({
varType: VarType.number,
comparison_operator: ComparisonOperator.largerThan,
value: '3',
}),
]),
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
logical_operator: LogicalOperator.or,
}))
})
it('should manage sub-variable conditions and loop variables', () => {
const payload = createPayload({
break_conditions: [{
id: 'condition-1',
varType: VarType.file,
variable_selector: ['node-1', 'files'],
comparison_operator: ComparisonOperator.contains,
value: '',
sub_variable_condition: {
logical_operator: LogicalOperator.and,
conditions: [{
id: 'sub-1',
key: 'name',
varType: VarType.string,
comparison_operator: ComparisonOperator.contains,
value: '',
}],
},
}],
})
const { result } = renderHook(() => useConfig('loop-node', payload))
result.current.handleAddSubVariableCondition('condition-1', 'name')
result.current.handleUpdateSubVariableCondition('condition-1', 'sub-1', {
id: 'sub-1',
key: 'size',
varType: VarType.string,
comparison_operator: ComparisonOperator.contains,
value: '2',
})
result.current.handleRemoveSubVariableCondition('condition-1', 'sub-1')
result.current.handleToggleSubVariableConditionLogicalOperator('condition-1')
result.current.handleUpdateLoopCount(5)
result.current.handleAddLoopVariable()
result.current.handleRemoveLoopVariable('loop-var-1')
result.current.handleUpdateLoopVariable('loop-var-1', { label: 'updated' })
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
break_conditions: [
expect.objectContaining({
sub_variable_condition: expect.objectContaining({
conditions: expect.arrayContaining([
expect.objectContaining({
id: 'generated-id',
key: 'name',
}),
]),
}),
}),
],
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
break_conditions: [
expect.objectContaining({
sub_variable_condition: expect.objectContaining({
logical_operator: LogicalOperator.or,
}),
}),
],
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
loop_count: 5,
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
loop_variables: expect.arrayContaining([
expect.objectContaining({
id: 'generated-id',
value_type: ValueType.constant,
}),
]),
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
loop_variables: [
expect.objectContaining({
id: 'generated-id',
value_type: ValueType.constant,
}),
],
}))
})
})

View File

@ -0,0 +1,100 @@
import type { Node } from '@/app/components/workflow/types'
import { BlockEnum } from '@/app/components/workflow/types'
import {
buildLoopChildCopy,
getContainerBounds,
getContainerResize,
getLoopChildren,
getRestrictedLoopPosition,
} from '../use-interactions.helpers'
const createNode = (overrides: Record<string, unknown> = {}) => ({
id: 'node',
type: 'custom',
position: { x: 0, y: 0 },
width: 100,
height: 80,
data: { type: BlockEnum.Code, title: 'Code', desc: '' },
...overrides,
})
describe('loop interaction helpers', () => {
it('calculates bounds and container resize from overflowing children', () => {
const children = [
createNode({ id: 'a', position: { x: 20, y: 10 }, width: 80, height: 40 }),
createNode({ id: 'b', position: { x: 120, y: 60 }, width: 50, height: 30 }),
]
const bounds = getContainerBounds(children as Node[])
expect(bounds.rightNode?.id).toBe('b')
expect(bounds.bottomNode?.id).toBe('b')
expect(getContainerResize(createNode({ width: 120, height: 80 }) as Node, bounds)).toEqual({
width: 186,
height: 110,
})
expect(getContainerResize(createNode({ width: 300, height: 300 }), bounds)).toEqual({
width: undefined,
height: undefined,
})
})
it('restricts loop positions only for loop children and filters loop-start nodes', () => {
const parent = createNode({ id: 'parent', width: 200, height: 180 })
expect(getRestrictedLoopPosition(createNode({ data: { isInLoop: false } }) as Node, parent as Node)).toEqual({ x: undefined, y: undefined })
expect(getRestrictedLoopPosition(
createNode({
position: { x: -10, y: 160 },
width: 80,
height: 40,
data: { isInLoop: true },
}),
parent as Node,
)).toEqual({ x: 16, y: 120 })
expect(getRestrictedLoopPosition(
createNode({
position: { x: 180, y: -4 },
width: 40,
height: 30,
data: { isInLoop: true },
}),
parent as Node,
)).toEqual({ x: 144, y: 65 })
expect(getLoopChildren([
createNode({ id: 'child', parentId: 'loop-1' }),
createNode({ id: 'start', parentId: 'loop-1', type: 'custom-loop-start' }),
createNode({ id: 'other', parentId: 'other-loop' }),
] as Node[], 'loop-1').map(item => item.id)).toEqual(['child'])
})
it('builds copied loop children with derived title and loop metadata', () => {
const child = createNode({
id: 'child',
position: { x: 12, y: 24 },
positionAbsolute: { x: 12, y: 24 },
extent: 'parent',
data: { type: BlockEnum.Code, title: 'Original', desc: 'child', selected: true },
})
const result = buildLoopChildCopy({
child: child as Node,
childNodeType: BlockEnum.Code,
defaultValue: { title: 'Code', desc: '', type: BlockEnum.Code } as Node['data'],
nodesWithSameTypeCount: 2,
newNodeId: 'loop-2',
index: 3,
})
expect(result.newId).toBe('loop-23')
expect(result.params).toEqual(expect.objectContaining({
parentId: 'loop-2',
zIndex: 1002,
data: expect.objectContaining({
title: 'Code 3',
isInLoop: true,
loop_id: 'loop-2',
selected: false,
_isBundled: false,
}),
}))
})
})

View File

@ -0,0 +1,174 @@
import type { Node } from '@/app/components/workflow/types'
import { renderHook } from '@testing-library/react'
import {
createLoopNode,
createNode,
} from '@/app/components/workflow/__tests__/fixtures'
import { LOOP_PADDING } from '@/app/components/workflow/constants'
import { BlockEnum } from '@/app/components/workflow/types'
import { useNodeLoopInteractions } from '../use-interactions'
const mockGetNodes = vi.hoisted(() => vi.fn())
const mockSetNodes = vi.hoisted(() => vi.fn())
const mockGenerateNewNode = vi.hoisted(() => vi.fn())
vi.mock('reactflow', async () => {
const actual = await vi.importActual<typeof import('reactflow')>('reactflow')
return {
...actual,
useStoreApi: () => ({
getState: () => ({
getNodes: mockGetNodes,
setNodes: mockSetNodes,
}),
}),
}
})
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesMetaData: () => ({
nodesMap: {
[BlockEnum.Code]: {
defaultValue: {
title: 'Code',
},
},
},
}),
}))
vi.mock('@/app/components/workflow/utils', () => ({
generateNewNode: (...args: unknown[]) => mockGenerateNewNode(...args),
getNodeCustomTypeByNodeDataType: () => 'custom',
}))
describe('useNodeLoopInteractions', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should expand the loop node when children overflow the bounds', () => {
mockGetNodes.mockReturnValue([
createLoopNode({
id: 'loop-node',
width: 120,
height: 80,
data: { width: 120, height: 80 },
}),
createNode({
id: 'child-node',
parentId: 'loop-node',
position: { x: 100, y: 90 },
width: 60,
height: 40,
}),
])
const { result } = renderHook(() => useNodeLoopInteractions())
result.current.handleNodeLoopRerender('loop-node')
expect(mockSetNodes).toHaveBeenCalledTimes(1)
const updatedNodes = mockSetNodes.mock.calls[0][0]
const updatedLoopNode = updatedNodes.find((node: Node) => node.id === 'loop-node')
expect(updatedLoopNode.width).toBe(100 + 60 + LOOP_PADDING.right)
expect(updatedLoopNode.height).toBe(90 + 40 + LOOP_PADDING.bottom)
})
it('should restrict dragging to the loop container padding', () => {
mockGetNodes.mockReturnValue([
createLoopNode({
id: 'loop-node',
width: 200,
height: 180,
data: { width: 200, height: 180 },
}),
])
const { result } = renderHook(() => useNodeLoopInteractions())
const dragResult = result.current.handleNodeLoopChildDrag(createNode({
id: 'child-node',
parentId: 'loop-node',
position: { x: -10, y: -5 },
width: 80,
height: 60,
data: { type: BlockEnum.Code, title: 'Child', desc: '', isInLoop: true },
}))
expect(dragResult.restrictPosition).toEqual({
x: LOOP_PADDING.left,
y: LOOP_PADDING.top,
})
})
it('should rerender the parent loop node when a child size changes', () => {
mockGetNodes.mockReturnValue([
createLoopNode({
id: 'loop-node',
width: 120,
height: 80,
data: { width: 120, height: 80 },
}),
createNode({
id: 'child-node',
parentId: 'loop-node',
position: { x: 100, y: 90 },
width: 60,
height: 40,
}),
])
const { result } = renderHook(() => useNodeLoopInteractions())
result.current.handleNodeLoopChildSizeChange('child-node')
expect(mockSetNodes).toHaveBeenCalledTimes(1)
})
it('should skip loop rerender when the resized node has no parent', () => {
mockGetNodes.mockReturnValue([
createNode({
id: 'standalone-node',
data: { type: BlockEnum.Code, title: 'Standalone', desc: '' },
}),
])
const { result } = renderHook(() => useNodeLoopInteractions())
result.current.handleNodeLoopChildSizeChange('standalone-node')
expect(mockSetNodes).not.toHaveBeenCalled()
})
it('should copy loop children and remap ids', () => {
mockGetNodes.mockReturnValue([
createLoopNode({ id: 'loop-node' }),
createNode({
id: 'child-node',
parentId: 'loop-node',
data: { type: BlockEnum.Code, title: 'Child', desc: '' },
}),
createNode({
id: 'same-type-node',
data: { type: BlockEnum.Code, title: 'Code', desc: '' },
}),
])
mockGenerateNewNode.mockReturnValue({
newNode: createNode({
id: 'generated',
parentId: 'new-loop',
data: { type: BlockEnum.Code, title: 'Code 3', desc: '', isInLoop: true, loop_id: 'new-loop' },
}),
})
const { result } = renderHook(() => useNodeLoopInteractions())
const copyResult = result.current.handleNodeLoopChildrenCopy('loop-node', 'new-loop', { existing: 'mapped' })
expect(mockGenerateNewNode).toHaveBeenCalledWith(expect.objectContaining({
type: 'custom',
parentId: 'new-loop',
}))
expect(copyResult.copyChildren).toHaveLength(1)
expect(copyResult.newIdMapping).toEqual({
'existing': 'mapped',
'child-node': 'new-loopgeneratednew-loop0',
})
})
})

View File

@ -0,0 +1,241 @@
import type { InputVar, Node, Variable } from '../../../types'
import type { Condition } from '../types'
import { BlockEnum, InputVarType, ValueType, VarType } from '@/app/components/workflow/types'
import { VALUE_SELECTOR_DELIMITER } from '@/config'
import { ComparisonOperator, LogicalOperator } from '../types'
import {
buildUsedOutVars,
createInputVarValues,
dedupeInputVars,
getDependentVarsFromLoopPayload,
getVarSelectorsFromCase,
getVarSelectorsFromCondition,
} from '../use-single-run-form-params.helpers'
const mockGetNodeInfoById = vi.hoisted(() => vi.fn())
const mockGetNodeUsedVarPassToServerKey = vi.hoisted(() => vi.fn())
const mockGetNodeUsedVars = vi.hoisted(() => vi.fn())
const mockIsSystemVar = vi.hoisted(() => vi.fn())
vi.mock('../../_base/components/variable/utils', () => ({
getNodeInfoById: (...args: unknown[]) => mockGetNodeInfoById(...args),
getNodeUsedVarPassToServerKey: (...args: unknown[]) => mockGetNodeUsedVarPassToServerKey(...args),
getNodeUsedVars: (...args: unknown[]) => mockGetNodeUsedVars(...args),
isSystemVar: (...args: unknown[]) => mockIsSystemVar(...args),
}))
const createNode = (id: string, title: string, type = BlockEnum.Tool): Node => ({
id,
position: { x: 0, y: 0 },
data: {
title,
desc: '',
type,
},
} as Node)
const createInputVar = (variable: string, label: InputVar['label'] = variable): InputVar => ({
type: InputVarType.textInput,
label,
variable,
required: false,
})
const createCondition = (overrides: Partial<Condition> = {}): Condition => ({
id: 'condition-1',
varType: VarType.string,
variable_selector: ['tool-node', 'value'],
comparison_operator: ComparisonOperator.equal,
value: '',
...overrides,
})
describe('use-single-run-form-params helpers', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should collect var selectors from conditions and nested cases', () => {
const nestedCondition = createCondition({
variable_selector: ['tool-node', 'value'],
sub_variable_condition: {
logical_operator: LogicalOperator.and,
conditions: [
createCondition({
id: 'sub-condition-1',
variable_selector: ['start-node', 'answer'],
}),
],
},
})
expect(getVarSelectorsFromCondition(nestedCondition)).toEqual([
['tool-node', 'value'],
['start-node', 'answer'],
])
expect(getVarSelectorsFromCase({
logical_operator: LogicalOperator.or,
conditions: [
nestedCondition,
createCondition({
id: 'condition-2',
variable_selector: ['other-node', 'result'],
}),
],
})).toEqual([
['tool-node', 'value'],
['start-node', 'answer'],
['other-node', 'result'],
])
})
it('should copy input values and dedupe duplicate or invalid input vars', () => {
const source = {
question: 'hello',
retry: true,
}
const values = createInputVarValues(source)
const deduped = dedupeInputVars([
createInputVar('tool-node.value'),
createInputVar('tool-node.value'),
undefined as unknown as InputVar,
createInputVar('start-node.answer'),
])
expect(values).toEqual(source)
expect(values).not.toBe(source)
expect(deduped).toEqual([
createInputVar('tool-node.value'),
createInputVar('start-node.answer'),
])
})
it('should build used output vars and pass-to-server keys while filtering loop-local selectors', () => {
const startNode = createNode('start-node', 'Start Node', BlockEnum.Start)
const sysNode = createNode('sys', 'System', BlockEnum.Start)
const loopChildrenNodes = [
createNode('tool-a', 'Tool A'),
createNode('tool-b', 'Tool B'),
createNode('current-node', 'Current Node'),
createNode('inner-node', 'Inner Node'),
]
mockGetNodeUsedVars.mockImplementation((node: Node) => {
switch (node.id) {
case 'tool-a':
return [['sys', 'files']]
case 'tool-b':
return [['start-node', 'answer'], ['current-node', 'self'], ['inner-node', 'secret']]
default:
return []
}
})
mockGetNodeUsedVarPassToServerKey.mockImplementation((_node: Node, selector: string[]) => {
return selector[0] === 'sys' ? ['sys_files', 'sys_files_backup'] : 'answer_key'
})
mockGetNodeInfoById.mockImplementation((nodes: Node[], id: string) => nodes.find(node => node.id === id))
mockIsSystemVar.mockImplementation((selector: string[]) => selector[0] === 'sys')
const toVarInputs = vi.fn((variables: Variable[]) => variables.map(variable => createInputVar(
variable.variable,
variable.label as InputVar['label'],
)))
const result = buildUsedOutVars({
loopChildrenNodes,
currentNodeId: 'current-node',
canChooseVarNodes: [startNode, sysNode, ...loopChildrenNodes],
isNodeInLoop: nodeId => nodeId === 'inner-node',
toVarInputs,
})
expect(toVarInputs).toHaveBeenCalledWith([
expect.objectContaining({
variable: 'sys.files',
label: {
nodeType: BlockEnum.Start,
nodeName: 'System',
variable: 'sys.files',
},
}),
expect.objectContaining({
variable: 'start-node.answer',
label: {
nodeType: BlockEnum.Start,
nodeName: 'Start Node',
variable: 'answer',
},
}),
])
expect(result.usedOutVars).toEqual([
createInputVar('sys.files', {
nodeType: BlockEnum.Start,
nodeName: 'System',
variable: 'sys.files',
}),
createInputVar('start-node.answer', {
nodeType: BlockEnum.Start,
nodeName: 'Start Node',
variable: 'answer',
}),
])
expect(result.allVarObject).toEqual({
[['sys.files', 'tool-a', 0].join(VALUE_SELECTOR_DELIMITER)]: {
inSingleRunPassedKey: 'sys_files',
},
[['sys.files', 'tool-a', 1].join(VALUE_SELECTOR_DELIMITER)]: {
inSingleRunPassedKey: 'sys_files_backup',
},
[['start-node.answer', 'tool-b', 0].join(VALUE_SELECTOR_DELIMITER)]: {
inSingleRunPassedKey: 'answer_key',
},
})
})
it('should derive dependent vars from payload and filter current node references', () => {
const dependentVars = getDependentVarsFromLoopPayload({
nodeId: 'loop-node',
usedOutVars: [
createInputVar('start-node.answer'),
createInputVar('loop-node.internal'),
],
breakConditions: [
createCondition({
variable_selector: ['tool-node', 'value'],
sub_variable_condition: {
logical_operator: LogicalOperator.and,
conditions: [
createCondition({
id: 'sub-condition-1',
variable_selector: ['loop-node', 'ignored'],
}),
],
},
}),
],
loopVariables: [
{
id: 'loop-variable-1',
label: 'Loop Input',
var_type: VarType.string,
value_type: ValueType.variable,
value: ['tool-node', 'next'],
},
{
id: 'loop-variable-2',
label: 'Constant',
var_type: VarType.string,
value_type: ValueType.constant,
value: 'plain-text',
},
],
})
expect(dependentVars).toEqual([
['start-node', 'answer'],
['tool-node', 'value'],
['tool-node', 'next'],
])
})
})

View File

@ -0,0 +1,216 @@
import type { InputVar, Node } from '../../../types'
import type { LoopNodeType } from '../types'
import type { NodeTracing } from '@/types/workflow'
import { act, renderHook } from '@testing-library/react'
import { BlockEnum, ErrorHandleMode, InputVarType, ValueType, VarType } from '@/app/components/workflow/types'
import { ComparisonOperator, LogicalOperator } from '../types'
import useSingleRunFormParams from '../use-single-run-form-params'
const mockUseIsNodeInLoop = vi.hoisted(() => vi.fn())
const mockUseWorkflow = vi.hoisted(() => vi.fn())
const mockFormatTracing = vi.hoisted(() => vi.fn())
const mockGetNodeUsedVars = vi.hoisted(() => vi.fn())
const mockGetNodeUsedVarPassToServerKey = vi.hoisted(() => vi.fn())
const mockGetNodeInfoById = vi.hoisted(() => vi.fn())
const mockIsSystemVar = vi.hoisted(() => vi.fn())
vi.mock('../../../hooks', () => ({
useIsNodeInLoop: (...args: unknown[]) => mockUseIsNodeInLoop(...args),
useWorkflow: () => mockUseWorkflow(),
}))
vi.mock('@/app/components/workflow/run/utils/format-log', () => ({
__esModule: true,
default: (...args: unknown[]) => mockFormatTracing(...args),
}))
vi.mock('../../_base/components/variable/utils', () => ({
getNodeUsedVars: (...args: unknown[]) => mockGetNodeUsedVars(...args),
getNodeUsedVarPassToServerKey: (...args: unknown[]) => mockGetNodeUsedVarPassToServerKey(...args),
getNodeInfoById: (...args: unknown[]) => mockGetNodeInfoById(...args),
isSystemVar: (...args: unknown[]) => mockIsSystemVar(...args),
}))
const createLoopNode = (overrides: Partial<LoopNodeType> = {}): LoopNodeType => ({
title: 'Loop',
desc: '',
type: BlockEnum.Loop,
start_node_id: 'start-node',
loop_count: 3,
error_handle_mode: ErrorHandleMode.Terminated,
break_conditions: [],
loop_variables: [],
...overrides,
})
const createVariableNode = (id: string, title: string, type = BlockEnum.Tool): Node => ({
id,
position: { x: 0, y: 0 },
data: {
title,
type,
desc: '',
},
} as Node)
const createInputVar = (variable: string): InputVar => ({
type: InputVarType.textInput,
label: variable,
variable,
required: false,
})
const createRunTrace = (): NodeTracing => ({
id: 'trace-1',
index: 0,
predecessor_node_id: '',
node_id: 'loop-node',
node_type: BlockEnum.Loop,
title: 'Loop',
inputs: {},
inputs_truncated: false,
process_data: {},
process_data_truncated: false,
outputs_truncated: false,
status: 'succeeded',
elapsed_time: 1,
metadata: {
iterator_length: 0,
iterator_index: 0,
loop_length: 2,
loop_index: 1,
},
created_at: 0,
created_by: {
id: 'user-1',
name: 'User',
email: 'user@example.com',
},
finished_at: 1,
})
describe('useSingleRunFormParams', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseIsNodeInLoop.mockReturnValue({
isNodeInLoop: (nodeId: string) => nodeId === 'inner-node',
})
mockUseWorkflow.mockReturnValue({
getLoopNodeChildren: () => [
createVariableNode('tool-a', 'Tool A'),
createVariableNode('loop-node', 'Loop Node'),
createVariableNode('inner-node', 'Inner Node'),
],
getBeforeNodesInSameBranch: () => [
createVariableNode('start-node', 'Start Node', BlockEnum.Start),
],
})
mockGetNodeUsedVars.mockImplementation((node: Node) => {
if (node.id === 'tool-a')
return [['start-node', 'answer']]
if (node.id === 'loop-node')
return [['loop-node', 'item']]
if (node.id === 'inner-node')
return [['inner-node', 'secret']]
return []
})
mockGetNodeUsedVarPassToServerKey.mockReturnValue('passed_key')
mockGetNodeInfoById.mockImplementation((nodes: Node[], id: string) => nodes.find(node => node.id === id))
mockIsSystemVar.mockReturnValue(false)
mockFormatTracing.mockReturnValue([{
id: 'formatted-node',
execution_metadata: { loop_index: 9 },
}])
})
it('should build single-run forms and filter out loop-local variables', () => {
const toVarInputs = vi.fn((variables: Array<{ variable: string }>) => variables.map(item => createInputVar(item.variable)))
const varSelectorsToVarInputs = vi.fn(() => [
createInputVar('tool-a.result'),
createInputVar('tool-a.result'),
createInputVar('start-node.answer'),
])
const { result } = renderHook(() => useSingleRunFormParams({
id: 'loop-node',
payload: createLoopNode({
break_conditions: [{
id: 'condition-1',
varType: VarType.string,
variable_selector: ['tool-a', 'result'],
comparison_operator: ComparisonOperator.equal,
value: '',
sub_variable_condition: {
logical_operator: LogicalOperator.and,
conditions: [],
},
}],
loop_variables: [{
id: 'loop-variable-1',
label: 'Loop Value',
var_type: VarType.string,
value_type: ValueType.variable,
value: ['start-node', 'answer'],
}],
}),
runInputData: {
question: 'hello',
},
runResult: null as unknown as NodeTracing,
loopRunResult: [],
setRunInputData: vi.fn(),
toVarInputs,
varSelectorsToVarInputs,
}))
expect(toVarInputs).toHaveBeenCalledWith([
expect.objectContaining({ variable: 'start-node.answer' }),
])
expect(result.current.forms).toHaveLength(1)
expect(result.current.forms[0].inputs).toEqual([
createInputVar('start-node.answer'),
createInputVar('tool-a.result'),
createInputVar('start-node.answer'),
])
expect(result.current.forms[0].values).toEqual({ question: 'hello' })
expect(result.current.allVarObject).toEqual({
'start-node.answer@@@tool-a@@@0': {
inSingleRunPassedKey: 'passed_key',
},
})
expect(result.current.getDependentVars()).toEqual([
['start-node', 'answer'],
['tool-a', 'result'],
['start-node', 'answer'],
])
})
it('should forward onChange and merge tracing metadata into node info', () => {
const setRunInputData = vi.fn()
const runResult = createRunTrace()
const { result } = renderHook(() => useSingleRunFormParams({
id: 'loop-node',
payload: createLoopNode(),
runInputData: {},
runResult,
loopRunResult: [runResult],
setRunInputData,
toVarInputs: vi.fn(() => []),
varSelectorsToVarInputs: vi.fn(() => []),
}))
act(() => {
result.current.forms[0].onChange({ retry: true })
})
expect(setRunInputData).toHaveBeenCalledWith({ retry: true })
expect(mockFormatTracing).toHaveBeenCalledWith([runResult], expect.any(Function))
expect(result.current.nodeInfo).toEqual({
id: 'formatted-node',
execution_metadata: expect.objectContaining({
loop_index: 9,
}),
})
})
})

View File

@ -0,0 +1,171 @@
import type { ErrorHandleMode, Var } from '../../types'
import type { Condition, LoopNodeType, LoopVariable } from './types'
import { produce } from 'immer'
import { v4 as uuid4 } from 'uuid'
import { ValueType, VarType } from '../../types'
import { LogicalOperator } from './types'
import { getOperators } from './utils'
export const canUseAsLoopInput = (variable: Var) => {
return [
VarType.array,
VarType.arrayString,
VarType.arrayNumber,
VarType.arrayObject,
VarType.arrayFile,
].includes(variable.type)
}
export const updateErrorHandleMode = (
inputs: LoopNodeType,
mode: ErrorHandleMode,
) => produce(inputs, (draft) => {
draft.error_handle_mode = mode
})
export const addBreakCondition = ({
inputs,
valueSelector,
variable,
isVarFileAttribute,
}: {
inputs: LoopNodeType
valueSelector: string[]
variable: { type: VarType }
isVarFileAttribute: boolean
}) => produce(inputs, (draft) => {
if (!draft.break_conditions)
draft.break_conditions = []
draft.break_conditions.push({
id: uuid4(),
varType: variable.type,
variable_selector: valueSelector,
comparison_operator: getOperators(variable.type, isVarFileAttribute ? { key: valueSelector.slice(-1)[0] } : undefined)[0],
value: variable.type === VarType.boolean ? 'false' : '',
})
})
export const removeBreakCondition = (
inputs: LoopNodeType,
conditionId: string,
) => produce(inputs, (draft) => {
draft.break_conditions = draft.break_conditions?.filter(item => item.id !== conditionId)
})
export const updateBreakCondition = (
inputs: LoopNodeType,
conditionId: string,
condition: Condition,
) => produce(inputs, (draft) => {
const targetCondition = draft.break_conditions?.find(item => item.id === conditionId)
if (targetCondition)
Object.assign(targetCondition, condition)
})
export const toggleConditionOperator = (inputs: LoopNodeType) => produce(inputs, (draft) => {
draft.logical_operator = draft.logical_operator === LogicalOperator.and ? LogicalOperator.or : LogicalOperator.and
})
export const addSubVariableCondition = (
inputs: LoopNodeType,
conditionId: string,
key?: string,
) => produce(inputs, (draft) => {
const condition = draft.break_conditions?.find(item => item.id === conditionId)
if (!condition)
return
if (!condition.sub_variable_condition) {
condition.sub_variable_condition = {
logical_operator: LogicalOperator.and,
conditions: [],
}
}
const comparisonOperators = getOperators(VarType.string, { key: key || '' })
condition.sub_variable_condition.conditions.push({
id: uuid4(),
key: key || '',
varType: VarType.string,
comparison_operator: comparisonOperators[0],
value: '',
})
})
export const removeSubVariableCondition = (
inputs: LoopNodeType,
conditionId: string,
subConditionId: string,
) => produce(inputs, (draft) => {
const condition = draft.break_conditions?.find(item => item.id === conditionId)
if (!condition?.sub_variable_condition)
return
condition.sub_variable_condition.conditions = condition.sub_variable_condition.conditions
.filter(item => item.id !== subConditionId)
})
export const updateSubVariableCondition = (
inputs: LoopNodeType,
conditionId: string,
subConditionId: string,
condition: Condition,
) => produce(inputs, (draft) => {
const targetCondition = draft.break_conditions?.find(item => item.id === conditionId)
const targetSubCondition = targetCondition?.sub_variable_condition?.conditions.find(item => item.id === subConditionId)
if (targetSubCondition)
Object.assign(targetSubCondition, condition)
})
export const toggleSubVariableConditionOperator = (
inputs: LoopNodeType,
conditionId: string,
) => produce(inputs, (draft) => {
const targetCondition = draft.break_conditions?.find(item => item.id === conditionId)
if (targetCondition?.sub_variable_condition) {
targetCondition.sub_variable_condition.logical_operator
= targetCondition.sub_variable_condition.logical_operator === LogicalOperator.and ? LogicalOperator.or : LogicalOperator.and
}
})
export const updateLoopCount = (
inputs: LoopNodeType,
value: number,
) => produce(inputs, (draft) => {
draft.loop_count = value
})
export const addLoopVariable = (inputs: LoopNodeType) => produce(inputs, (draft) => {
if (!draft.loop_variables)
draft.loop_variables = []
draft.loop_variables.push({
id: uuid4(),
label: '',
var_type: VarType.string,
value_type: ValueType.constant,
value: '',
})
})
export const removeLoopVariable = (
inputs: LoopNodeType,
id: string,
) => produce(inputs, (draft) => {
draft.loop_variables = draft.loop_variables?.filter(item => item.id !== id)
})
export const updateLoopVariable = (
inputs: LoopNodeType,
id: string,
updateData: Partial<LoopVariable>,
) => produce(inputs, (draft) => {
const index = draft.loop_variables?.findIndex(item => item.id === id) ?? -1
if (index > -1) {
draft.loop_variables![index] = {
...draft.loop_variables![index],
...updateData,
}
}
})

View File

@ -9,12 +9,10 @@ import type {
HandleUpdateSubVariableCondition,
LoopNodeType,
} from './types'
import { produce } from 'immer'
import {
useCallback,
useRef,
} from 'react'
import { v4 as uuid4 } from 'uuid'
import { useStore } from '@/app/components/workflow/store'
import {
useAllBuiltInTools,
@ -27,12 +25,25 @@ import {
useNodesReadOnly,
useWorkflow,
} from '../../hooks'
import { ValueType, VarType } from '../../types'
import { toNodeOutputVars } from '../_base/components/variable/utils'
import useNodeCrud from '../_base/hooks/use-node-crud'
import { LogicalOperator } from './types'
import {
addBreakCondition,
addLoopVariable,
addSubVariableCondition,
canUseAsLoopInput,
removeBreakCondition,
removeLoopVariable,
removeSubVariableCondition,
toggleConditionOperator,
toggleSubVariableConditionOperator,
updateBreakCondition,
updateErrorHandleMode,
updateLoopCount,
updateLoopVariable,
updateSubVariableCondition,
} from './use-config.helpers'
import useIsVarFileAttribute from './use-is-var-file-attribute'
import { getOperators } from './utils'
const useConfig = (id: string, payload: LoopNodeType) => {
const { nodesReadOnly: readOnly } = useNodesReadOnly()
@ -46,9 +57,7 @@ const useConfig = (id: string, payload: LoopNodeType) => {
setInputs(newInputs)
}, [setInputs])
const filterInputVar = useCallback((varPayload: Var) => {
return [VarType.array, VarType.arrayString, VarType.arrayNumber, VarType.arrayObject, VarType.arrayFile].includes(varPayload.type)
}, [])
const filterInputVar = useCallback((varPayload: Var) => canUseAsLoopInput(varPayload), [])
// output
const { getLoopNodeChildren } = useWorkflow()
@ -74,158 +83,60 @@ const useConfig = (id: string, payload: LoopNodeType) => {
})
const changeErrorResponseMode = useCallback((item: { value: unknown }) => {
const newInputs = produce(inputsRef.current, (draft) => {
draft.error_handle_mode = item.value as ErrorHandleMode
})
handleInputsChange(newInputs)
}, [inputs, handleInputsChange])
handleInputsChange(updateErrorHandleMode(inputsRef.current, item.value as ErrorHandleMode))
}, [handleInputsChange])
const handleAddCondition = useCallback<HandleAddCondition>((valueSelector, varItem) => {
const newInputs = produce(inputsRef.current, (draft) => {
if (!draft.break_conditions)
draft.break_conditions = []
draft.break_conditions?.push({
id: uuid4(),
varType: varItem.type,
variable_selector: valueSelector,
comparison_operator: getOperators(varItem.type, getIsVarFileAttribute(valueSelector) ? { key: valueSelector.slice(-1)[0] } : undefined)[0],
value: varItem.type === VarType.boolean ? 'false' : '',
})
})
handleInputsChange(newInputs)
handleInputsChange(addBreakCondition({
inputs: inputsRef.current,
valueSelector,
variable: varItem,
isVarFileAttribute: !!getIsVarFileAttribute(valueSelector),
}))
}, [getIsVarFileAttribute, handleInputsChange])
const handleRemoveCondition = useCallback<HandleRemoveCondition>((conditionId) => {
const newInputs = produce(inputsRef.current, (draft) => {
draft.break_conditions = draft.break_conditions?.filter(item => item.id !== conditionId)
})
handleInputsChange(newInputs)
handleInputsChange(removeBreakCondition(inputsRef.current, conditionId))
}, [handleInputsChange])
const handleUpdateCondition = useCallback<HandleUpdateCondition>((conditionId, newCondition) => {
const newInputs = produce(inputsRef.current, (draft) => {
const targetCondition = draft.break_conditions?.find(item => item.id === conditionId)
if (targetCondition)
Object.assign(targetCondition, newCondition)
})
handleInputsChange(newInputs)
handleInputsChange(updateBreakCondition(inputsRef.current, conditionId, newCondition))
}, [handleInputsChange])
const handleToggleConditionLogicalOperator = useCallback<HandleToggleConditionLogicalOperator>(() => {
const newInputs = produce(inputsRef.current, (draft) => {
draft.logical_operator = draft.logical_operator === LogicalOperator.and ? LogicalOperator.or : LogicalOperator.and
})
handleInputsChange(newInputs)
handleInputsChange(toggleConditionOperator(inputsRef.current))
}, [handleInputsChange])
const handleAddSubVariableCondition = useCallback<HandleAddSubVariableCondition>((conditionId: string, key?: string) => {
const newInputs = produce(inputsRef.current, (draft) => {
const condition = draft.break_conditions?.find(item => item.id === conditionId)
if (!condition)
return
if (!condition?.sub_variable_condition) {
condition.sub_variable_condition = {
logical_operator: LogicalOperator.and,
conditions: [],
}
}
const subVarCondition = condition.sub_variable_condition
if (subVarCondition) {
if (!subVarCondition.conditions)
subVarCondition.conditions = []
const svcComparisonOperators = getOperators(VarType.string, { key: key || '' })
subVarCondition.conditions.push({
id: uuid4(),
key: key || '',
varType: VarType.string,
comparison_operator: (svcComparisonOperators && svcComparisonOperators.length) ? svcComparisonOperators[0] : undefined,
value: '',
})
}
})
handleInputsChange(newInputs)
handleInputsChange(addSubVariableCondition(inputsRef.current, conditionId, key))
}, [handleInputsChange])
const handleRemoveSubVariableCondition = useCallback((conditionId: string, subConditionId: string) => {
const newInputs = produce(inputsRef.current, (draft) => {
const condition = draft.break_conditions?.find(item => item.id === conditionId)
if (!condition)
return
if (!condition?.sub_variable_condition)
return
const subVarCondition = condition.sub_variable_condition
if (subVarCondition)
subVarCondition.conditions = subVarCondition.conditions.filter(item => item.id !== subConditionId)
})
handleInputsChange(newInputs)
handleInputsChange(removeSubVariableCondition(inputsRef.current, conditionId, subConditionId))
}, [handleInputsChange])
const handleUpdateSubVariableCondition = useCallback<HandleUpdateSubVariableCondition>((conditionId, subConditionId, newSubCondition) => {
const newInputs = produce(inputsRef.current, (draft) => {
const targetCondition = draft.break_conditions?.find(item => item.id === conditionId)
if (targetCondition && targetCondition.sub_variable_condition) {
const targetSubCondition = targetCondition.sub_variable_condition.conditions.find(item => item.id === subConditionId)
if (targetSubCondition)
Object.assign(targetSubCondition, newSubCondition)
}
})
handleInputsChange(newInputs)
handleInputsChange(updateSubVariableCondition(inputsRef.current, conditionId, subConditionId, newSubCondition))
}, [handleInputsChange])
const handleToggleSubVariableConditionLogicalOperator = useCallback<HandleToggleSubVariableConditionLogicalOperator>((conditionId) => {
const newInputs = produce(inputsRef.current, (draft) => {
const targetCondition = draft.break_conditions?.find(item => item.id === conditionId)
if (targetCondition && targetCondition.sub_variable_condition)
targetCondition.sub_variable_condition.logical_operator = targetCondition.sub_variable_condition.logical_operator === LogicalOperator.and ? LogicalOperator.or : LogicalOperator.and
})
handleInputsChange(newInputs)
handleInputsChange(toggleSubVariableConditionOperator(inputsRef.current, conditionId))
}, [handleInputsChange])
const handleUpdateLoopCount = useCallback((value: number) => {
const newInputs = produce(inputsRef.current, (draft) => {
draft.loop_count = value
})
handleInputsChange(newInputs)
handleInputsChange(updateLoopCount(inputsRef.current, value))
}, [handleInputsChange])
const handleAddLoopVariable = useCallback(() => {
const newInputs = produce(inputsRef.current, (draft) => {
if (!draft.loop_variables)
draft.loop_variables = []
draft.loop_variables.push({
id: uuid4(),
label: '',
var_type: VarType.string,
value_type: ValueType.constant,
value: '',
})
})
handleInputsChange(newInputs)
handleInputsChange(addLoopVariable(inputsRef.current))
}, [handleInputsChange])
const handleRemoveLoopVariable = useCallback((id: string) => {
const newInputs = produce(inputsRef.current, (draft) => {
draft.loop_variables = draft.loop_variables?.filter(item => item.id !== id)
})
handleInputsChange(newInputs)
handleInputsChange(removeLoopVariable(inputsRef.current, id))
}, [handleInputsChange])
const handleUpdateLoopVariable = useCallback((id: string, updateData: any) => {
const loopVariables = inputsRef.current.loop_variables || []
const index = loopVariables.findIndex(item => item.id === id)
const newInputs = produce(inputsRef.current, (draft) => {
if (index > -1) {
draft.loop_variables![index] = {
...draft.loop_variables![index],
...updateData,
}
}
})
handleInputsChange(newInputs)
handleInputsChange(updateLoopVariable(inputsRef.current, id, updateData))
}, [handleInputsChange])
return {

View File

@ -0,0 +1,109 @@
import type {
BlockEnum,
Node,
} from '../../types'
import {
LOOP_CHILDREN_Z_INDEX,
LOOP_PADDING,
} from '../../constants'
import { CUSTOM_LOOP_START_NODE } from '../loop-start/constants'
type ContainerBounds = {
rightNode?: Node
bottomNode?: Node
}
export const getContainerBounds = (childrenNodes: Node[]): ContainerBounds => {
return childrenNodes.reduce<ContainerBounds>((acc, node) => {
const nextRightNode = !acc.rightNode || node.position.x + node.width! > acc.rightNode.position.x + acc.rightNode.width!
? node
: acc.rightNode
const nextBottomNode = !acc.bottomNode || node.position.y + node.height! > acc.bottomNode.position.y + acc.bottomNode.height!
? node
: acc.bottomNode
return {
rightNode: nextRightNode,
bottomNode: nextBottomNode,
}
}, {})
}
export const getContainerResize = (currentNode: Node, bounds: ContainerBounds) => {
const width = bounds.rightNode && currentNode.width! < bounds.rightNode.position.x + bounds.rightNode.width!
? bounds.rightNode.position.x + bounds.rightNode.width! + LOOP_PADDING.right
: undefined
const height = bounds.bottomNode && currentNode.height! < bounds.bottomNode.position.y + bounds.bottomNode.height!
? bounds.bottomNode.position.y + bounds.bottomNode.height! + LOOP_PADDING.bottom
: undefined
return {
width,
height,
}
}
export const getRestrictedLoopPosition = (node: Node, parentNode?: Node) => {
const restrictPosition: { x?: number, y?: number } = { x: undefined, y: undefined }
if (!node.data.isInLoop || !parentNode)
return restrictPosition
if (node.position.y < LOOP_PADDING.top)
restrictPosition.y = LOOP_PADDING.top
if (node.position.x < LOOP_PADDING.left)
restrictPosition.x = LOOP_PADDING.left
if (node.position.x + node.width! > parentNode.width! - LOOP_PADDING.right)
restrictPosition.x = parentNode.width! - LOOP_PADDING.right - node.width!
if (node.position.y + node.height! > parentNode.height! - LOOP_PADDING.bottom)
restrictPosition.y = parentNode.height! - LOOP_PADDING.bottom - node.height!
return restrictPosition
}
export const getLoopChildren = (nodes: Node[], nodeId: string) => {
return nodes.filter(node => node.parentId === nodeId && node.type !== CUSTOM_LOOP_START_NODE)
}
export const buildLoopChildCopy = ({
child,
childNodeType,
defaultValue,
nodesWithSameTypeCount,
newNodeId,
index,
}: {
child: Node
childNodeType: BlockEnum
defaultValue: Node['data']
nodesWithSameTypeCount: number
newNodeId: string
index: number
}) => {
const params = {
type: child.type!,
data: {
...defaultValue,
...child.data,
selected: false,
_isBundled: false,
_connectedSourceHandleIds: [],
_connectedTargetHandleIds: [],
_dimmed: false,
title: nodesWithSameTypeCount > 0 ? `${defaultValue.title} ${nodesWithSameTypeCount + 1}` : defaultValue.title,
isInLoop: true,
loop_id: newNodeId,
type: childNodeType,
},
position: child.position,
positionAbsolute: child.positionAbsolute,
parentId: newNodeId,
extent: child.extent,
zIndex: LOOP_CHILDREN_Z_INDEX,
}
return {
params,
newId: `${newNodeId}${index}`,
}
}

View File

@ -6,15 +6,17 @@ import { produce } from 'immer'
import { useCallback } from 'react'
import { useStoreApi } from 'reactflow'
import { useNodesMetaData } from '@/app/components/workflow/hooks'
import {
LOOP_CHILDREN_Z_INDEX,
LOOP_PADDING,
} from '../../constants'
import {
generateNewNode,
getNodeCustomTypeByNodeDataType,
} from '../../utils'
import { CUSTOM_LOOP_START_NODE } from '../loop-start/constants'
import {
buildLoopChildCopy,
getContainerBounds,
getContainerResize,
getLoopChildren,
getRestrictedLoopPosition,
} from './use-interactions.helpers'
export const useNodeLoopInteractions = () => {
const store = useStoreApi()
@ -29,40 +31,19 @@ export const useNodeLoopInteractions = () => {
const nodes = getNodes()
const currentNode = nodes.find(n => n.id === nodeId)!
const childrenNodes = nodes.filter(n => n.parentId === nodeId)
let rightNode: Node
let bottomNode: Node
const resize = getContainerResize(currentNode, getContainerBounds(childrenNodes))
childrenNodes.forEach((n) => {
if (rightNode) {
if (n.position.x + n.width! > rightNode.position.x + rightNode.width!)
rightNode = n
}
else {
rightNode = n
}
if (bottomNode) {
if (n.position.y + n.height! > bottomNode.position.y + bottomNode.height!)
bottomNode = n
}
else {
bottomNode = n
}
})
const widthShouldExtend = rightNode! && currentNode.width! < rightNode.position.x + rightNode.width!
const heightShouldExtend = bottomNode! && currentNode.height! < bottomNode.position.y + bottomNode.height!
if (widthShouldExtend || heightShouldExtend) {
if (resize.width || resize.height) {
const newNodes = produce(nodes, (draft) => {
draft.forEach((n) => {
if (n.id === nodeId) {
if (widthShouldExtend) {
n.data.width = rightNode.position.x + rightNode.width! + LOOP_PADDING.right
n.width = rightNode.position.x + rightNode.width! + LOOP_PADDING.right
if (resize.width) {
n.data.width = resize.width
n.width = resize.width
}
if (heightShouldExtend) {
n.data.height = bottomNode.position.y + bottomNode.height! + LOOP_PADDING.bottom
n.height = bottomNode.position.y + bottomNode.height! + LOOP_PADDING.bottom
if (resize.height) {
n.data.height = resize.height
n.height = resize.height
}
}
})
@ -76,25 +57,8 @@ export const useNodeLoopInteractions = () => {
const { getNodes } = store.getState()
const nodes = getNodes()
const restrictPosition: { x?: number, y?: number } = { x: undefined, y: undefined }
if (node.data.isInLoop) {
const parentNode = nodes.find(n => n.id === node.parentId)
if (parentNode) {
if (node.position.y < LOOP_PADDING.top)
restrictPosition.y = LOOP_PADDING.top
if (node.position.x < LOOP_PADDING.left)
restrictPosition.x = LOOP_PADDING.left
if (node.position.x + node.width! > parentNode!.width! - LOOP_PADDING.right)
restrictPosition.x = parentNode!.width! - LOOP_PADDING.right - node.width!
if (node.position.y + node.height! > parentNode!.height! - LOOP_PADDING.bottom)
restrictPosition.y = parentNode!.height! - LOOP_PADDING.bottom - node.height!
}
}
return {
restrictPosition,
restrictPosition: getRestrictedLoopPosition(node, nodes.find(n => n.id === node.parentId)),
}
}, [store])
@ -111,35 +75,26 @@ export const useNodeLoopInteractions = () => {
const handleNodeLoopChildrenCopy = useCallback((nodeId: string, newNodeId: string, idMapping: Record<string, string>) => {
const { getNodes } = store.getState()
const nodes = getNodes()
const childrenNodes = nodes.filter(n => n.parentId === nodeId && n.type !== CUSTOM_LOOP_START_NODE)
const childrenNodes = getLoopChildren(nodes, nodeId)
const newIdMapping = { ...idMapping }
const copyChildren = childrenNodes.map((child, index) => {
const childNodeType = child.data.type as BlockEnum
const { defaultValue } = nodesMetaDataMap![childNodeType]
const nodesWithSameType = nodes.filter(node => node.data.type === childNodeType)
const { newNode } = generateNewNode({
type: getNodeCustomTypeByNodeDataType(childNodeType),
data: {
...defaultValue,
...child.data,
selected: false,
_isBundled: false,
_connectedSourceHandleIds: [],
_connectedTargetHandleIds: [],
_dimmed: false,
title: nodesWithSameType.length > 0 ? `${defaultValue.title} ${nodesWithSameType.length + 1}` : defaultValue.title,
isInLoop: true,
loop_id: newNodeId,
type: childNodeType,
},
position: child.position,
positionAbsolute: child.positionAbsolute,
parentId: newNodeId,
extent: child.extent,
zIndex: LOOP_CHILDREN_Z_INDEX,
const childCopy = buildLoopChildCopy({
child,
childNodeType,
defaultValue: defaultValue as Node['data'],
nodesWithSameTypeCount: nodesWithSameType.length,
newNodeId,
index,
})
newNode.id = `${newNodeId}${newNode.id + index}`
const { newNode } = generateNewNode({
...childCopy.params,
type: getNodeCustomTypeByNodeDataType(childNodeType),
})
newNode.id = `${newNodeId}${newNode.id + childCopy.newId}`
newIdMapping[child.id] = newNode.id
return newNode
})

View File

@ -0,0 +1,131 @@
import type { InputVar, Node, ValueSelector, Variable } from '../../types'
import type { CaseItem, Condition, LoopVariable } from './types'
import { ValueType } from '@/app/components/workflow/types'
import { VALUE_SELECTOR_DELIMITER as DELIMITER } from '@/config'
import { getNodeInfoById, getNodeUsedVarPassToServerKey, getNodeUsedVars, isSystemVar } from '../_base/components/variable/utils'
export function getVarSelectorsFromCase(caseItem: CaseItem): ValueSelector[] {
const vars: ValueSelector[] = []
caseItem.conditions?.forEach((condition) => {
vars.push(...getVarSelectorsFromCondition(condition))
})
return vars
}
export function getVarSelectorsFromCondition(condition: Condition): ValueSelector[] {
const vars: ValueSelector[] = []
if (condition.variable_selector)
vars.push(condition.variable_selector)
if (condition.sub_variable_condition?.conditions?.length)
vars.push(...getVarSelectorsFromCase(condition.sub_variable_condition))
return vars
}
export const createInputVarValues = (runInputData: Record<string, unknown>) => {
const vars: Record<string, unknown> = {}
Object.keys(runInputData).forEach((key) => {
vars[key] = runInputData[key]
})
return vars
}
export const dedupeInputVars = (inputVars: InputVar[]) => {
const seen: Record<string, boolean> = {}
const uniqueInputVars: InputVar[] = []
inputVars.forEach((input) => {
if (!input || seen[input.variable])
return
seen[input.variable] = true
uniqueInputVars.push(input)
})
return uniqueInputVars
}
export const buildUsedOutVars = ({
loopChildrenNodes,
currentNodeId,
canChooseVarNodes,
isNodeInLoop,
toVarInputs,
}: {
loopChildrenNodes: Node[]
currentNodeId: string
canChooseVarNodes: Node[]
isNodeInLoop: (nodeId: string) => boolean
toVarInputs: (variables: Variable[]) => InputVar[]
}) => {
const vars: ValueSelector[] = []
const seenVarSelectors: Record<string, boolean> = {}
const allVarObject: Record<string, { inSingleRunPassedKey: string }> = {}
loopChildrenNodes.forEach((node) => {
const nodeVars = getNodeUsedVars(node).filter(item => item && item.length > 0)
nodeVars.forEach((varSelector) => {
if (varSelector[0] === currentNodeId)
return
if (isNodeInLoop(varSelector[0]))
return
const varSelectorStr = varSelector.join('.')
if (!seenVarSelectors[varSelectorStr]) {
seenVarSelectors[varSelectorStr] = true
vars.push(varSelector)
}
let passToServerKeys = getNodeUsedVarPassToServerKey(node, varSelector)
if (typeof passToServerKeys === 'string')
passToServerKeys = [passToServerKeys]
passToServerKeys.forEach((key: string, index: number) => {
allVarObject[[varSelectorStr, node.id, index].join(DELIMITER)] = {
inSingleRunPassedKey: key,
}
})
})
})
const usedOutVars = toVarInputs(vars.map((valueSelector) => {
const varInfo = getNodeInfoById(canChooseVarNodes, valueSelector[0])
return {
label: {
nodeType: varInfo?.data.type,
nodeName: varInfo?.data.title || canChooseVarNodes[0]?.data.title,
variable: isSystemVar(valueSelector) ? valueSelector.join('.') : valueSelector[valueSelector.length - 1],
},
variable: valueSelector.join('.'),
value_selector: valueSelector,
}
}))
return { usedOutVars, allVarObject }
}
export const getDependentVarsFromLoopPayload = ({
nodeId,
usedOutVars,
breakConditions,
loopVariables,
}: {
nodeId: string
usedOutVars: InputVar[]
breakConditions?: Condition[]
loopVariables?: LoopVariable[]
}) => {
const vars: ValueSelector[] = usedOutVars.map(item => item.variable.split('.'))
breakConditions?.forEach((condition) => {
vars.push(...getVarSelectorsFromCondition(condition))
})
loopVariables?.forEach((loopVariable) => {
if (loopVariable.value_type === ValueType.variable)
vars.push(loopVariable.value)
})
return vars.filter(item => item[0] !== nodeId)
}

View File

@ -1,13 +1,18 @@
import type { InputVar, ValueSelector, Variable } from '../../types'
import type { CaseItem, Condition, LoopNodeType } from './types'
import type { LoopNodeType } from './types'
import type { NodeTracing } from '@/types/workflow'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import formatTracing from '@/app/components/workflow/run/utils/format-log'
import { ValueType } from '@/app/components/workflow/types'
import { VALUE_SELECTOR_DELIMITER as DELIMITER } from '@/config'
import { useIsNodeInLoop, useWorkflow } from '../../hooks'
import { getNodeInfoById, getNodeUsedVarPassToServerKey, getNodeUsedVars, isSystemVar } from '../_base/components/variable/utils'
import {
buildUsedOutVars,
createInputVarValues,
dedupeInputVars,
getDependentVarsFromLoopPayload,
getVarSelectorsFromCondition,
} from './use-single-run-form-params.helpers'
type Params = {
id: string
@ -37,58 +42,15 @@ const useSingleRunFormParams = ({
const { getLoopNodeChildren, getBeforeNodesInSameBranch } = useWorkflow()
const loopChildrenNodes = getLoopNodeChildren(id)
const beforeNodes = getBeforeNodesInSameBranch(id)
const canChooseVarNodes = [...beforeNodes, ...loopChildrenNodes]
const canChooseVarNodes = useMemo(() => [...beforeNodes, ...loopChildrenNodes], [beforeNodes, loopChildrenNodes])
const { usedOutVars, allVarObject } = (() => {
const vars: ValueSelector[] = []
const varObjs: Record<string, boolean> = {}
const allVarObject: Record<string, {
inSingleRunPassedKey: string
}> = {}
loopChildrenNodes.forEach((node) => {
const nodeVars = getNodeUsedVars(node).filter(item => item && item.length > 0)
nodeVars.forEach((varSelector) => {
if (varSelector[0] === id) { // skip loop node itself variable: item, index
return
}
const isInLoop = isNodeInLoop(varSelector[0])
if (isInLoop) // not pass loop inner variable
return
const varSectorStr = varSelector.join('.')
if (!varObjs[varSectorStr]) {
varObjs[varSectorStr] = true
vars.push(varSelector)
}
let passToServerKeys = getNodeUsedVarPassToServerKey(node, varSelector)
if (typeof passToServerKeys === 'string')
passToServerKeys = [passToServerKeys]
passToServerKeys.forEach((key: string, index: number) => {
allVarObject[[varSectorStr, node.id, index].join(DELIMITER)] = {
inSingleRunPassedKey: key,
}
})
})
})
const res = toVarInputs(vars.map((item) => {
const varInfo = getNodeInfoById(canChooseVarNodes, item[0])
return {
label: {
nodeType: varInfo?.data.type,
nodeName: varInfo?.data.title || canChooseVarNodes[0]?.data.title, // default start node title
variable: isSystemVar(item) ? item.join('.') : item[item.length - 1],
},
variable: `${item.join('.')}`,
value_selector: item,
}
}))
return {
usedOutVars: res,
allVarObject,
}
})()
const { usedOutVars, allVarObject } = useMemo(() => buildUsedOutVars({
loopChildrenNodes,
currentNodeId: id,
canChooseVarNodes,
isNodeInLoop,
toVarInputs,
}), [loopChildrenNodes, id, canChooseVarNodes, isNodeInLoop, toVarInputs])
const nodeInfo = useMemo(() => {
const formattedNodeInfo = formatTracing(loopRunResult, t)[0]
@ -110,38 +72,9 @@ const useSingleRunFormParams = ({
setRunInputData(newPayload)
}, [setRunInputData])
const inputVarValues = (() => {
const vars: Record<string, any> = {}
Object.keys(runInputData)
.forEach((key) => {
vars[key] = runInputData[key]
})
return vars
})()
const inputVarValues = useMemo(() => createInputVarValues(runInputData), [runInputData])
const getVarSelectorsFromCase = (caseItem: CaseItem): ValueSelector[] => {
const vars: ValueSelector[] = []
if (caseItem.conditions && caseItem.conditions.length) {
caseItem.conditions.forEach((condition) => {
// eslint-disable-next-line ts/no-use-before-define
const conditionVars = getVarSelectorsFromCondition(condition)
vars.push(...conditionVars)
})
}
return vars
}
const getVarSelectorsFromCondition = (condition: Condition) => {
const vars: ValueSelector[] = []
if (condition.variable_selector)
vars.push(condition.variable_selector)
if (condition.sub_variable_condition && condition.sub_variable_condition.conditions?.length)
vars.push(...getVarSelectorsFromCase(condition.sub_variable_condition))
return vars
}
const forms = (() => {
const forms = useMemo(() => {
const allInputs: ValueSelector[] = []
payload.break_conditions?.forEach((condition) => {
const vars = getVarSelectorsFromCondition(condition)
@ -154,16 +87,7 @@ const useSingleRunFormParams = ({
})
const inputVarsFromValue: InputVar[] = []
const varInputs = [...varSelectorsToVarInputs(allInputs), ...inputVarsFromValue]
const existVarsKey: Record<string, boolean> = {}
const uniqueVarInputs: InputVar[] = []
varInputs.forEach((input) => {
if (!input)
return
if (!existVarsKey[input.variable]) {
existVarsKey[input.variable] = true
uniqueVarInputs.push(input)
}
})
const uniqueVarInputs = dedupeInputVars(varInputs)
return [
{
inputs: [...usedOutVars, ...uniqueVarInputs],
@ -171,43 +95,14 @@ const useSingleRunFormParams = ({
onChange: setInputVarValues,
},
]
})()
}, [payload.break_conditions, payload.loop_variables, varSelectorsToVarInputs, usedOutVars, inputVarValues, setInputVarValues])
const getVarFromCaseItem = (caseItem: CaseItem): ValueSelector[] => {
const vars: ValueSelector[] = []
if (caseItem.conditions && caseItem.conditions.length) {
caseItem.conditions.forEach((condition) => {
// eslint-disable-next-line ts/no-use-before-define
const conditionVars = getVarFromCondition(condition)
vars.push(...conditionVars)
})
}
return vars
}
const getVarFromCondition = (condition: Condition): ValueSelector[] => {
const vars: ValueSelector[] = []
if (condition.variable_selector)
vars.push(condition.variable_selector)
if (condition.sub_variable_condition && condition.sub_variable_condition.conditions?.length)
vars.push(...getVarFromCaseItem(condition.sub_variable_condition))
return vars
}
const getDependentVars = () => {
const vars: ValueSelector[] = usedOutVars.map(item => item.variable.split('.'))
payload.break_conditions?.forEach((condition) => {
const conditionVars = getVarFromCondition(condition)
vars.push(...conditionVars)
})
payload.loop_variables?.forEach((loopVariable) => {
if (loopVariable.value_type === ValueType.variable)
vars.push(loopVariable.value)
})
const hasFilterLoopVars = vars.filter(item => item[0] !== id)
return hasFilterLoopVars
}
const getDependentVars = useCallback(() => getDependentVarsFromLoopPayload({
nodeId: id,
usedOutVars,
breakConditions: payload.break_conditions,
loopVariables: payload.loop_variables,
}), [id, usedOutVars, payload.break_conditions, payload.loop_variables])
return {
forms,

View File

@ -75,6 +75,8 @@ const Panel: FC<NodePanelProps<ParameterExtractorNodeType>> = ({
hideDebugWithMultipleModel
debugWithMultipleModel={false}
readonly={readOnly}
nodesOutputVars={availableVars}
availableNodes={availableNodesWithParent}
/>
</Field>
<Field

View File

@ -64,6 +64,8 @@ const Panel: FC<NodePanelProps<QuestionClassifierNodeType>> = ({
hideDebugWithMultipleModel
debugWithMultipleModel={false}
readonly={readOnly}
nodesOutputVars={availableVars}
availableNodes={availableNodesWithParent}
/>
</Field>
<Field

View File

@ -0,0 +1,196 @@
import type { WebhookTriggerNodeType } from '../types'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import {
syncVariables,
updateContentType,
updateMethod,
updateSimpleField,
updateSourceFields,
updateWebhookUrls,
} from '../use-config.helpers'
import { WEBHOOK_RAW_VARIABLE_NAME } from '../utils/raw-variable'
const createInputs = (): WebhookTriggerNodeType => ({
title: 'Webhook',
desc: '',
type: BlockEnum.TriggerWebhook,
method: 'POST',
content_type: 'application/json',
headers: [],
params: [],
body: [],
async_mode: false,
status_code: 200,
response_body: '',
variables: [
{ variable: 'existing_header', label: 'header', required: false, value_selector: [], value_type: VarType.string },
{ variable: 'body_value', label: 'body', required: true, value_selector: [], value_type: VarType.string },
],
} as unknown as WebhookTriggerNodeType)
describe('trigger webhook config helpers', () => {
it('syncs variables, updates existing ones and validates names', () => {
const notifyError = vi.fn()
const isVarUsedInNodes = vi.fn(([_, variable]) => variable === 'old_param')
const removeUsedVarInNodes = vi.fn()
const draft = {
...createInputs(),
variables: [
{ variable: 'old_param', label: 'param', required: true, value_selector: [], value_type: VarType.number },
{ variable: 'existing_header', label: 'header', required: false, value_selector: [], value_type: VarType.string },
],
}
expect(syncVariables({
draft,
id: 'node-1',
newData: [{ name: 'existing_header', type: VarType.string, required: true }],
sourceType: 'header',
notifyError,
isVarUsedInNodes,
removeUsedVarInNodes,
})).toBe(true)
expect(draft.variables).toContainEqual(expect.objectContaining({
variable: 'existing_header',
label: 'header',
required: true,
}))
expect(syncVariables({
draft,
id: 'node-1',
newData: [{ name: '1invalid', type: VarType.string, required: true }],
sourceType: 'param',
notifyError,
isVarUsedInNodes,
removeUsedVarInNodes,
})).toBe(false)
expect(notifyError).toHaveBeenCalledWith('varKeyError.notStartWithNumber')
expect(syncVariables({
draft: createInputs(),
id: 'node-1',
newData: [
{ name: 'x-request-id', type: VarType.string, required: true },
{ name: 'x-request-id', type: VarType.string, required: false },
],
sourceType: 'header',
notifyError,
isVarUsedInNodes,
removeUsedVarInNodes,
})).toBe(false)
expect(notifyError).toHaveBeenCalledWith('variableConfig.varName')
expect(syncVariables({
draft: {
...createInputs(),
variables: undefined,
} as unknown as WebhookTriggerNodeType,
id: 'node-1',
newData: [{ name: WEBHOOK_RAW_VARIABLE_NAME, type: VarType.string, required: true }],
sourceType: 'body',
notifyError,
isVarUsedInNodes,
removeUsedVarInNodes,
})).toBe(false)
expect(notifyError).toHaveBeenCalledWith('variableConfig.varName')
expect(syncVariables({
draft: createInputs(),
id: 'node-1',
newData: [{ name: 'existing_header', type: VarType.string, required: true }],
sourceType: 'param',
notifyError,
isVarUsedInNodes,
removeUsedVarInNodes,
})).toBe(false)
expect(notifyError).toHaveBeenCalledWith('existing_header')
const removableDraft = {
...createInputs(),
variables: [
{ variable: 'old_param', label: 'param', required: true, value_selector: [], value_type: VarType.number },
],
}
expect(syncVariables({
draft: removableDraft,
id: 'node-1',
newData: [],
sourceType: 'param',
notifyError,
isVarUsedInNodes,
removeUsedVarInNodes,
})).toBe(true)
expect(removeUsedVarInNodes).toHaveBeenCalledWith(['node-1', 'old_param'])
})
it('updates content, source fields and webhook urls', () => {
const removeUsedVarInNodes = vi.fn()
const nextContentType = updateContentType({
inputs: createInputs(),
id: 'node-1',
contentType: 'text/plain',
isVarUsedInNodes: () => true,
removeUsedVarInNodes,
})
expect(nextContentType.body).toEqual([])
expect(nextContentType.variables.every(item => item.label !== 'body')).toBe(true)
expect(removeUsedVarInNodes).toHaveBeenCalledWith(['node-1', 'body_value'])
expect(updateContentType({
inputs: createInputs(),
id: 'node-1',
contentType: 'application/json',
isVarUsedInNodes: () => false,
removeUsedVarInNodes,
}).body).toEqual([])
expect(updateContentType({
inputs: {
...createInputs(),
variables: undefined,
} as unknown as WebhookTriggerNodeType,
id: 'node-1',
contentType: 'multipart/form-data',
isVarUsedInNodes: () => false,
removeUsedVarInNodes,
}).body).toEqual([])
expect(updateSourceFields({
inputs: createInputs(),
id: 'node-1',
sourceType: 'param',
nextData: [{ name: 'page', type: VarType.number, required: true }],
notifyError: vi.fn(),
isVarUsedInNodes: () => false,
removeUsedVarInNodes: vi.fn(),
}).params).toEqual([{ name: 'page', type: VarType.number, required: true }])
expect(updateSourceFields({
inputs: createInputs(),
id: 'node-1',
sourceType: 'body',
nextData: [{ name: 'payload', type: VarType.string, required: true }],
notifyError: vi.fn(),
isVarUsedInNodes: () => false,
removeUsedVarInNodes: vi.fn(),
}).body).toEqual([{ name: 'payload', type: VarType.string, required: true }])
expect(updateSourceFields({
inputs: createInputs(),
id: 'node-1',
sourceType: 'header',
nextData: [{ name: 'x-request-id', required: true }],
notifyError: vi.fn(),
isVarUsedInNodes: () => false,
removeUsedVarInNodes: vi.fn(),
}).headers).toEqual([{ name: 'x-request-id', required: true }])
expect(updateMethod(createInputs(), 'GET').method).toBe('GET')
expect(updateSimpleField(createInputs(), 'status_code', 204).status_code).toBe(204)
expect(updateWebhookUrls(createInputs(), 'https://hook', 'https://debug')).toEqual(expect.objectContaining({
webhook_url: 'https://hook',
webhook_debug_url: 'https://debug',
}))
})
})

Some files were not shown because too many files have changed in this diff Show More