mirror of
https://github.com/langgenius/dify.git
synced 2026-05-09 12:59:18 +08:00
Merge remote-tracking branch 'origin/main' into test/workflow-part-8
This commit is contained in:
commit
16e8bf1cf9
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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 ""
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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} />)
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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', () => ({
|
||||
|
||||
@ -1,2 +1,2 @@
|
||||
export { default, isAmplitudeEnabled } from './AmplitudeProvider'
|
||||
export { default } from './lazy-amplitude-provider'
|
||||
export { resetUser, setUserId, setUserProperties, trackEvent } from './utils'
|
||||
|
||||
@ -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
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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')
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
13
web/app/components/devtools/agentation-loader.tsx
Normal file
13
web/app/components/devtools/agentation-loader.tsx
Normal 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 />
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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()
|
||||
|
||||
16
web/app/components/lazy-sentry-initializer.tsx
Normal file
16
web/app/components/lazy-sentry-initializer.tsx
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
118
web/app/components/workflow/header/test-run-menu-helpers.tsx
Normal file
118
web/app/components/workflow/header/test-run-menu-helpers.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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())
|
||||
|
||||
|
||||
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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 })
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
})
|
||||
})
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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'
|
||||
|
||||
@ -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),
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
71
web/app/components/workflow/hooks/use-workflow-organize.ts
Normal file
71
web/app/components/workflow/hooks/use-workflow-organize.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
37
web/app/components/workflow/hooks/use-workflow-update.ts
Normal file
37
web/app/components/workflow/hooks/use-workflow-update.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
31
web/app/components/workflow/hooks/use-workflow-zoom.ts
Normal file
31
web/app/components/workflow/hooks/use-workflow-zoom.ts
Normal 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]),
|
||||
}
|
||||
}
|
||||
@ -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 })
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}),
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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]
|
||||
})
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
|
||||
171
web/app/components/workflow/nodes/http/components/curl-parser.ts
Normal file
171
web/app/components/workflow/nodes/http/components/curl-parser.ts
Normal 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 }
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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>}
|
||||
|
||||
@ -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())
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
}))
|
||||
})
|
||||
})
|
||||
237
web/app/components/workflow/nodes/if-else/use-config.helpers.ts
Normal file
237
web/app/components/workflow/nodes/if-else/use-config.helpers.ts
Normal 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
|
||||
})
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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
|
||||
})
|
||||
@ -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])
|
||||
|
||||
|
||||
@ -131,6 +131,8 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
|
||||
hideDebugWithMultipleModel
|
||||
debugWithMultipleModel={false}
|
||||
readonly={readOnly}
|
||||
nodesOutputVars={availableVars}
|
||||
availableNodes={availableNodesWithParent}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}),
|
||||
],
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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'],
|
||||
])
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
171
web/app/components/workflow/nodes/loop/use-config.helpers.ts
Normal file
171
web/app/components/workflow/nodes/loop/use-config.helpers.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
})
|
||||
@ -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 {
|
||||
|
||||
@ -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}`,
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
})
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -75,6 +75,8 @@ const Panel: FC<NodePanelProps<ParameterExtractorNodeType>> = ({
|
||||
hideDebugWithMultipleModel
|
||||
debugWithMultipleModel={false}
|
||||
readonly={readOnly}
|
||||
nodesOutputVars={availableVars}
|
||||
availableNodes={availableNodesWithParent}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
|
||||
@ -64,6 +64,8 @@ const Panel: FC<NodePanelProps<QuestionClassifierNodeType>> = ({
|
||||
hideDebugWithMultipleModel
|
||||
debugWithMultipleModel={false}
|
||||
readonly={readOnly}
|
||||
nodesOutputVars={availableVars}
|
||||
availableNodes={availableNodesWithParent}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user