mirror of
https://github.com/langgenius/dify.git
synced 2026-03-24 09:17:09 +00:00
Compare commits
13 Commits
test/workf
...
test/workf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79edee0610 | ||
|
|
cfd5994a90 | ||
|
|
0c3d11f920 | ||
|
|
d8704d7124 | ||
|
|
1674f8c2fb | ||
|
|
7fe25f1365 | ||
|
|
98b2a36219 | ||
|
|
85d62c5a48 | ||
|
|
5c074b6508 | ||
|
|
4ce3e23057 | ||
|
|
2ba96f92b6 | ||
|
|
8695435607 | ||
|
|
169511e68b |
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,207 @@
|
||||
import type { WebhookTriggerNodeType } from '../types'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { BlockEnum, VarType } from '@/app/components/workflow/types'
|
||||
import { fetchWebhookUrl } from '@/service/apps'
|
||||
import { createNodeCrudModuleMock } from '../../__tests__/use-config-test-utils'
|
||||
import { DEFAULT_STATUS_CODE, MAX_STATUS_CODE, normalizeStatusCode, useConfig } from '../use-config'
|
||||
|
||||
const mockSetInputs = vi.hoisted(() => vi.fn())
|
||||
const mockIsVarUsedInNodes = vi.hoisted(() => vi.fn())
|
||||
const mockRemoveUsedVarInNodes = vi.hoisted(() => vi.fn())
|
||||
const mockUseNodesReadOnly = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) => options?.key || key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
notify: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useNodesReadOnly: () => mockUseNodesReadOnly(),
|
||||
useWorkflow: () => ({
|
||||
isVarUsedInNodes: (...args: unknown[]) => mockIsVarUsedInNodes(...args),
|
||||
removeUsedVarInNodes: (...args: unknown[]) => mockRemoveUsedVarInNodes(...args),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
|
||||
...createNodeCrudModuleMock<WebhookTriggerNodeType>(mockSetInputs),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/apps', () => ({
|
||||
fetchWebhookUrl: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockedFetchWebhookUrl = vi.mocked(fetchWebhookUrl)
|
||||
const mockedToastNotify = vi.mocked(Toast.notify)
|
||||
|
||||
const createPayload = (overrides: Partial<WebhookTriggerNodeType> = {}): 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: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('useConfig', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.spyOn(useAppStore, 'getState').mockReturnValue({
|
||||
appDetail: { id: 'app-1' },
|
||||
} as never)
|
||||
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false })
|
||||
mockIsVarUsedInNodes.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('should update simple fields and reset body variables when content type changes', () => {
|
||||
const payload = createPayload({
|
||||
content_type: 'application/json',
|
||||
body: [{ name: 'payload', type: VarType.string, required: true }],
|
||||
variables: [
|
||||
{ variable: 'payload', label: 'body', required: true, value_selector: [], value_type: VarType.string },
|
||||
{ variable: 'token', label: 'header', required: false, value_selector: [], value_type: VarType.string },
|
||||
],
|
||||
})
|
||||
mockIsVarUsedInNodes.mockImplementation(([_, variable]) => variable === 'payload')
|
||||
const { result } = renderHook(() => useConfig('webhook-node', payload))
|
||||
|
||||
result.current.handleMethodChange('GET')
|
||||
result.current.handleContentTypeChange('text/plain')
|
||||
result.current.handleAsyncModeChange(true)
|
||||
result.current.handleStatusCodeChange(204)
|
||||
result.current.handleResponseBodyChange('ok')
|
||||
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
|
||||
method: 'GET',
|
||||
}))
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
|
||||
content_type: 'text/plain',
|
||||
body: [],
|
||||
variables: [
|
||||
expect.objectContaining({
|
||||
variable: 'token',
|
||||
label: 'header',
|
||||
}),
|
||||
],
|
||||
}))
|
||||
expect(mockRemoveUsedVarInNodes).toHaveBeenCalledWith(['webhook-node', 'payload'])
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ async_mode: true }))
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ status_code: 204 }))
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ response_body: 'ok' }))
|
||||
})
|
||||
|
||||
it('should sync params, headers and body variables and reject conflicting names', () => {
|
||||
const payload = createPayload({
|
||||
variables: [
|
||||
{ variable: 'existing_header', label: 'header', required: false, value_selector: [], value_type: VarType.string },
|
||||
],
|
||||
})
|
||||
const { result } = renderHook(() => useConfig('webhook-node', payload))
|
||||
|
||||
result.current.handleParamsChange([{ name: 'page', type: VarType.number, required: true }])
|
||||
result.current.handleHeadersChange([{ name: 'x-request-id', required: false }])
|
||||
result.current.handleBodyChange([{ name: 'body_field', type: VarType.string, required: true }])
|
||||
result.current.handleParamsChange([{ name: 'existing_header', type: VarType.string, required: true }])
|
||||
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
|
||||
params: [{ name: 'page', type: VarType.number, required: true }],
|
||||
variables: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
variable: 'page',
|
||||
label: 'param',
|
||||
value_type: VarType.number,
|
||||
}),
|
||||
]),
|
||||
}))
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
|
||||
headers: [{ name: 'x-request-id', required: false }],
|
||||
variables: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
variable: 'x_request_id',
|
||||
label: 'header',
|
||||
}),
|
||||
]),
|
||||
}))
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
|
||||
body: [{ name: 'body_field', type: VarType.string, required: true }],
|
||||
variables: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
variable: 'body_field',
|
||||
label: 'body',
|
||||
}),
|
||||
]),
|
||||
}))
|
||||
expect(mockedToastNotify).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should generate webhook urls once and fall back to empty url on request failure', async () => {
|
||||
mockedFetchWebhookUrl.mockResolvedValueOnce({
|
||||
webhook_url: 'https://example.com/hook',
|
||||
webhook_debug_url: 'https://example.com/debug',
|
||||
} as never)
|
||||
mockedFetchWebhookUrl.mockRejectedValueOnce(new Error('boom'))
|
||||
|
||||
const { result, rerender } = renderHook(({ payload }) => useConfig('webhook-node', payload), {
|
||||
initialProps: {
|
||||
payload: createPayload(),
|
||||
},
|
||||
})
|
||||
|
||||
await result.current.generateWebhookUrl()
|
||||
expect(mockedFetchWebhookUrl).toHaveBeenCalledWith({ appId: 'app-1', nodeId: 'webhook-node' })
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
|
||||
webhook_url: 'https://example.com/hook',
|
||||
webhook_debug_url: 'https://example.com/debug',
|
||||
}))
|
||||
|
||||
rerender({
|
||||
payload: createPayload(),
|
||||
})
|
||||
await result.current.generateWebhookUrl()
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
|
||||
webhook_url: '',
|
||||
}))
|
||||
|
||||
rerender({
|
||||
payload: createPayload({ webhook_url: 'https://already-exists' }),
|
||||
})
|
||||
await result.current.generateWebhookUrl()
|
||||
expect(mockedFetchWebhookUrl).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should expose readonly state, clamp status codes and skip url generation without app id', async () => {
|
||||
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: true })
|
||||
vi.spyOn(useAppStore, 'getState').mockReturnValue({
|
||||
appDetail: undefined,
|
||||
} as never)
|
||||
|
||||
const { result } = renderHook(() => useConfig('webhook-node', createPayload()))
|
||||
|
||||
expect(result.current.readOnly).toBe(true)
|
||||
expect(normalizeStatusCode(DEFAULT_STATUS_CODE - 10)).toBe(DEFAULT_STATUS_CODE)
|
||||
expect(normalizeStatusCode(248)).toBe(248)
|
||||
expect(normalizeStatusCode(MAX_STATUS_CODE + 10)).toBe(MAX_STATUS_CODE)
|
||||
|
||||
await result.current.generateWebhookUrl()
|
||||
|
||||
expect(mockedFetchWebhookUrl).not.toHaveBeenCalled()
|
||||
expect(mockSetInputs).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,197 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useState } from 'react'
|
||||
import GenericTable from '../generic-table'
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
title: 'Name',
|
||||
type: 'input' as const,
|
||||
placeholder: 'Name',
|
||||
width: 'w-[140px]',
|
||||
},
|
||||
{
|
||||
key: 'enabled',
|
||||
title: 'Enabled',
|
||||
type: 'switch' as const,
|
||||
width: 'w-[80px]',
|
||||
},
|
||||
]
|
||||
|
||||
const advancedColumns = [
|
||||
{
|
||||
key: 'method',
|
||||
title: 'Method',
|
||||
type: 'select' as const,
|
||||
placeholder: 'Choose method',
|
||||
options: [{ name: 'POST', value: 'post' }],
|
||||
width: 'w-[120px]',
|
||||
},
|
||||
{
|
||||
key: 'preview',
|
||||
title: 'Preview',
|
||||
type: 'custom' as const,
|
||||
width: 'w-[120px]',
|
||||
render: (_value: unknown, row: { method?: string }, index: number, onChange: (value: unknown) => void) => (
|
||||
<button type="button" onClick={() => onChange(`${index}:${row.method || 'empty'}`)}>
|
||||
custom-render
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'unsupported',
|
||||
title: 'Unsupported',
|
||||
type: 'unsupported' as never,
|
||||
width: 'w-[80px]',
|
||||
},
|
||||
]
|
||||
|
||||
describe('GenericTable', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render an empty editable row and append a configured row when typing into the virtual row', async () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<GenericTable
|
||||
title="Headers"
|
||||
columns={columns}
|
||||
data={[]}
|
||||
emptyRowData={{ name: '', enabled: false }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'my key' } })
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith([{ name: 'my_key', enabled: false }])
|
||||
})
|
||||
|
||||
it('should skip intermediate empty rows and blur the current input when enter is pressed', () => {
|
||||
render(
|
||||
<GenericTable
|
||||
title="Headers"
|
||||
columns={columns}
|
||||
data={[
|
||||
{ name: 'alpha', enabled: false },
|
||||
{ name: '', enabled: false },
|
||||
{ name: 'beta', enabled: true },
|
||||
]}
|
||||
emptyRowData={{ name: '', enabled: false }}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
expect(inputs).toHaveLength(3)
|
||||
expect(screen.getAllByRole('button', { name: 'Delete row' })).toHaveLength(2)
|
||||
|
||||
const blurSpy = vi.spyOn(inputs[0], 'blur')
|
||||
fireEvent.keyDown(inputs[0], { key: 'Enter' })
|
||||
expect(blurSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should update existing rows, show delete action, and remove rows by primary key', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<GenericTable
|
||||
title="Headers"
|
||||
columns={columns}
|
||||
data={[{ name: 'alpha', enabled: false }]}
|
||||
emptyRowData={{ name: '', enabled: false }}
|
||||
onChange={onChange}
|
||||
showHeader
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Name')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getAllByRole('checkbox')[0])
|
||||
expect(onChange).toHaveBeenCalledWith([{ name: 'alpha', enabled: true }])
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Delete row' }))
|
||||
expect(onChange).toHaveBeenLastCalledWith([])
|
||||
})
|
||||
|
||||
it('should update select and custom cells for existing rows', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
const ControlledTable = () => {
|
||||
const [data, setData] = useState([{ method: '', preview: '' }])
|
||||
|
||||
return (
|
||||
<GenericTable
|
||||
title="Advanced"
|
||||
columns={advancedColumns}
|
||||
data={data}
|
||||
emptyRowData={{ method: '', preview: '' }}
|
||||
onChange={(nextData) => {
|
||||
onChange(nextData)
|
||||
setData(nextData as { method: string, preview: string }[])
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
render(
|
||||
<ControlledTable />,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Choose method' }))
|
||||
await user.click(await screen.findByText('POST'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onChange).toHaveBeenCalledWith([{ method: 'post', preview: '' }])
|
||||
})
|
||||
|
||||
onChange.mockClear()
|
||||
await user.click(screen.getAllByRole('button', { name: 'custom-render' })[0])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onChange).toHaveBeenCalledWith([{ method: 'post', preview: '0:post' }])
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore custom-cell updates when readonly rows are rendered', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<GenericTable
|
||||
title="Advanced"
|
||||
columns={advancedColumns}
|
||||
data={[{ method: 'post', preview: '' }]}
|
||||
emptyRowData={{ method: '', preview: '' }}
|
||||
onChange={onChange}
|
||||
readonly
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'custom-render' }))
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show readonly placeholder without rendering editable rows', () => {
|
||||
render(
|
||||
<GenericTable
|
||||
title="Headers"
|
||||
columns={columns}
|
||||
data={[]}
|
||||
emptyRowData={{ name: '', enabled: false }}
|
||||
onChange={vi.fn()}
|
||||
readonly
|
||||
placeholder="No data"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('No data')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -57,6 +57,126 @@ type DisplayRow = {
|
||||
isVirtual: boolean // whether this row is the extra empty row for adding new items
|
||||
}
|
||||
|
||||
const isEmptyRow = (row: GenericTableRow) => {
|
||||
return Object.values(row).every(v => v === '' || v === null || v === undefined || v === false)
|
||||
}
|
||||
|
||||
const getDisplayRows = (
|
||||
data: GenericTableRow[],
|
||||
emptyRowData: GenericTableRow,
|
||||
readonly: boolean,
|
||||
): DisplayRow[] => {
|
||||
if (readonly)
|
||||
return data.map((row, index) => ({ row, dataIndex: index, isVirtual: false }))
|
||||
|
||||
if (!data.length)
|
||||
return [{ row: { ...emptyRowData }, dataIndex: null, isVirtual: true }]
|
||||
|
||||
const rows = data.reduce<DisplayRow[]>((acc, row, index) => {
|
||||
if (isEmptyRow(row) && index < data.length - 1)
|
||||
return acc
|
||||
|
||||
acc.push({ row, dataIndex: index, isVirtual: false })
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
const lastRow = data.at(-1)
|
||||
if (lastRow && !isEmptyRow(lastRow))
|
||||
rows.push({ row: { ...emptyRowData }, dataIndex: null, isVirtual: true })
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
const getPrimaryKey = (columns: ColumnConfig[]) => {
|
||||
return columns.find(col => col.key === 'key' || col.key === 'name')?.key ?? 'key'
|
||||
}
|
||||
|
||||
const renderInputCell = (
|
||||
column: ColumnConfig,
|
||||
value: unknown,
|
||||
readonly: boolean,
|
||||
handleChange: (value: unknown) => void,
|
||||
) => {
|
||||
return (
|
||||
<Input
|
||||
value={(value as string) || ''}
|
||||
onChange={(e) => {
|
||||
if (column.key === 'key' || column.key === 'name')
|
||||
replaceSpaceWithUnderscoreInVarNameInput(e.target)
|
||||
handleChange(e.target.value)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
e.currentTarget.blur()
|
||||
}
|
||||
}}
|
||||
placeholder={column.placeholder}
|
||||
disabled={readonly}
|
||||
wrapperClassName="w-full min-w-0"
|
||||
className={cn(
|
||||
'h-6 rounded-none border-0 bg-transparent px-0 py-0 shadow-none',
|
||||
'hover:border-transparent hover:bg-transparent focus:border-transparent focus:bg-transparent',
|
||||
'text-text-secondary system-sm-regular placeholder:text-text-quaternary',
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const renderSelectCell = (
|
||||
column: ColumnConfig,
|
||||
value: unknown,
|
||||
readonly: boolean,
|
||||
handleChange: (value: unknown) => void,
|
||||
) => {
|
||||
return (
|
||||
<SimpleSelect
|
||||
items={column.options || []}
|
||||
defaultValue={value as string | undefined}
|
||||
onSelect={item => handleChange(item.value)}
|
||||
disabled={readonly}
|
||||
placeholder={column.placeholder}
|
||||
hideChecked={false}
|
||||
notClearable={true}
|
||||
wrapperClassName="h-6 w-full min-w-0"
|
||||
className={cn(
|
||||
'h-6 rounded-none bg-transparent pl-0 pr-6 text-text-secondary',
|
||||
'hover:bg-transparent focus-visible:bg-transparent group-hover/simple-select:bg-transparent',
|
||||
)}
|
||||
optionWrapClassName="w-26 min-w-26 z-[60] -ml-3"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const renderSwitchCell = (
|
||||
column: ColumnConfig,
|
||||
value: unknown,
|
||||
dataIndex: number | null,
|
||||
readonly: boolean,
|
||||
handleChange: (value: unknown) => void,
|
||||
) => {
|
||||
return (
|
||||
<div className="flex h-7 items-center">
|
||||
<Checkbox
|
||||
id={`${column.key}-${String(dataIndex ?? 'v')}`}
|
||||
checked={Boolean(value)}
|
||||
onCheck={() => handleChange(!value)}
|
||||
disabled={readonly}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderCustomCell = (
|
||||
column: ColumnConfig,
|
||||
value: unknown,
|
||||
row: GenericTableRow,
|
||||
dataIndex: number | null,
|
||||
handleChange: (value: unknown) => void,
|
||||
) => {
|
||||
return column.render ? column.render(value, row, (dataIndex ?? -1), handleChange) : null
|
||||
}
|
||||
|
||||
const GenericTable: FC<GenericTableProps> = ({
|
||||
title,
|
||||
columns,
|
||||
@@ -68,42 +188,8 @@ const GenericTable: FC<GenericTableProps> = ({
|
||||
className,
|
||||
showHeader = false,
|
||||
}) => {
|
||||
// Build the rows to display while keeping a stable mapping to original data
|
||||
const displayRows = useMemo<DisplayRow[]>(() => {
|
||||
// Helper to check empty
|
||||
const isEmptyRow = (r: GenericTableRow) =>
|
||||
Object.values(r).every(v => v === '' || v === null || v === undefined || v === false)
|
||||
|
||||
if (readonly)
|
||||
return data.map((r, i) => ({ row: r, dataIndex: i, isVirtual: false }))
|
||||
|
||||
const hasData = data.length > 0
|
||||
const rows: DisplayRow[] = []
|
||||
|
||||
if (!hasData) {
|
||||
// Initialize with exactly one empty row when there is no data
|
||||
rows.push({ row: { ...emptyRowData }, dataIndex: null, isVirtual: true })
|
||||
return rows
|
||||
}
|
||||
|
||||
// Add configured rows, hide intermediate empty ones, keep mapping
|
||||
data.forEach((r, i) => {
|
||||
const isEmpty = isEmptyRow(r)
|
||||
// Skip empty rows except the very last configured row
|
||||
if (isEmpty && i < data.length - 1)
|
||||
return
|
||||
rows.push({ row: r, dataIndex: i, isVirtual: false })
|
||||
})
|
||||
|
||||
// If the last configured row has content, append a trailing empty row
|
||||
const lastRow = data.at(-1)
|
||||
if (!lastRow)
|
||||
return rows
|
||||
const lastHasContent = !isEmptyRow(lastRow)
|
||||
if (lastHasContent)
|
||||
rows.push({ row: { ...emptyRowData }, dataIndex: null, isVirtual: true })
|
||||
|
||||
return rows
|
||||
return getDisplayRows(data, emptyRowData, readonly)
|
||||
}, [data, emptyRowData, readonly])
|
||||
|
||||
const removeRow = useCallback((dataIndex: number) => {
|
||||
@@ -134,9 +220,7 @@ const GenericTable: FC<GenericTableProps> = ({
|
||||
}, [data, emptyRowData, onChange, readonly])
|
||||
|
||||
// Determine the primary identifier column just once
|
||||
const primaryKey = useMemo(() => (
|
||||
columns.find(col => col.key === 'key' || col.key === 'name')?.key ?? 'key'
|
||||
), [columns])
|
||||
const primaryKey = useMemo(() => getPrimaryKey(columns), [columns])
|
||||
|
||||
const renderCell = (column: ColumnConfig, row: GenericTableRow, dataIndex: number | null) => {
|
||||
const value = row[column.key]
|
||||
@@ -144,67 +228,16 @@ const GenericTable: FC<GenericTableProps> = ({
|
||||
|
||||
switch (column.type) {
|
||||
case 'input':
|
||||
return (
|
||||
<Input
|
||||
value={(value as string) || ''}
|
||||
onChange={(e) => {
|
||||
// Format variable names (replace spaces with underscores)
|
||||
if (column.key === 'key' || column.key === 'name')
|
||||
replaceSpaceWithUnderscoreInVarNameInput(e.target)
|
||||
handleChange(e.target.value)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
e.currentTarget.blur()
|
||||
}
|
||||
}}
|
||||
placeholder={column.placeholder}
|
||||
disabled={readonly}
|
||||
wrapperClassName="w-full min-w-0"
|
||||
className={cn(
|
||||
// Ghost/inline style: looks like plain text until focus/hover
|
||||
'h-6 rounded-none border-0 bg-transparent px-0 py-0 shadow-none',
|
||||
'hover:border-transparent hover:bg-transparent focus:border-transparent focus:bg-transparent',
|
||||
'text-text-secondary system-sm-regular placeholder:text-text-quaternary',
|
||||
)}
|
||||
/>
|
||||
)
|
||||
return renderInputCell(column, value, readonly, handleChange)
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<SimpleSelect
|
||||
items={column.options || []}
|
||||
defaultValue={value as string | undefined}
|
||||
onSelect={item => handleChange(item.value)}
|
||||
disabled={readonly}
|
||||
placeholder={column.placeholder}
|
||||
hideChecked={false}
|
||||
notClearable={true}
|
||||
// wrapper provides compact height, trigger is transparent like text
|
||||
wrapperClassName="h-6 w-full min-w-0"
|
||||
className={cn(
|
||||
'h-6 rounded-none bg-transparent pl-0 pr-6 text-text-secondary',
|
||||
'hover:bg-transparent focus-visible:bg-transparent group-hover/simple-select:bg-transparent',
|
||||
)}
|
||||
optionWrapClassName="w-26 min-w-26 z-[60] -ml-3"
|
||||
/>
|
||||
)
|
||||
return renderSelectCell(column, value, readonly, handleChange)
|
||||
|
||||
case 'switch':
|
||||
return (
|
||||
<div className="flex h-7 items-center">
|
||||
<Checkbox
|
||||
id={`${column.key}-${String(dataIndex ?? 'v')}`}
|
||||
checked={Boolean(value)}
|
||||
onCheck={() => handleChange(!value)}
|
||||
disabled={readonly}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
return renderSwitchCell(column, value, dataIndex, readonly, handleChange)
|
||||
|
||||
case 'custom':
|
||||
return column.render ? column.render(value, row, (dataIndex ?? -1), handleChange) : null
|
||||
return renderCustomCell(column, value, row, dataIndex, handleChange)
|
||||
|
||||
default:
|
||||
return null
|
||||
@@ -270,6 +303,7 @@ const GenericTable: FC<GenericTableProps> = ({
|
||||
className="p-1"
|
||||
aria-label="Delete row"
|
||||
>
|
||||
{/* eslint-disable-next-line hyoban/prefer-tailwind-icons */}
|
||||
<RiDeleteBinLine className="h-3.5 w-3.5 text-text-destructive" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import type { HttpMethod, WebhookHeader, WebhookParameter, WebhookTriggerNodeType } from './types'
|
||||
import type { Variable } from '@/app/components/workflow/types'
|
||||
import { produce } from 'immer'
|
||||
import { VarType } from '@/app/components/workflow/types'
|
||||
import { checkKeys, hasDuplicateStr } from '@/utils/var'
|
||||
import { WEBHOOK_RAW_VARIABLE_NAME } from './utils/raw-variable'
|
||||
|
||||
export type VariableSyncSource = 'param' | 'header' | 'body'
|
||||
|
||||
type SanitizedEntry = {
|
||||
item: WebhookParameter | WebhookHeader
|
||||
sanitizedName: string
|
||||
}
|
||||
|
||||
type NotifyError = (key: string) => void
|
||||
|
||||
const sanitizeEntryName = (item: WebhookParameter | WebhookHeader, sourceType: VariableSyncSource) => {
|
||||
return sourceType === 'header' ? item.name.replace(/-/g, '_') : item.name
|
||||
}
|
||||
|
||||
const getSanitizedEntries = (
|
||||
newData: (WebhookParameter | WebhookHeader)[],
|
||||
sourceType: VariableSyncSource,
|
||||
): SanitizedEntry[] => {
|
||||
return newData.map(item => ({
|
||||
item,
|
||||
sanitizedName: sanitizeEntryName(item, sourceType),
|
||||
}))
|
||||
}
|
||||
|
||||
const createVariable = (
|
||||
item: WebhookParameter | WebhookHeader,
|
||||
sourceType: VariableSyncSource,
|
||||
sanitizedName: string,
|
||||
): Variable => {
|
||||
const inputVarType: VarType = 'type' in item ? item.type : VarType.string
|
||||
|
||||
return {
|
||||
value_type: inputVarType,
|
||||
label: sourceType,
|
||||
variable: sanitizedName,
|
||||
value_selector: [],
|
||||
required: item.required,
|
||||
}
|
||||
}
|
||||
|
||||
export const syncVariables = ({
|
||||
draft,
|
||||
id,
|
||||
newData,
|
||||
sourceType,
|
||||
notifyError,
|
||||
isVarUsedInNodes,
|
||||
removeUsedVarInNodes,
|
||||
}: {
|
||||
draft: WebhookTriggerNodeType
|
||||
id: string
|
||||
newData: (WebhookParameter | WebhookHeader)[]
|
||||
sourceType: VariableSyncSource
|
||||
notifyError: NotifyError
|
||||
isVarUsedInNodes: (selector: [string, string]) => boolean
|
||||
removeUsedVarInNodes: (selector: [string, string]) => void
|
||||
}) => {
|
||||
if (!draft.variables)
|
||||
draft.variables = []
|
||||
|
||||
const sanitizedEntries = getSanitizedEntries(newData, sourceType)
|
||||
if (sanitizedEntries.some(entry => entry.sanitizedName === WEBHOOK_RAW_VARIABLE_NAME)) {
|
||||
notifyError('variableConfig.varName')
|
||||
return false
|
||||
}
|
||||
|
||||
const existingOtherVarNames = new Set(
|
||||
draft.variables
|
||||
.filter(v => v.label !== sourceType && v.variable !== WEBHOOK_RAW_VARIABLE_NAME)
|
||||
.map(v => v.variable),
|
||||
)
|
||||
|
||||
const crossScopeConflict = sanitizedEntries.find(entry => existingOtherVarNames.has(entry.sanitizedName))
|
||||
if (crossScopeConflict) {
|
||||
notifyError(crossScopeConflict.sanitizedName)
|
||||
return false
|
||||
}
|
||||
|
||||
if (hasDuplicateStr(sanitizedEntries.map(entry => entry.sanitizedName))) {
|
||||
notifyError('variableConfig.varName')
|
||||
return false
|
||||
}
|
||||
|
||||
for (const { sanitizedName } of sanitizedEntries) {
|
||||
const { isValid, errorMessageKey } = checkKeys([sanitizedName], false)
|
||||
if (!isValid) {
|
||||
notifyError(`varKeyError.${errorMessageKey}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const nextNames = new Set(sanitizedEntries.map(entry => entry.sanitizedName))
|
||||
draft.variables
|
||||
.filter(v => v.label === sourceType && !nextNames.has(v.variable))
|
||||
.forEach((variable) => {
|
||||
if (isVarUsedInNodes([id, variable.variable]))
|
||||
removeUsedVarInNodes([id, variable.variable])
|
||||
})
|
||||
|
||||
draft.variables = draft.variables.filter((variable) => {
|
||||
if (variable.label !== sourceType)
|
||||
return true
|
||||
return nextNames.has(variable.variable)
|
||||
})
|
||||
|
||||
sanitizedEntries.forEach(({ item, sanitizedName }) => {
|
||||
const existingVarIndex = draft.variables.findIndex(v => v.variable === sanitizedName)
|
||||
const variable = createVariable(item, sourceType, sanitizedName)
|
||||
if (existingVarIndex >= 0)
|
||||
draft.variables[existingVarIndex] = variable
|
||||
else
|
||||
draft.variables.push(variable)
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export const updateMethod = (inputs: WebhookTriggerNodeType, method: HttpMethod) => produce(inputs, (draft) => {
|
||||
draft.method = method
|
||||
})
|
||||
|
||||
export const updateSimpleField = <
|
||||
K extends 'async_mode' | 'status_code' | 'response_body',
|
||||
>(
|
||||
inputs: WebhookTriggerNodeType,
|
||||
key: K,
|
||||
value: WebhookTriggerNodeType[K],
|
||||
) => produce(inputs, (draft) => {
|
||||
draft[key] = value
|
||||
})
|
||||
|
||||
export const updateContentType = ({
|
||||
inputs,
|
||||
id,
|
||||
contentType,
|
||||
isVarUsedInNodes,
|
||||
removeUsedVarInNodes,
|
||||
}: {
|
||||
inputs: WebhookTriggerNodeType
|
||||
id: string
|
||||
contentType: string
|
||||
isVarUsedInNodes: (selector: [string, string]) => boolean
|
||||
removeUsedVarInNodes: (selector: [string, string]) => void
|
||||
}) => produce(inputs, (draft) => {
|
||||
const previousContentType = draft.content_type
|
||||
draft.content_type = contentType
|
||||
|
||||
if (previousContentType === contentType)
|
||||
return
|
||||
|
||||
draft.body = []
|
||||
if (!draft.variables)
|
||||
return
|
||||
|
||||
draft.variables
|
||||
.filter(v => v.label === 'body')
|
||||
.forEach((variable) => {
|
||||
if (isVarUsedInNodes([id, variable.variable]))
|
||||
removeUsedVarInNodes([id, variable.variable])
|
||||
})
|
||||
|
||||
draft.variables = draft.variables.filter(v => v.label !== 'body')
|
||||
})
|
||||
|
||||
type SourceField = 'params' | 'headers' | 'body'
|
||||
|
||||
const getSourceField = (sourceType: VariableSyncSource): SourceField => {
|
||||
switch (sourceType) {
|
||||
case 'param':
|
||||
return 'params'
|
||||
case 'header':
|
||||
return 'headers'
|
||||
default:
|
||||
return 'body'
|
||||
}
|
||||
}
|
||||
|
||||
export const updateSourceFields = ({
|
||||
inputs,
|
||||
id,
|
||||
sourceType,
|
||||
nextData,
|
||||
notifyError,
|
||||
isVarUsedInNodes,
|
||||
removeUsedVarInNodes,
|
||||
}: {
|
||||
inputs: WebhookTriggerNodeType
|
||||
id: string
|
||||
sourceType: VariableSyncSource
|
||||
nextData: WebhookParameter[] | WebhookHeader[]
|
||||
notifyError: NotifyError
|
||||
isVarUsedInNodes: (selector: [string, string]) => boolean
|
||||
removeUsedVarInNodes: (selector: [string, string]) => void
|
||||
}) => produce(inputs, (draft) => {
|
||||
draft[getSourceField(sourceType)] = nextData as never
|
||||
syncVariables({
|
||||
draft,
|
||||
id,
|
||||
newData: nextData,
|
||||
sourceType,
|
||||
notifyError,
|
||||
isVarUsedInNodes,
|
||||
removeUsedVarInNodes,
|
||||
})
|
||||
})
|
||||
|
||||
export const updateWebhookUrls = (
|
||||
inputs: WebhookTriggerNodeType,
|
||||
webhookUrl: string,
|
||||
webhookDebugUrl?: string,
|
||||
) => produce(inputs, (draft) => {
|
||||
draft.webhook_url = webhookUrl
|
||||
draft.webhook_debug_url = webhookDebugUrl
|
||||
})
|
||||
@@ -1,17 +1,18 @@
|
||||
import type { HttpMethod, WebhookHeader, WebhookParameter, WebhookTriggerNodeType } from './types'
|
||||
import type { Variable } from '@/app/components/workflow/types'
|
||||
import { produce } from 'immer'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { useNodesReadOnly, useWorkflow } from '@/app/components/workflow/hooks'
|
||||
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
|
||||
import { VarType } from '@/app/components/workflow/types'
|
||||
import { fetchWebhookUrl } from '@/service/apps'
|
||||
import { checkKeys, hasDuplicateStr } from '@/utils/var'
|
||||
import { WEBHOOK_RAW_VARIABLE_NAME } from './utils/raw-variable'
|
||||
import {
|
||||
updateContentType,
|
||||
updateMethod,
|
||||
updateSimpleField,
|
||||
updateSourceFields,
|
||||
updateWebhookUrls,
|
||||
} from './use-config.helpers'
|
||||
|
||||
export const DEFAULT_STATUS_CODE = 200
|
||||
export const MAX_STATUS_CODE = 399
|
||||
@@ -24,182 +25,80 @@ export const useConfig = (id: string, payload: WebhookTriggerNodeType) => {
|
||||
const appId = useAppStore.getState().appDetail?.id
|
||||
const { isVarUsedInNodes, removeUsedVarInNodes } = useWorkflow()
|
||||
|
||||
const notifyVarError = useCallback((key: string) => {
|
||||
const fieldLabel = key === 'variableConfig.varName'
|
||||
? t('variableConfig.varName', { ns: 'appDebug' })
|
||||
: key
|
||||
const message = key.startsWith('varKeyError.')
|
||||
? t(key as never, { ns: 'appDebug', key: fieldLabel })
|
||||
: t('varKeyError.keyAlreadyExists', { ns: 'appDebug', key: fieldLabel })
|
||||
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message,
|
||||
})
|
||||
}, [t])
|
||||
|
||||
const handleMethodChange = useCallback((method: HttpMethod) => {
|
||||
setInputs(produce(inputs, (draft) => {
|
||||
draft.method = method
|
||||
}))
|
||||
setInputs(updateMethod(inputs, method))
|
||||
}, [inputs, setInputs])
|
||||
|
||||
const handleContentTypeChange = useCallback((contentType: string) => {
|
||||
setInputs(produce(inputs, (draft) => {
|
||||
const previousContentType = draft.content_type
|
||||
draft.content_type = contentType
|
||||
|
||||
// If the content type changes, reset body parameters and their variables, as the variable types might differ.
|
||||
// However, we could consider retaining variables that are compatible with the new content type later.
|
||||
if (previousContentType !== contentType) {
|
||||
draft.body = []
|
||||
if (draft.variables) {
|
||||
const bodyVariables = draft.variables.filter(v => v.label === 'body')
|
||||
bodyVariables.forEach((v) => {
|
||||
if (isVarUsedInNodes([id, v.variable]))
|
||||
removeUsedVarInNodes([id, v.variable])
|
||||
})
|
||||
|
||||
draft.variables = draft.variables.filter(v => v.label !== 'body')
|
||||
}
|
||||
}
|
||||
setInputs(updateContentType({
|
||||
inputs,
|
||||
id,
|
||||
contentType,
|
||||
isVarUsedInNodes,
|
||||
removeUsedVarInNodes,
|
||||
}))
|
||||
}, [inputs, setInputs, id, isVarUsedInNodes, removeUsedVarInNodes])
|
||||
|
||||
const syncVariablesInDraft = useCallback((
|
||||
draft: WebhookTriggerNodeType,
|
||||
newData: (WebhookParameter | WebhookHeader)[],
|
||||
sourceType: 'param' | 'header' | 'body',
|
||||
) => {
|
||||
if (!draft.variables)
|
||||
draft.variables = []
|
||||
|
||||
const sanitizedEntries = newData.map(item => ({
|
||||
item,
|
||||
sanitizedName: sourceType === 'header' ? item.name.replace(/-/g, '_') : item.name,
|
||||
}))
|
||||
|
||||
const hasReservedConflict = sanitizedEntries.some(entry => entry.sanitizedName === WEBHOOK_RAW_VARIABLE_NAME)
|
||||
if (hasReservedConflict) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('varKeyError.keyAlreadyExists', {
|
||||
ns: 'appDebug',
|
||||
key: t('variableConfig.varName', { ns: 'appDebug' }),
|
||||
}),
|
||||
})
|
||||
return false
|
||||
}
|
||||
const existingOtherVarNames = new Set(
|
||||
draft.variables
|
||||
.filter(v => v.label !== sourceType && v.variable !== WEBHOOK_RAW_VARIABLE_NAME)
|
||||
.map(v => v.variable),
|
||||
)
|
||||
|
||||
const crossScopeConflict = sanitizedEntries.find(entry => existingOtherVarNames.has(entry.sanitizedName))
|
||||
if (crossScopeConflict) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('varKeyError.keyAlreadyExists', {
|
||||
ns: 'appDebug',
|
||||
key: crossScopeConflict.sanitizedName,
|
||||
}),
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
if (hasDuplicateStr(sanitizedEntries.map(entry => entry.sanitizedName))) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('varKeyError.keyAlreadyExists', {
|
||||
ns: 'appDebug',
|
||||
key: t('variableConfig.varName', { ns: 'appDebug' }),
|
||||
}),
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
for (const { sanitizedName } of sanitizedEntries) {
|
||||
const { isValid, errorMessageKey } = checkKeys([sanitizedName], false)
|
||||
if (!isValid) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t(`varKeyError.${errorMessageKey}`, {
|
||||
ns: 'appDebug',
|
||||
key: t('variableConfig.varName', { ns: 'appDebug' }),
|
||||
}),
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Create set of new variable names for this source
|
||||
const newVarNames = new Set(sanitizedEntries.map(entry => entry.sanitizedName))
|
||||
|
||||
// Find variables from current source that will be deleted and clean up references
|
||||
draft.variables
|
||||
.filter(v => v.label === sourceType && !newVarNames.has(v.variable))
|
||||
.forEach((v) => {
|
||||
// Clean up references if variable is used in other nodes
|
||||
if (isVarUsedInNodes([id, v.variable]))
|
||||
removeUsedVarInNodes([id, v.variable])
|
||||
})
|
||||
|
||||
// Remove variables that no longer exist in newData for this specific source type
|
||||
draft.variables = draft.variables.filter((v) => {
|
||||
// Keep variables from other sources
|
||||
if (v.label !== sourceType)
|
||||
return true
|
||||
return newVarNames.has(v.variable)
|
||||
})
|
||||
|
||||
// Add or update variables
|
||||
sanitizedEntries.forEach(({ item, sanitizedName }) => {
|
||||
const existingVarIndex = draft.variables.findIndex(v => v.variable === sanitizedName)
|
||||
|
||||
const inputVarType = 'type' in item
|
||||
? item.type
|
||||
: VarType.string // Default to string for headers
|
||||
|
||||
const newVar: Variable = {
|
||||
value_type: inputVarType,
|
||||
label: sourceType, // Use sourceType as label to identify source
|
||||
variable: sanitizedName,
|
||||
value_selector: [],
|
||||
required: item.required,
|
||||
}
|
||||
|
||||
if (existingVarIndex >= 0)
|
||||
draft.variables[existingVarIndex] = newVar
|
||||
else
|
||||
draft.variables.push(newVar)
|
||||
})
|
||||
return true
|
||||
}, [t, id, isVarUsedInNodes, removeUsedVarInNodes])
|
||||
|
||||
const handleParamsChange = useCallback((params: WebhookParameter[]) => {
|
||||
setInputs(produce(inputs, (draft) => {
|
||||
draft.params = params
|
||||
syncVariablesInDraft(draft, params, 'param')
|
||||
setInputs(updateSourceFields({
|
||||
inputs,
|
||||
id,
|
||||
sourceType: 'param',
|
||||
nextData: params,
|
||||
notifyError: notifyVarError,
|
||||
isVarUsedInNodes,
|
||||
removeUsedVarInNodes,
|
||||
}))
|
||||
}, [inputs, setInputs, syncVariablesInDraft])
|
||||
}, [id, inputs, isVarUsedInNodes, notifyVarError, removeUsedVarInNodes, setInputs])
|
||||
|
||||
const handleHeadersChange = useCallback((headers: WebhookHeader[]) => {
|
||||
setInputs(produce(inputs, (draft) => {
|
||||
draft.headers = headers
|
||||
syncVariablesInDraft(draft, headers, 'header')
|
||||
setInputs(updateSourceFields({
|
||||
inputs,
|
||||
id,
|
||||
sourceType: 'header',
|
||||
nextData: headers,
|
||||
notifyError: notifyVarError,
|
||||
isVarUsedInNodes,
|
||||
removeUsedVarInNodes,
|
||||
}))
|
||||
}, [inputs, setInputs, syncVariablesInDraft])
|
||||
}, [id, inputs, isVarUsedInNodes, notifyVarError, removeUsedVarInNodes, setInputs])
|
||||
|
||||
const handleBodyChange = useCallback((body: WebhookParameter[]) => {
|
||||
setInputs(produce(inputs, (draft) => {
|
||||
draft.body = body
|
||||
syncVariablesInDraft(draft, body, 'body')
|
||||
setInputs(updateSourceFields({
|
||||
inputs,
|
||||
id,
|
||||
sourceType: 'body',
|
||||
nextData: body,
|
||||
notifyError: notifyVarError,
|
||||
isVarUsedInNodes,
|
||||
removeUsedVarInNodes,
|
||||
}))
|
||||
}, [inputs, setInputs, syncVariablesInDraft])
|
||||
}, [id, inputs, isVarUsedInNodes, notifyVarError, removeUsedVarInNodes, setInputs])
|
||||
|
||||
const handleAsyncModeChange = useCallback((asyncMode: boolean) => {
|
||||
setInputs(produce(inputs, (draft) => {
|
||||
draft.async_mode = asyncMode
|
||||
}))
|
||||
setInputs(updateSimpleField(inputs, 'async_mode', asyncMode))
|
||||
}, [inputs, setInputs])
|
||||
|
||||
const handleStatusCodeChange = useCallback((statusCode: number) => {
|
||||
setInputs(produce(inputs, (draft) => {
|
||||
draft.status_code = statusCode
|
||||
}))
|
||||
setInputs(updateSimpleField(inputs, 'status_code', statusCode))
|
||||
}, [inputs, setInputs])
|
||||
|
||||
const handleResponseBodyChange = useCallback((responseBody: string) => {
|
||||
setInputs(produce(inputs, (draft) => {
|
||||
draft.response_body = responseBody
|
||||
}))
|
||||
setInputs(updateSimpleField(inputs, 'response_body', responseBody))
|
||||
}, [inputs, setInputs])
|
||||
|
||||
const generateWebhookUrl = useCallback(async () => {
|
||||
@@ -211,23 +110,12 @@ export const useConfig = (id: string, payload: WebhookTriggerNodeType) => {
|
||||
return
|
||||
|
||||
try {
|
||||
// Call backend to generate or fetch webhook url for this node
|
||||
const response = await fetchWebhookUrl({ appId, nodeId: id })
|
||||
|
||||
const newInputs = produce(inputs, (draft) => {
|
||||
draft.webhook_url = response.webhook_url
|
||||
draft.webhook_debug_url = response.webhook_debug_url
|
||||
})
|
||||
setInputs(newInputs)
|
||||
setInputs(updateWebhookUrls(inputs, response.webhook_url, response.webhook_debug_url))
|
||||
}
|
||||
catch (error: unknown) {
|
||||
// Fallback to mock URL when API is not ready or request fails
|
||||
// Keep the UI unblocked and allow users to proceed in local/dev environments.
|
||||
console.error('Failed to generate webhook URL:', error)
|
||||
const newInputs = produce(inputs, (draft) => {
|
||||
draft.webhook_url = ''
|
||||
})
|
||||
setInputs(newInputs)
|
||||
setInputs(updateWebhookUrls(inputs, ''))
|
||||
}
|
||||
}, [appId, id, inputs, setInputs])
|
||||
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import type { VariableAssignerNodeType } from '../types'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { BlockEnum, VarType } from '@/app/components/workflow/types'
|
||||
import {
|
||||
createNodeCrudModuleMock,
|
||||
createUuidModuleMock,
|
||||
} from '../../__tests__/use-config-test-utils'
|
||||
import useConfig from '../use-config'
|
||||
|
||||
const mockSetInputs = vi.hoisted(() => vi.fn())
|
||||
const mockDeleteNodeInspectorVars = vi.hoisted(() => vi.fn())
|
||||
const mockRenameInspectVarName = vi.hoisted(() => vi.fn())
|
||||
const mockHandleOutVarRenameChange = vi.hoisted(() => vi.fn())
|
||||
const mockIsVarUsedInNodes = vi.hoisted(() => vi.fn())
|
||||
const mockRemoveUsedVarInNodes = vi.hoisted(() => vi.fn())
|
||||
const mockGetAvailableVars = vi.hoisted(() => vi.fn())
|
||||
const mockUuid = vi.hoisted(() => vi.fn(() => 'generated-group-id'))
|
||||
|
||||
vi.mock('uuid', () => ({
|
||||
...createUuidModuleMock(mockUuid),
|
||||
}))
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useBoolean: (initialValue: boolean) => {
|
||||
let current = initialValue
|
||||
return [
|
||||
current,
|
||||
{
|
||||
setTrue: () => {
|
||||
current = true
|
||||
},
|
||||
setFalse: () => {
|
||||
current = false
|
||||
},
|
||||
},
|
||||
] as const
|
||||
},
|
||||
useDebounceFn: (fn: (...args: unknown[]) => void) => ({
|
||||
run: fn,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useNodesReadOnly: () => ({ nodesReadOnly: false }),
|
||||
useWorkflow: () => ({
|
||||
handleOutVarRenameChange: (...args: unknown[]) => mockHandleOutVarRenameChange(...args),
|
||||
isVarUsedInNodes: (...args: unknown[]) => mockIsVarUsedInNodes(...args),
|
||||
removeUsedVarInNodes: (...args: unknown[]) => mockRemoveUsedVarInNodes(...args),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
|
||||
...createNodeCrudModuleMock<VariableAssignerNodeType>(mockSetInputs),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks/use-inspect-vars-crud', () => ({
|
||||
__esModule: true,
|
||||
default: () => ({
|
||||
deleteNodeInspectorVars: (...args: unknown[]) => mockDeleteNodeInspectorVars(...args),
|
||||
renameInspectVarName: (...args: unknown[]) => mockRenameInspectVarName(...args),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useGetAvailableVars: () => mockGetAvailableVars,
|
||||
}))
|
||||
|
||||
const createPayload = (overrides: Partial<VariableAssignerNodeType> = {}): VariableAssignerNodeType => ({
|
||||
title: 'Variable Assigner',
|
||||
desc: '',
|
||||
type: BlockEnum.VariableAssigner,
|
||||
output_type: VarType.string,
|
||||
variables: [['source-node', 'initialVar']],
|
||||
advanced_settings: {
|
||||
group_enabled: true,
|
||||
groups: [
|
||||
{
|
||||
groupId: 'group-1',
|
||||
group_name: 'Group1',
|
||||
output_type: VarType.string,
|
||||
variables: [['source-node', 'initialVar']],
|
||||
},
|
||||
{
|
||||
groupId: 'group-2',
|
||||
group_name: 'Group2',
|
||||
output_type: VarType.number,
|
||||
variables: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('useConfig', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetAvailableVars.mockReturnValue([])
|
||||
mockIsVarUsedInNodes.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('should expose read-only state, group mode and typed variable filters', () => {
|
||||
const { result } = renderHook(() => useConfig('assigner-node', createPayload()))
|
||||
|
||||
expect(result.current.readOnly).toBe(false)
|
||||
expect(result.current.isEnableGroup).toBe(true)
|
||||
expect(result.current.filterVar(VarType.string)({ type: VarType.any } as never)).toBe(true)
|
||||
expect(result.current.filterVar(VarType.number)({ type: VarType.string } as never)).toBe(false)
|
||||
expect(result.current.getAvailableVars).toBe(mockGetAvailableVars)
|
||||
})
|
||||
|
||||
it('should update root and grouped variable payloads', () => {
|
||||
const { result } = renderHook(() => useConfig('assigner-node', createPayload()))
|
||||
|
||||
result.current.handleListOrTypeChange({
|
||||
output_type: VarType.number,
|
||||
variables: [['source-node', 'changed']],
|
||||
})
|
||||
result.current.handleListOrTypeChangeInGroup('group-1')({
|
||||
output_type: VarType.boolean,
|
||||
variables: [['source-node', 'groupVar']],
|
||||
})
|
||||
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
|
||||
output_type: VarType.number,
|
||||
variables: [['source-node', 'changed']],
|
||||
}))
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
|
||||
advanced_settings: expect.objectContaining({
|
||||
groups: [
|
||||
expect.objectContaining({
|
||||
groupId: 'group-1',
|
||||
output_type: VarType.boolean,
|
||||
variables: [['source-node', 'groupVar']],
|
||||
}),
|
||||
expect.anything(),
|
||||
],
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should add and remove groups and toggle group mode', () => {
|
||||
const { result } = renderHook(() => useConfig('assigner-node', createPayload()))
|
||||
|
||||
result.current.handleAddGroup()
|
||||
result.current.handleGroupRemoved('group-2')()
|
||||
result.current.handleGroupEnabledChange(false)
|
||||
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
|
||||
advanced_settings: expect.objectContaining({
|
||||
groups: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
groupId: 'generated-group-id',
|
||||
group_name: 'Group3',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}))
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
|
||||
advanced_settings: expect.objectContaining({
|
||||
groups: [
|
||||
expect.objectContaining({ groupId: 'group-1' }),
|
||||
],
|
||||
}),
|
||||
}))
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
|
||||
advanced_settings: expect.objectContaining({
|
||||
group_enabled: false,
|
||||
}),
|
||||
output_type: VarType.string,
|
||||
variables: [['source-node', 'initialVar']],
|
||||
}))
|
||||
expect(mockDeleteNodeInspectorVars).toHaveBeenCalledWith('assigner-node')
|
||||
expect(mockHandleOutVarRenameChange).toHaveBeenCalledWith(
|
||||
'assigner-node',
|
||||
['assigner-node', 'Group1', 'output'],
|
||||
['assigner-node', 'output'],
|
||||
)
|
||||
})
|
||||
|
||||
it('should rename groups and remove used vars after confirmation', () => {
|
||||
const { result } = renderHook(() => useConfig('assigner-node', createPayload()))
|
||||
|
||||
result.current.handleVarGroupNameChange('group-1')('Renamed')
|
||||
result.current.onRemoveVarConfirm()
|
||||
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
|
||||
advanced_settings: expect.objectContaining({
|
||||
groups: [
|
||||
expect.objectContaining({
|
||||
groupId: 'group-1',
|
||||
group_name: 'Renamed',
|
||||
}),
|
||||
expect.anything(),
|
||||
],
|
||||
}),
|
||||
}))
|
||||
expect(mockHandleOutVarRenameChange).toHaveBeenCalledWith(
|
||||
'assigner-node',
|
||||
['assigner-node', 'Group1', 'output'],
|
||||
['assigner-node', 'Renamed', 'output'],
|
||||
)
|
||||
expect(mockRenameInspectVarName).toHaveBeenCalledWith('assigner-node', 'Group1', 'Renamed')
|
||||
})
|
||||
|
||||
it('should confirm removing a used group before deleting it', () => {
|
||||
mockIsVarUsedInNodes.mockImplementation(selector => selector[1] === 'Group2')
|
||||
const { result } = renderHook(() => useConfig('assigner-node', createPayload()))
|
||||
|
||||
act(() => {
|
||||
result.current.handleGroupRemoved('group-2')()
|
||||
})
|
||||
act(() => {
|
||||
result.current.onRemoveVarConfirm()
|
||||
})
|
||||
|
||||
expect(mockRemoveUsedVarInNodes).toHaveBeenCalledWith(['assigner-node', 'Group2', 'output'])
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
|
||||
advanced_settings: expect.objectContaining({
|
||||
groups: [expect.objectContaining({ groupId: 'group-1' })],
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should enable empty groups and confirm disabling when downstream vars are used', () => {
|
||||
const { result: enableResult } = renderHook(() => useConfig('assigner-node', createPayload({
|
||||
advanced_settings: {
|
||||
group_enabled: false,
|
||||
groups: [],
|
||||
},
|
||||
})))
|
||||
|
||||
enableResult.current.handleGroupEnabledChange(true)
|
||||
|
||||
expect(mockHandleOutVarRenameChange).toHaveBeenCalledWith(
|
||||
'assigner-node',
|
||||
['assigner-node', 'output'],
|
||||
['assigner-node', 'Group1', 'output'],
|
||||
)
|
||||
|
||||
mockIsVarUsedInNodes.mockImplementation(selector => selector[1] === 'Group2')
|
||||
const { result } = renderHook(() => useConfig('assigner-node', createPayload()))
|
||||
|
||||
act(() => {
|
||||
result.current.handleGroupEnabledChange(false)
|
||||
})
|
||||
act(() => {
|
||||
result.current.onRemoveVarConfirm()
|
||||
})
|
||||
|
||||
expect(mockRemoveUsedVarInNodes).toHaveBeenCalledWith(['assigner-node', 'Group2', 'output'])
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
|
||||
advanced_settings: expect.objectContaining({ group_enabled: false }),
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { Var } from '../../types'
|
||||
import type { VarGroupItem, VariableAssignerNodeType } from './types'
|
||||
import { produce } from 'immer'
|
||||
import { v4 as uuid4 } from 'uuid'
|
||||
import { VarType } 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 updateRootVarGroupItem = (
|
||||
inputs: VariableAssignerNodeType,
|
||||
payload: VarGroupItem,
|
||||
) => ({
|
||||
...inputs,
|
||||
...payload,
|
||||
})
|
||||
|
||||
export const updateNestedVarGroupItem = (
|
||||
inputs: VariableAssignerNodeType,
|
||||
groupId: string,
|
||||
payload: VarGroupItem,
|
||||
) => produce(inputs, (draft) => {
|
||||
const index = draft.advanced_settings.groups.findIndex(item => item.groupId === groupId)
|
||||
draft.advanced_settings.groups[index] = {
|
||||
...draft.advanced_settings.groups[index],
|
||||
...payload,
|
||||
}
|
||||
})
|
||||
|
||||
export const removeGroupByIndex = (
|
||||
inputs: VariableAssignerNodeType,
|
||||
index: number,
|
||||
) => produce(inputs, (draft) => {
|
||||
draft.advanced_settings.groups.splice(index, 1)
|
||||
})
|
||||
|
||||
export const toggleGroupEnabled = ({
|
||||
inputs,
|
||||
enabled,
|
||||
}: {
|
||||
inputs: VariableAssignerNodeType
|
||||
enabled: boolean
|
||||
}) => produce(inputs, (draft) => {
|
||||
if (!draft.advanced_settings)
|
||||
draft.advanced_settings = { group_enabled: false, groups: [] }
|
||||
|
||||
if (enabled) {
|
||||
if (draft.advanced_settings.groups.length === 0) {
|
||||
draft.advanced_settings.groups = [{
|
||||
output_type: draft.output_type,
|
||||
variables: draft.variables,
|
||||
group_name: 'Group1',
|
||||
groupId: uuid4(),
|
||||
}]
|
||||
}
|
||||
}
|
||||
else if (draft.advanced_settings.groups.length > 0) {
|
||||
draft.output_type = draft.advanced_settings.groups[0].output_type
|
||||
draft.variables = draft.advanced_settings.groups[0].variables
|
||||
}
|
||||
|
||||
draft.advanced_settings.group_enabled = enabled
|
||||
})
|
||||
|
||||
export const addGroup = (inputs: VariableAssignerNodeType) => {
|
||||
let maxInGroupName = 1
|
||||
inputs.advanced_settings.groups.forEach((item) => {
|
||||
const match = /(\d+)$/.exec(item.group_name)
|
||||
if (match) {
|
||||
const num = Number.parseInt(match[1], 10)
|
||||
if (num > maxInGroupName)
|
||||
maxInGroupName = num
|
||||
}
|
||||
})
|
||||
|
||||
return produce(inputs, (draft) => {
|
||||
draft.advanced_settings.groups.push({
|
||||
output_type: VarType.any,
|
||||
variables: [],
|
||||
group_name: `Group${maxInGroupName + 1}`,
|
||||
groupId: uuid4(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export const renameGroup = (
|
||||
inputs: VariableAssignerNodeType,
|
||||
groupId: string,
|
||||
name: string,
|
||||
) => produce(inputs, (draft) => {
|
||||
const index = draft.advanced_settings.groups.findIndex(item => item.groupId === groupId)
|
||||
draft.advanced_settings.groups[index].group_name = name
|
||||
})
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { ValueSelector, Var } from '../../types'
|
||||
import type { ValueSelector } from '../../types'
|
||||
import type { VarGroupItem, VariableAssignerNodeType } from './types'
|
||||
import { useBoolean, useDebounceFn } from 'ahooks'
|
||||
import { produce } from 'immer'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { v4 as uuid4 } from 'uuid'
|
||||
import {
|
||||
useNodesReadOnly,
|
||||
useWorkflow,
|
||||
@@ -11,8 +9,16 @@ import {
|
||||
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
|
||||
import useInspectVarsCrud from '../../hooks/use-inspect-vars-crud'
|
||||
|
||||
import { VarType } from '../../types'
|
||||
import { useGetAvailableVars } from './hooks'
|
||||
import {
|
||||
addGroup,
|
||||
filterVarByType,
|
||||
removeGroupByIndex,
|
||||
renameGroup,
|
||||
toggleGroupEnabled,
|
||||
updateNestedVarGroupItem,
|
||||
updateRootVarGroupItem,
|
||||
} from './use-config.helpers'
|
||||
|
||||
const useConfig = (id: string, payload: VariableAssignerNodeType) => {
|
||||
const {
|
||||
@@ -27,35 +33,16 @@ const useConfig = (id: string, payload: VariableAssignerNodeType) => {
|
||||
|
||||
// Not Enable Group
|
||||
const handleListOrTypeChange = useCallback((payload: VarGroupItem) => {
|
||||
setInputs({
|
||||
...inputs,
|
||||
...payload,
|
||||
})
|
||||
setInputs(updateRootVarGroupItem(inputs, payload))
|
||||
}, [inputs, setInputs])
|
||||
|
||||
const handleListOrTypeChangeInGroup = useCallback((groupId: string) => {
|
||||
return (payload: VarGroupItem) => {
|
||||
const index = inputs.advanced_settings.groups.findIndex(item => item.groupId === groupId)
|
||||
const newInputs = produce(inputs, (draft) => {
|
||||
draft.advanced_settings.groups[index] = {
|
||||
...draft.advanced_settings.groups[index],
|
||||
...payload,
|
||||
}
|
||||
})
|
||||
setInputs(newInputs)
|
||||
setInputs(updateNestedVarGroupItem(inputs, groupId, payload))
|
||||
}
|
||||
}, [inputs, setInputs])
|
||||
|
||||
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 [isShowRemoveVarConfirm, {
|
||||
setTrue: showRemoveVarConfirm,
|
||||
@@ -75,84 +62,48 @@ const useConfig = (id: string, payload: VariableAssignerNodeType) => {
|
||||
setRemovedGroupIndex(index)
|
||||
return
|
||||
}
|
||||
const newInputs = produce(inputs, (draft) => {
|
||||
draft.advanced_settings.groups.splice(index, 1)
|
||||
})
|
||||
setInputs(newInputs)
|
||||
setInputs(removeGroupByIndex(inputs, index))
|
||||
}
|
||||
}, [id, inputs, isVarUsedInNodes, setInputs, showRemoveVarConfirm])
|
||||
|
||||
const handleGroupEnabledChange = useCallback((enabled: boolean) => {
|
||||
const newInputs = produce(inputs, (draft) => {
|
||||
if (!draft.advanced_settings)
|
||||
draft.advanced_settings = { group_enabled: false, groups: [] }
|
||||
if (enabled) {
|
||||
if (draft.advanced_settings.groups.length === 0) {
|
||||
const DEFAULT_GROUP_NAME = 'Group1'
|
||||
draft.advanced_settings.groups = [{
|
||||
output_type: draft.output_type,
|
||||
variables: draft.variables,
|
||||
group_name: DEFAULT_GROUP_NAME,
|
||||
groupId: uuid4(),
|
||||
}]
|
||||
if (enabled && inputs.advanced_settings.groups.length === 0) {
|
||||
handleOutVarRenameChange(id, [id, 'output'], [id, 'Group1', 'output'])
|
||||
}
|
||||
|
||||
handleOutVarRenameChange(id, [id, 'output'], [id, DEFAULT_GROUP_NAME, 'output'])
|
||||
if (!enabled && inputs.advanced_settings.groups.length > 0) {
|
||||
if (inputs.advanced_settings.groups.length > 1) {
|
||||
const useVars = inputs.advanced_settings.groups.filter((item, index) => index > 0 && isVarUsedInNodes([id, item.group_name, 'output']))
|
||||
if (useVars.length > 0) {
|
||||
showRemoveVarConfirm()
|
||||
setRemovedVars(useVars.map(item => [id, item.group_name, 'output']))
|
||||
setRemoveType('enableChanged')
|
||||
return
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (draft.advanced_settings.groups.length > 0) {
|
||||
if (draft.advanced_settings.groups.length > 1) {
|
||||
const useVars = draft.advanced_settings.groups.filter((item, index) => index > 0 && isVarUsedInNodes([id, item.group_name, 'output']))
|
||||
if (useVars.length > 0) {
|
||||
showRemoveVarConfirm()
|
||||
setRemovedVars(useVars.map(item => [id, item.group_name, 'output']))
|
||||
setRemoveType('enableChanged')
|
||||
return
|
||||
}
|
||||
}
|
||||
draft.output_type = draft.advanced_settings.groups[0].output_type
|
||||
draft.variables = draft.advanced_settings.groups[0].variables
|
||||
handleOutVarRenameChange(id, [id, draft.advanced_settings.groups[0].group_name, 'output'], [id, 'output'])
|
||||
}
|
||||
}
|
||||
draft.advanced_settings.group_enabled = enabled
|
||||
})
|
||||
setInputs(newInputs)
|
||||
|
||||
handleOutVarRenameChange(id, [id, inputs.advanced_settings.groups[0].group_name, 'output'], [id, 'output'])
|
||||
}
|
||||
|
||||
setInputs(toggleGroupEnabled({ inputs, enabled }))
|
||||
deleteNodeInspectorVars(id)
|
||||
}, [deleteNodeInspectorVars, handleOutVarRenameChange, id, inputs, isVarUsedInNodes, setInputs, showRemoveVarConfirm])
|
||||
|
||||
const handleAddGroup = useCallback(() => {
|
||||
let maxInGroupName = 1
|
||||
inputs.advanced_settings.groups.forEach((item) => {
|
||||
const match = /(\d+)$/.exec(item.group_name)
|
||||
if (match) {
|
||||
const num = Number.parseInt(match[1], 10)
|
||||
if (num > maxInGroupName)
|
||||
maxInGroupName = num
|
||||
}
|
||||
})
|
||||
const newInputs = produce(inputs, (draft) => {
|
||||
draft.advanced_settings.groups.push({
|
||||
output_type: VarType.any,
|
||||
variables: [],
|
||||
group_name: `Group${maxInGroupName + 1}`,
|
||||
groupId: uuid4(),
|
||||
})
|
||||
})
|
||||
setInputs(newInputs)
|
||||
setInputs(addGroup(inputs))
|
||||
deleteNodeInspectorVars(id)
|
||||
}, [deleteNodeInspectorVars, id, inputs, setInputs])
|
||||
|
||||
// record the first old name value
|
||||
const oldNameRecord = useRef<Record<string, string>>({})
|
||||
const oldNameRef = useRef<Record<string, string>>({})
|
||||
|
||||
const {
|
||||
run: renameInspectNameWithDebounce,
|
||||
} = useDebounceFn(
|
||||
(id: string, newName: string) => {
|
||||
const oldName = oldNameRecord.current[id]
|
||||
const oldName = oldNameRef.current[id]
|
||||
renameInspectVarName(id, oldName, newName)
|
||||
delete oldNameRecord.current[id]
|
||||
delete oldNameRef.current[id]
|
||||
},
|
||||
{ wait: 500 },
|
||||
)
|
||||
@@ -160,13 +111,10 @@ const useConfig = (id: string, payload: VariableAssignerNodeType) => {
|
||||
const handleVarGroupNameChange = useCallback((groupId: string) => {
|
||||
return (name: string) => {
|
||||
const index = inputs.advanced_settings.groups.findIndex(item => item.groupId === groupId)
|
||||
const newInputs = produce(inputs, (draft) => {
|
||||
draft.advanced_settings.groups[index].group_name = name
|
||||
})
|
||||
handleOutVarRenameChange(id, [id, inputs.advanced_settings.groups[index].group_name, 'output'], [id, name, 'output'])
|
||||
setInputs(newInputs)
|
||||
if (!(id in oldNameRecord.current))
|
||||
oldNameRecord.current[id] = inputs.advanced_settings.groups[index].group_name
|
||||
setInputs(renameGroup(inputs, groupId, name))
|
||||
if (!(id in oldNameRef.current))
|
||||
oldNameRef.current[id] = inputs.advanced_settings.groups[index].group_name
|
||||
renameInspectNameWithDebounce(id, name)
|
||||
}
|
||||
}, [handleOutVarRenameChange, id, inputs, renameInspectNameWithDebounce, setInputs])
|
||||
@@ -177,19 +125,11 @@ const useConfig = (id: string, payload: VariableAssignerNodeType) => {
|
||||
})
|
||||
hideRemoveVarConfirm()
|
||||
if (removeType === 'group') {
|
||||
const newInputs = produce(inputs, (draft) => {
|
||||
draft.advanced_settings.groups.splice(removedGroupIndex, 1)
|
||||
})
|
||||
setInputs(newInputs)
|
||||
setInputs(removeGroupByIndex(inputs, removedGroupIndex))
|
||||
}
|
||||
else {
|
||||
// removeType === 'enableChanged' to enabled
|
||||
const newInputs = produce(inputs, (draft) => {
|
||||
draft.advanced_settings.group_enabled = false
|
||||
draft.output_type = draft.advanced_settings.groups[0].output_type
|
||||
draft.variables = draft.advanced_settings.groups[0].variables
|
||||
})
|
||||
setInputs(newInputs)
|
||||
setInputs(toggleGroupEnabled({ inputs, enabled: false }))
|
||||
}
|
||||
}, [removedVars, hideRemoveVarConfirm, removeType, removeUsedVarInNodes, inputs, setInputs, removedGroupIndex])
|
||||
|
||||
@@ -207,7 +147,7 @@ const useConfig = (id: string, payload: VariableAssignerNodeType) => {
|
||||
hideRemoveVarConfirm,
|
||||
onRemoveVarConfirm,
|
||||
getAvailableVars,
|
||||
filterVar,
|
||||
filterVar: filterVarByType,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useCommand, useFontSize } from '../hooks'
|
||||
|
||||
type MockSelectionParent = { isLink: boolean } | null
|
||||
|
||||
const {
|
||||
mockDispatchCommand,
|
||||
mockEditorUpdate,
|
||||
mockRegisterUpdateListener,
|
||||
mockRegisterCommand,
|
||||
mockRead,
|
||||
mockSetLinkAnchorElement,
|
||||
mockSelectionNode,
|
||||
mockSelection,
|
||||
mockPatchStyleText,
|
||||
mockSetSelection,
|
||||
mockSelectionFontSize,
|
||||
mockIsRangeSelection,
|
||||
mockSelectedIsBullet,
|
||||
mockSetBlocksType,
|
||||
} = vi.hoisted(() => ({
|
||||
mockDispatchCommand: vi.fn(),
|
||||
mockEditorUpdate: vi.fn(),
|
||||
mockRegisterUpdateListener: vi.fn(),
|
||||
mockRegisterCommand: vi.fn(),
|
||||
mockRead: vi.fn(),
|
||||
mockSetLinkAnchorElement: vi.fn(),
|
||||
mockSelectionNode: {
|
||||
getParent: vi.fn<() => MockSelectionParent>(() => null),
|
||||
},
|
||||
mockSelection: {
|
||||
anchor: {
|
||||
getNode: vi.fn(),
|
||||
},
|
||||
focus: {
|
||||
getNode: vi.fn(),
|
||||
},
|
||||
isBackward: vi.fn(() => false),
|
||||
clone: vi.fn(() => 'cloned-selection'),
|
||||
},
|
||||
mockPatchStyleText: vi.fn(),
|
||||
mockSetSelection: vi.fn(),
|
||||
mockSelectionFontSize: vi.fn(),
|
||||
mockIsRangeSelection: vi.fn(() => true),
|
||||
mockSelectedIsBullet: vi.fn(() => false),
|
||||
mockSetBlocksType: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@lexical/react/LexicalComposerContext', () => ({
|
||||
useLexicalComposerContext: () => ([{
|
||||
dispatchCommand: mockDispatchCommand,
|
||||
update: mockEditorUpdate,
|
||||
registerUpdateListener: mockRegisterUpdateListener,
|
||||
registerCommand: mockRegisterCommand,
|
||||
getEditorState: () => ({
|
||||
read: mockRead,
|
||||
}),
|
||||
}]),
|
||||
}))
|
||||
|
||||
vi.mock('@lexical/link', () => ({
|
||||
$isLinkNode: (node: unknown) => Boolean(node && typeof node === 'object' && 'isLink' in (node as object)),
|
||||
TOGGLE_LINK_COMMAND: 'toggle-link-command',
|
||||
}))
|
||||
|
||||
vi.mock('@lexical/list', () => ({
|
||||
INSERT_UNORDERED_LIST_COMMAND: 'insert-unordered-list-command',
|
||||
}))
|
||||
|
||||
vi.mock('@lexical/selection', () => ({
|
||||
$getSelectionStyleValueForProperty: () => mockSelectionFontSize(),
|
||||
$isAtNodeEnd: () => false,
|
||||
$patchStyleText: mockPatchStyleText,
|
||||
$setBlocksType: mockSetBlocksType,
|
||||
}))
|
||||
|
||||
vi.mock('@lexical/utils', () => ({
|
||||
mergeRegister: (...cleanups: Array<() => void>) => () => cleanups.forEach(cleanup => cleanup()),
|
||||
}))
|
||||
|
||||
vi.mock('lexical', () => ({
|
||||
$createParagraphNode: () => ({ type: 'paragraph' }),
|
||||
$getSelection: () => mockSelection,
|
||||
$isRangeSelection: () => mockIsRangeSelection(),
|
||||
$setSelection: mockSetSelection,
|
||||
COMMAND_PRIORITY_CRITICAL: 4,
|
||||
FORMAT_TEXT_COMMAND: 'format-text-command',
|
||||
SELECTION_CHANGE_COMMAND: 'selection-change-command',
|
||||
}))
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
useNoteEditorStore: () => ({
|
||||
getState: () => ({
|
||||
selectedIsBullet: mockSelectedIsBullet(),
|
||||
setLinkAnchorElement: mockSetLinkAnchorElement,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('note toolbar hooks', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockEditorUpdate.mockImplementation((callback) => {
|
||||
callback()
|
||||
})
|
||||
mockRegisterUpdateListener.mockImplementation((listener) => {
|
||||
listener({})
|
||||
return vi.fn()
|
||||
})
|
||||
mockRegisterCommand.mockImplementation((_command, listener) => {
|
||||
listener()
|
||||
return vi.fn()
|
||||
})
|
||||
mockRead.mockImplementation((callback) => {
|
||||
callback()
|
||||
})
|
||||
mockSelectionFontSize.mockReturnValue('16px')
|
||||
mockIsRangeSelection.mockReturnValue(true)
|
||||
mockSelectedIsBullet.mockReturnValue(false)
|
||||
mockSelection.anchor.getNode.mockReturnValue(mockSelectionNode)
|
||||
mockSelection.focus.getNode.mockReturnValue(mockSelectionNode)
|
||||
mockSelectionNode.getParent.mockReturnValue(null)
|
||||
})
|
||||
|
||||
describe('useCommand', () => {
|
||||
it('should dispatch text formatting commands directly', () => {
|
||||
const { result } = renderHook(() => useCommand())
|
||||
|
||||
result.current.handleCommand('bold')
|
||||
result.current.handleCommand('italic')
|
||||
result.current.handleCommand('strikethrough')
|
||||
|
||||
expect(mockDispatchCommand).toHaveBeenNthCalledWith(1, 'format-text-command', 'bold')
|
||||
expect(mockDispatchCommand).toHaveBeenNthCalledWith(2, 'format-text-command', 'italic')
|
||||
expect(mockDispatchCommand).toHaveBeenNthCalledWith(3, 'format-text-command', 'strikethrough')
|
||||
})
|
||||
|
||||
it('should open link editing when current selection is not already a link', () => {
|
||||
const { result } = renderHook(() => useCommand())
|
||||
|
||||
result.current.handleCommand('link')
|
||||
|
||||
expect(mockDispatchCommand).toHaveBeenCalledWith('toggle-link-command', '')
|
||||
expect(mockSetLinkAnchorElement).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should remove the link when the current selection is already within a link node', () => {
|
||||
mockSelectionNode.getParent.mockReturnValue({ isLink: true })
|
||||
const { result } = renderHook(() => useCommand())
|
||||
|
||||
result.current.handleCommand('link')
|
||||
|
||||
expect(mockDispatchCommand).toHaveBeenCalledWith('toggle-link-command', null)
|
||||
expect(mockSetLinkAnchorElement).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('should ignore link commands when the selection is not a range', () => {
|
||||
mockIsRangeSelection.mockReturnValue(false)
|
||||
const { result } = renderHook(() => useCommand())
|
||||
|
||||
result.current.handleCommand('link')
|
||||
|
||||
expect(mockDispatchCommand).not.toHaveBeenCalled()
|
||||
expect(mockSetLinkAnchorElement).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should toggle bullet formatting on and off', () => {
|
||||
const { result, rerender } = renderHook(() => useCommand())
|
||||
|
||||
result.current.handleCommand('bullet')
|
||||
expect(mockDispatchCommand).toHaveBeenCalledWith('insert-unordered-list-command', undefined)
|
||||
|
||||
mockSelectedIsBullet.mockReturnValue(true)
|
||||
rerender()
|
||||
|
||||
result.current.handleCommand('bullet')
|
||||
expect(mockSetBlocksType).toHaveBeenCalledWith(mockSelection, expect.any(Function))
|
||||
})
|
||||
})
|
||||
|
||||
describe('useFontSize', () => {
|
||||
it('should expose font size state and update selection styling', () => {
|
||||
const { result } = renderHook(() => useFontSize())
|
||||
|
||||
expect(result.current.fontSize).toBe('16px')
|
||||
|
||||
result.current.handleFontSize('20px')
|
||||
expect(mockPatchStyleText).toHaveBeenCalledWith(mockSelection, { 'font-size': '20px' })
|
||||
})
|
||||
|
||||
it('should preserve the current selection when opening the selector', () => {
|
||||
const { result } = renderHook(() => useFontSize())
|
||||
|
||||
result.current.handleOpenFontSizeSelector(true)
|
||||
|
||||
expect(mockSetSelection).toHaveBeenCalledWith('cloned-selection')
|
||||
})
|
||||
|
||||
it('should keep the default font size and avoid patching styles when the selection is not a range', () => {
|
||||
mockIsRangeSelection.mockReturnValue(false)
|
||||
const { result } = renderHook(() => useFontSize())
|
||||
|
||||
expect(result.current.fontSize).toBe('12px')
|
||||
|
||||
result.current.handleFontSize('20px')
|
||||
expect(mockPatchStyleText).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -27,55 +27,72 @@ import {
|
||||
import { useNoteEditorStore } from '../store'
|
||||
import { getSelectedNode } from '../utils'
|
||||
|
||||
const DEFAULT_FONT_SIZE = '12px'
|
||||
|
||||
const updateFontSizeFromSelection = (setFontSize: (fontSize: string) => void) => {
|
||||
const selection = $getSelection()
|
||||
if ($isRangeSelection(selection))
|
||||
setFontSize($getSelectionStyleValueForProperty(selection, 'font-size', DEFAULT_FONT_SIZE))
|
||||
}
|
||||
|
||||
const toggleLink = (
|
||||
editor: ReturnType<typeof useLexicalComposerContext>[0],
|
||||
noteEditorStore: ReturnType<typeof useNoteEditorStore>,
|
||||
) => {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection()
|
||||
|
||||
if (!$isRangeSelection(selection))
|
||||
return
|
||||
|
||||
const node = getSelectedNode(selection)
|
||||
const parent = node.getParent()
|
||||
const { setLinkAnchorElement } = noteEditorStore.getState()
|
||||
|
||||
if ($isLinkNode(parent) || $isLinkNode(node)) {
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
|
||||
setLinkAnchorElement()
|
||||
return
|
||||
}
|
||||
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, '')
|
||||
setLinkAnchorElement(true)
|
||||
})
|
||||
}
|
||||
|
||||
const toggleBullet = (
|
||||
editor: ReturnType<typeof useLexicalComposerContext>[0],
|
||||
selectedIsBullet: boolean,
|
||||
) => {
|
||||
if (!selectedIsBullet) {
|
||||
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined)
|
||||
return
|
||||
}
|
||||
|
||||
editor.update(() => {
|
||||
const selection = $getSelection()
|
||||
if ($isRangeSelection(selection))
|
||||
$setBlocksType(selection, () => $createParagraphNode())
|
||||
})
|
||||
}
|
||||
|
||||
export const useCommand = () => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const noteEditorStore = useNoteEditorStore()
|
||||
|
||||
const handleCommand = useCallback((type: string) => {
|
||||
if (type === 'bold')
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold')
|
||||
|
||||
if (type === 'italic')
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic')
|
||||
|
||||
if (type === 'strikethrough')
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough')
|
||||
if (type === 'bold' || type === 'italic' || type === 'strikethrough') {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, type)
|
||||
return
|
||||
}
|
||||
|
||||
if (type === 'link') {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection()
|
||||
|
||||
if ($isRangeSelection(selection)) {
|
||||
const node = getSelectedNode(selection)
|
||||
const parent = node.getParent()
|
||||
const { setLinkAnchorElement } = noteEditorStore.getState()
|
||||
|
||||
if ($isLinkNode(parent) || $isLinkNode(node)) {
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
|
||||
setLinkAnchorElement()
|
||||
}
|
||||
else {
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, '')
|
||||
setLinkAnchorElement(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
toggleLink(editor, noteEditorStore)
|
||||
return
|
||||
}
|
||||
|
||||
if (type === 'bullet') {
|
||||
const { selectedIsBullet } = noteEditorStore.getState()
|
||||
|
||||
if (selectedIsBullet) {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection()
|
||||
if ($isRangeSelection(selection))
|
||||
$setBlocksType(selection, () => $createParagraphNode())
|
||||
})
|
||||
}
|
||||
else {
|
||||
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined)
|
||||
}
|
||||
}
|
||||
if (type === 'bullet')
|
||||
toggleBullet(editor, noteEditorStore.getState().selectedIsBullet)
|
||||
}, [editor, noteEditorStore])
|
||||
|
||||
return {
|
||||
@@ -85,7 +102,7 @@ export const useCommand = () => {
|
||||
|
||||
export const useFontSize = () => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const [fontSize, setFontSize] = useState('12px')
|
||||
const [fontSize, setFontSize] = useState(DEFAULT_FONT_SIZE)
|
||||
const [fontSizeSelectorShow, setFontSizeSelectorShow] = useState(false)
|
||||
|
||||
const handleFontSize = useCallback((fontSize: string) => {
|
||||
@@ -113,24 +130,13 @@ export const useFontSize = () => {
|
||||
return mergeRegister(
|
||||
editor.registerUpdateListener(() => {
|
||||
editor.getEditorState().read(() => {
|
||||
const selection = $getSelection()
|
||||
|
||||
if ($isRangeSelection(selection)) {
|
||||
const fontSize = $getSelectionStyleValueForProperty(selection, 'font-size', '12px')
|
||||
setFontSize(fontSize)
|
||||
}
|
||||
updateFontSizeFromSelection(setFontSize)
|
||||
})
|
||||
}),
|
||||
editor.registerCommand(
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
() => {
|
||||
const selection = $getSelection()
|
||||
|
||||
if ($isRangeSelection(selection)) {
|
||||
const fontSize = $getSelectionStyleValueForProperty(selection, 'font-size', '12px')
|
||||
setFontSize(fontSize)
|
||||
}
|
||||
|
||||
updateFontSizeFromSelection(setFontSize)
|
||||
return false
|
||||
},
|
||||
COMMAND_PRIORITY_CRITICAL,
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import type { Shape } from '@/app/components/workflow/store/workflow'
|
||||
import type { EnvironmentVariable } from '@/app/components/workflow/types'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { WorkflowContext } from '@/app/components/workflow/context'
|
||||
import { createWorkflowStore } from '@/app/components/workflow/store/workflow'
|
||||
import EnvPanel from '../index'
|
||||
|
||||
type MockWorkflowNode = {
|
||||
id: string
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
const {
|
||||
mockDoSyncWorkflowDraft,
|
||||
mockGetNodes,
|
||||
mockSetNodes,
|
||||
mockFindUsedVarNodes,
|
||||
mockUpdateNodeVars,
|
||||
mockVariableTriggerState,
|
||||
} = vi.hoisted(() => ({
|
||||
mockDoSyncWorkflowDraft: vi.fn(() => Promise.resolve()),
|
||||
mockGetNodes: vi.fn<() => MockWorkflowNode[]>(() => []),
|
||||
mockSetNodes: vi.fn<(nodes: MockWorkflowNode[]) => void>(),
|
||||
mockFindUsedVarNodes: vi.fn<(selector: string[], nodes: MockWorkflowNode[]) => MockWorkflowNode[]>(() => []),
|
||||
mockUpdateNodeVars: vi.fn<(node: MockWorkflowNode, currentSelector: string[], nextSelector: string[]) => MockWorkflowNode>((node, _currentSelector, nextSelector) => ({
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
nextSelector,
|
||||
},
|
||||
})),
|
||||
mockVariableTriggerState: {
|
||||
savePayload: undefined as EnvironmentVariable | undefined,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks/use-nodes-sync-draft', () => ({
|
||||
useNodesSyncDraft: () => ({
|
||||
doSyncWorkflowDraft: mockDoSyncWorkflowDraft,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
useStoreApi: () => ({
|
||||
getState: () => ({
|
||||
getNodes: mockGetNodes,
|
||||
setNodes: mockSetNodes,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/variable/utils', () => ({
|
||||
findUsedVarNodes: mockFindUsedVarNodes,
|
||||
updateNodeVars: mockUpdateNodeVars,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/remove-effect-var-confirm', () => ({
|
||||
default: ({
|
||||
isShow,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
isShow: boolean
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
}) => isShow
|
||||
? (
|
||||
<div>
|
||||
<button onClick={onCancel}>Cancel remove</button>
|
||||
<button onClick={onConfirm}>Confirm remove</button>
|
||||
</div>
|
||||
)
|
||||
: null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/panel/env-panel/env-item', () => ({
|
||||
default: ({
|
||||
env,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
env: EnvironmentVariable
|
||||
onEdit: (env: EnvironmentVariable) => void
|
||||
onDelete: (env: EnvironmentVariable) => void
|
||||
}) => (
|
||||
<div>
|
||||
<span>{env.name}</span>
|
||||
<button onClick={() => onEdit(env)}>
|
||||
Edit
|
||||
{' '}
|
||||
{env.name}
|
||||
</button>
|
||||
<button onClick={() => onDelete(env)}>
|
||||
Delete
|
||||
{' '}
|
||||
{env.name}
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/panel/env-panel/variable-trigger', () => ({
|
||||
default: ({
|
||||
open,
|
||||
env,
|
||||
onClose,
|
||||
onSave,
|
||||
setOpen,
|
||||
}: {
|
||||
open: boolean
|
||||
env?: EnvironmentVariable
|
||||
onClose: () => void
|
||||
onSave: (env: EnvironmentVariable) => Promise<void>
|
||||
setOpen: (open: boolean) => void
|
||||
}) => (
|
||||
<div>
|
||||
<span>
|
||||
Variable trigger:
|
||||
{open ? 'open' : 'closed'}
|
||||
:
|
||||
{env?.name || 'new'}
|
||||
</span>
|
||||
<button onClick={() => setOpen(true)}>Open variable modal</button>
|
||||
<button
|
||||
onClick={() => onSave(mockVariableTriggerState.savePayload || env || {
|
||||
id: 'env-created',
|
||||
name: 'created_name',
|
||||
value: 'created-value',
|
||||
value_type: 'string',
|
||||
description: 'created',
|
||||
})}
|
||||
>
|
||||
Save variable
|
||||
</button>
|
||||
<button onClick={onClose}>Close variable modal</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createEnv = (overrides: Partial<EnvironmentVariable> = {}): EnvironmentVariable => ({
|
||||
id: 'env-1',
|
||||
name: 'api_key',
|
||||
value: '[__HIDDEN__]',
|
||||
value_type: 'secret',
|
||||
description: 'secret description',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const renderWithProviders = (
|
||||
ui: ReactElement,
|
||||
storeState: Partial<Shape> = {},
|
||||
) => {
|
||||
const store = createWorkflowStore({})
|
||||
store.setState(storeState)
|
||||
|
||||
return {
|
||||
store,
|
||||
...render(
|
||||
<WorkflowContext value={store}>
|
||||
{ui}
|
||||
</WorkflowContext>,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
describe('EnvPanel container', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetNodes.mockReturnValue([])
|
||||
mockFindUsedVarNodes.mockReturnValue([])
|
||||
mockVariableTriggerState.savePayload = undefined
|
||||
})
|
||||
|
||||
it('should close the panel from the header action', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container, store } = renderWithProviders(<EnvPanel />, {
|
||||
environmentVariables: [],
|
||||
})
|
||||
|
||||
await user.click(container.querySelector('.cursor-pointer') as HTMLElement)
|
||||
|
||||
expect(store.getState().showEnvPanel).toBe(false)
|
||||
})
|
||||
|
||||
it('should add variables and normalize secret values after syncing', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { store } = renderWithProviders(<EnvPanel />, {
|
||||
environmentVariables: [],
|
||||
envSecrets: {},
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save variable' }))
|
||||
|
||||
expect(mockDoSyncWorkflowDraft).toHaveBeenCalledTimes(1)
|
||||
expect(store.getState().environmentVariables).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'env-created',
|
||||
name: 'created_name',
|
||||
value: 'created-value',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('should delete unused variables and sync draft changes', async () => {
|
||||
const user = userEvent.setup()
|
||||
const env = createEnv({ value_type: 'string', value: 'plain-text' })
|
||||
const { store } = renderWithProviders(<EnvPanel />, {
|
||||
environmentVariables: [env],
|
||||
envSecrets: {},
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: `Delete ${env.name}` }))
|
||||
|
||||
expect(store.getState().environmentVariables).toEqual([])
|
||||
expect(mockDoSyncWorkflowDraft).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should add secret variables, persist masked secrets, and sanitize the stored env value', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockVariableTriggerState.savePayload = createEnv({
|
||||
id: 'env-secret',
|
||||
name: 'secret_key',
|
||||
value: '1234567890',
|
||||
value_type: 'secret',
|
||||
})
|
||||
|
||||
const { store } = renderWithProviders(<EnvPanel />, {
|
||||
environmentVariables: [],
|
||||
envSecrets: {},
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save variable' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(store.getState().environmentVariables).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'env-secret',
|
||||
name: 'secret_key',
|
||||
value: '[__HIDDEN__]',
|
||||
value_type: 'secret',
|
||||
}),
|
||||
])
|
||||
})
|
||||
expect(store.getState().envSecrets).toEqual({
|
||||
'env-secret': '123456************90',
|
||||
})
|
||||
})
|
||||
|
||||
it('should clear the current variable when the variable modal closes', async () => {
|
||||
const user = userEvent.setup()
|
||||
const env = createEnv({ value_type: 'string', value: 'plain-text' })
|
||||
|
||||
renderWithProviders(<EnvPanel />, {
|
||||
environmentVariables: [env],
|
||||
envSecrets: {},
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: `Edit ${env.name}` }))
|
||||
expect(screen.getByText('Variable trigger:open:api_key')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Close variable modal' }))
|
||||
|
||||
expect(screen.getByText('Variable trigger:open:new')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should rename existing secret variables and update affected nodes without re-saving unchanged secrets', async () => {
|
||||
const user = userEvent.setup()
|
||||
const env = createEnv()
|
||||
mockVariableTriggerState.savePayload = createEnv({
|
||||
id: env.id,
|
||||
name: 'renamed_key',
|
||||
value: '[__HIDDEN__]',
|
||||
value_type: 'secret',
|
||||
})
|
||||
mockFindUsedVarNodes.mockReturnValue([{ id: 'node-1' }])
|
||||
mockGetNodes.mockReturnValue([
|
||||
{ id: 'node-1', data: { nextSelector: ['env', env.name] } },
|
||||
{ id: 'node-2', data: { untouched: true } },
|
||||
])
|
||||
|
||||
const { store } = renderWithProviders(<EnvPanel />, {
|
||||
environmentVariables: [env],
|
||||
envSecrets: {
|
||||
[env.id]: '[__HIDDEN__]',
|
||||
},
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: `Edit ${env.name}` }))
|
||||
await user.click(screen.getByRole('button', { name: 'Save variable' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(store.getState().environmentVariables).toEqual([
|
||||
expect.objectContaining({
|
||||
id: env.id,
|
||||
name: 'renamed_key',
|
||||
value: '[__HIDDEN__]',
|
||||
value_type: 'secret',
|
||||
}),
|
||||
])
|
||||
})
|
||||
expect(store.getState().envSecrets).toEqual({
|
||||
[env.id]: '[__HIDDEN__]',
|
||||
})
|
||||
expect(mockUpdateNodeVars).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'node-1' }),
|
||||
['env', env.name],
|
||||
['env', 'renamed_key'],
|
||||
)
|
||||
expect(mockSetNodes).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
id: 'node-1',
|
||||
data: expect.objectContaining({
|
||||
nextSelector: ['env', 'renamed_key'],
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({ id: 'node-2' }),
|
||||
])
|
||||
})
|
||||
|
||||
it('should convert edited plain variables into secrets and store the masked secret value', async () => {
|
||||
const user = userEvent.setup()
|
||||
const env = createEnv({ value_type: 'string', value: 'plain-text' })
|
||||
mockVariableTriggerState.savePayload = createEnv({
|
||||
id: env.id,
|
||||
name: env.name,
|
||||
value: 'abcdef123456',
|
||||
value_type: 'secret',
|
||||
})
|
||||
|
||||
const { store } = renderWithProviders(<EnvPanel />, {
|
||||
environmentVariables: [env],
|
||||
envSecrets: {},
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: `Edit ${env.name}` }))
|
||||
await user.click(screen.getByRole('button', { name: 'Save variable' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(store.getState().environmentVariables).toEqual([
|
||||
expect.objectContaining({
|
||||
id: env.id,
|
||||
value: '[__HIDDEN__]',
|
||||
value_type: 'secret',
|
||||
}),
|
||||
])
|
||||
})
|
||||
expect(store.getState().envSecrets).toEqual({
|
||||
[env.id]: 'abcdef************56',
|
||||
})
|
||||
})
|
||||
|
||||
it('should persist a new masked secret when an existing secret variable changes value', async () => {
|
||||
const user = userEvent.setup()
|
||||
const env = createEnv()
|
||||
mockVariableTriggerState.savePayload = createEnv({
|
||||
id: env.id,
|
||||
name: env.name,
|
||||
value: 'updated-secret-99',
|
||||
value_type: 'secret',
|
||||
})
|
||||
|
||||
const { store } = renderWithProviders(<EnvPanel />, {
|
||||
environmentVariables: [env],
|
||||
envSecrets: {
|
||||
[env.id]: '[__HIDDEN__]',
|
||||
},
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: `Edit ${env.name}` }))
|
||||
await user.click(screen.getByRole('button', { name: 'Save variable' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(store.getState().environmentVariables).toEqual([
|
||||
expect.objectContaining({
|
||||
id: env.id,
|
||||
value: '[__HIDDEN__]',
|
||||
value_type: 'secret',
|
||||
}),
|
||||
])
|
||||
})
|
||||
expect(store.getState().envSecrets).toEqual({
|
||||
[env.id]: 'update************99',
|
||||
})
|
||||
})
|
||||
|
||||
it('should require confirmation before deleting affected secret variables', async () => {
|
||||
const user = userEvent.setup()
|
||||
const env = createEnv()
|
||||
mockFindUsedVarNodes.mockReturnValue([{ id: 'node-1' }])
|
||||
mockGetNodes.mockReturnValue([
|
||||
{ id: 'node-1', data: { nextSelector: ['env', env.name] } },
|
||||
{ id: 'node-2', data: { untouched: true } },
|
||||
])
|
||||
|
||||
const { store } = renderWithProviders(<EnvPanel />, {
|
||||
environmentVariables: [env],
|
||||
envSecrets: {
|
||||
[env.id]: 'abcdef************56',
|
||||
},
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: `Delete ${env.name}` }))
|
||||
expect(screen.getByRole('button', { name: 'Cancel remove' })).toBeInTheDocument()
|
||||
expect(store.getState().environmentVariables).toHaveLength(1)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel remove' }))
|
||||
expect(screen.queryByRole('button', { name: 'Confirm remove' })).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: `Delete ${env.name}` }))
|
||||
await user.click(screen.getByRole('button', { name: 'Confirm remove' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(store.getState().environmentVariables).toEqual([])
|
||||
})
|
||||
expect(store.getState().envSecrets).toEqual({})
|
||||
expect(mockUpdateNodeVars).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'node-1' }),
|
||||
['env', env.name],
|
||||
[],
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -19,6 +19,79 @@ import VariableTrigger from '@/app/components/workflow/panel/env-panel/variable-
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
const HIDDEN_SECRET_VALUE = '[__HIDDEN__]'
|
||||
|
||||
const formatSecret = (secret: string) => {
|
||||
return secret.length > 8 ? `${secret.slice(0, 6)}************${secret.slice(-2)}` : '********************'
|
||||
}
|
||||
|
||||
const sanitizeSecretValue = (env: EnvironmentVariable) => {
|
||||
return env.value_type === 'secret'
|
||||
? { ...env, value: HIDDEN_SECRET_VALUE }
|
||||
: env
|
||||
}
|
||||
|
||||
const useEnvPanelActions = ({
|
||||
store,
|
||||
envSecrets,
|
||||
updateEnvList,
|
||||
setEnvSecrets,
|
||||
doSyncWorkflowDraft,
|
||||
}: {
|
||||
store: ReturnType<typeof useStoreApi>
|
||||
envSecrets: Record<string, string>
|
||||
updateEnvList: (envList: EnvironmentVariable[]) => void
|
||||
setEnvSecrets: (envSecrets: Record<string, string>) => void
|
||||
doSyncWorkflowDraft: () => Promise<void>
|
||||
}) => {
|
||||
const getAffectedNodes = useCallback((env: EnvironmentVariable) => {
|
||||
const allNodes = store.getState().getNodes()
|
||||
return findUsedVarNodes(
|
||||
['env', env.name],
|
||||
allNodes,
|
||||
)
|
||||
}, [store])
|
||||
|
||||
const updateAffectedNodes = useCallback((currentEnv: EnvironmentVariable, nextSelector: string[]) => {
|
||||
const { getNodes, setNodes } = store.getState()
|
||||
const affectedNodes = getAffectedNodes(currentEnv)
|
||||
const nextNodes = getNodes().map((node) => {
|
||||
if (affectedNodes.find(affectedNode => affectedNode.id === node.id))
|
||||
return updateNodeVars(node, ['env', currentEnv.name], nextSelector)
|
||||
|
||||
return node
|
||||
})
|
||||
setNodes(nextNodes)
|
||||
}, [getAffectedNodes, store])
|
||||
|
||||
const syncEnvList = useCallback(async (nextEnvList: EnvironmentVariable[]) => {
|
||||
updateEnvList(nextEnvList)
|
||||
await doSyncWorkflowDraft()
|
||||
updateEnvList(nextEnvList.map(sanitizeSecretValue))
|
||||
}, [doSyncWorkflowDraft, updateEnvList])
|
||||
|
||||
const saveSecretValue = useCallback((env: EnvironmentVariable) => {
|
||||
setEnvSecrets({
|
||||
...envSecrets,
|
||||
[env.id]: formatSecret(String(env.value)),
|
||||
})
|
||||
}, [envSecrets, setEnvSecrets])
|
||||
|
||||
const removeEnvSecret = useCallback((envId: string) => {
|
||||
const nextSecrets = { ...envSecrets }
|
||||
delete nextSecrets[envId]
|
||||
setEnvSecrets(nextSecrets)
|
||||
}, [envSecrets, setEnvSecrets])
|
||||
|
||||
return {
|
||||
getAffectedNodes,
|
||||
updateAffectedNodes,
|
||||
syncEnvList,
|
||||
saveSecretValue,
|
||||
removeEnvSecret,
|
||||
}
|
||||
}
|
||||
|
||||
const EnvPanel = () => {
|
||||
const { t } = useTranslation()
|
||||
const store = useStoreApi()
|
||||
@@ -28,123 +101,87 @@ const EnvPanel = () => {
|
||||
const updateEnvList = useStore(s => s.setEnvironmentVariables)
|
||||
const setEnvSecrets = useStore(s => s.setEnvSecrets)
|
||||
const { doSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const {
|
||||
getAffectedNodes,
|
||||
updateAffectedNodes,
|
||||
syncEnvList,
|
||||
saveSecretValue,
|
||||
removeEnvSecret,
|
||||
} = useEnvPanelActions({
|
||||
store,
|
||||
envSecrets,
|
||||
updateEnvList,
|
||||
setEnvSecrets,
|
||||
doSyncWorkflowDraft,
|
||||
})
|
||||
|
||||
const [showVariableModal, setShowVariableModal] = useState(false)
|
||||
const [currentVar, setCurrentVar] = useState<EnvironmentVariable>()
|
||||
|
||||
const [showRemoveVarConfirm, setShowRemoveConfirm] = useState(false)
|
||||
const [showRemoveVarConfirm, setShowRemoveVarConfirm] = useState(false)
|
||||
const [cacheForDelete, setCacheForDelete] = useState<EnvironmentVariable>()
|
||||
|
||||
const formatSecret = (s: string) => {
|
||||
return s.length > 8 ? `${s.slice(0, 6)}************${s.slice(-2)}` : '********************'
|
||||
}
|
||||
|
||||
const getEffectedNodes = useCallback((env: EnvironmentVariable) => {
|
||||
const { getNodes } = store.getState()
|
||||
const allNodes = getNodes()
|
||||
return findUsedVarNodes(
|
||||
['env', env.name],
|
||||
allNodes,
|
||||
)
|
||||
}, [store])
|
||||
|
||||
const removeUsedVarInNodes = useCallback((env: EnvironmentVariable) => {
|
||||
const { getNodes, setNodes } = store.getState()
|
||||
const effectedNodes = getEffectedNodes(env)
|
||||
const newNodes = getNodes().map((node) => {
|
||||
if (effectedNodes.find(n => n.id === node.id))
|
||||
return updateNodeVars(node, ['env', env.name], [])
|
||||
|
||||
return node
|
||||
})
|
||||
setNodes(newNodes)
|
||||
}, [getEffectedNodes, store])
|
||||
|
||||
const handleEdit = (env: EnvironmentVariable) => {
|
||||
setCurrentVar(env)
|
||||
setShowVariableModal(true)
|
||||
}
|
||||
|
||||
const handleDelete = useCallback((env: EnvironmentVariable) => {
|
||||
removeUsedVarInNodes(env)
|
||||
updateAffectedNodes(env, [])
|
||||
updateEnvList(envList.filter(e => e.id !== env.id))
|
||||
setCacheForDelete(undefined)
|
||||
setShowRemoveConfirm(false)
|
||||
setShowRemoveVarConfirm(false)
|
||||
doSyncWorkflowDraft()
|
||||
if (env.value_type === 'secret') {
|
||||
const newMap = { ...envSecrets }
|
||||
delete newMap[env.id]
|
||||
setEnvSecrets(newMap)
|
||||
}
|
||||
}, [doSyncWorkflowDraft, envList, envSecrets, removeUsedVarInNodes, setEnvSecrets, updateEnvList])
|
||||
if (env.value_type === 'secret')
|
||||
removeEnvSecret(env.id)
|
||||
}, [doSyncWorkflowDraft, envList, removeEnvSecret, updateAffectedNodes, updateEnvList])
|
||||
|
||||
const deleteCheck = useCallback((env: EnvironmentVariable) => {
|
||||
const effectedNodes = getEffectedNodes(env)
|
||||
if (effectedNodes.length > 0) {
|
||||
const affectedNodes = getAffectedNodes(env)
|
||||
if (affectedNodes.length > 0) {
|
||||
setCacheForDelete(env)
|
||||
setShowRemoveConfirm(true)
|
||||
setShowRemoveVarConfirm(true)
|
||||
}
|
||||
else {
|
||||
handleDelete(env)
|
||||
}
|
||||
}, [getEffectedNodes, handleDelete])
|
||||
}, [getAffectedNodes, handleDelete])
|
||||
|
||||
const handleSave = useCallback(async (env: EnvironmentVariable) => {
|
||||
// add env
|
||||
let newEnv = env
|
||||
if (!currentVar) {
|
||||
if (env.value_type === 'secret') {
|
||||
setEnvSecrets({
|
||||
...envSecrets,
|
||||
[env.id]: formatSecret(env.value),
|
||||
})
|
||||
}
|
||||
const newList = [env, ...envList]
|
||||
updateEnvList(newList)
|
||||
await doSyncWorkflowDraft()
|
||||
updateEnvList(newList.map(e => (e.id === env.id && env.value_type === 'secret') ? { ...e, value: '[__HIDDEN__]' } : e))
|
||||
if (env.value_type === 'secret')
|
||||
saveSecretValue(env)
|
||||
|
||||
await syncEnvList([env, ...envList])
|
||||
return
|
||||
}
|
||||
else if (currentVar.value_type === 'secret') {
|
||||
|
||||
if (currentVar.value_type === 'secret') {
|
||||
if (env.value_type === 'secret') {
|
||||
if (envSecrets[currentVar.id] !== env.value) {
|
||||
newEnv = env
|
||||
setEnvSecrets({
|
||||
...envSecrets,
|
||||
[env.id]: formatSecret(env.value),
|
||||
})
|
||||
saveSecretValue(env)
|
||||
}
|
||||
else {
|
||||
newEnv = { ...env, value: '[__HIDDEN__]' }
|
||||
newEnv = sanitizeSecretValue(env)
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (env.value_type === 'secret') {
|
||||
newEnv = env
|
||||
setEnvSecrets({
|
||||
...envSecrets,
|
||||
[env.id]: formatSecret(env.value),
|
||||
})
|
||||
}
|
||||
else if (env.value_type === 'secret') {
|
||||
saveSecretValue(env)
|
||||
}
|
||||
const newList = envList.map(e => e.id === currentVar.id ? newEnv : e)
|
||||
updateEnvList(newList)
|
||||
// side effects of rename env
|
||||
if (currentVar.name !== env.name) {
|
||||
const { getNodes, setNodes } = store.getState()
|
||||
const effectedNodes = getEffectedNodes(currentVar)
|
||||
const newNodes = getNodes().map((node) => {
|
||||
if (effectedNodes.find(n => n.id === node.id))
|
||||
return updateNodeVars(node, ['env', currentVar.name], ['env', env.name])
|
||||
|
||||
return node
|
||||
})
|
||||
setNodes(newNodes)
|
||||
}
|
||||
await doSyncWorkflowDraft()
|
||||
updateEnvList(newList.map(e => (e.id === env.id && env.value_type === 'secret') ? { ...e, value: '[__HIDDEN__]' } : e))
|
||||
}, [currentVar, doSyncWorkflowDraft, envList, envSecrets, getEffectedNodes, setEnvSecrets, store, updateEnvList])
|
||||
const newList = envList.map(e => e.id === currentVar.id ? newEnv : e)
|
||||
if (currentVar.name !== env.name)
|
||||
updateAffectedNodes(currentVar, ['env', env.name])
|
||||
|
||||
await syncEnvList(newList)
|
||||
}, [currentVar, envList, envSecrets, saveSecretValue, syncEnvList, updateAffectedNodes])
|
||||
|
||||
const handleVariableModalClose = () => {
|
||||
setCurrentVar(undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -159,6 +196,7 @@ const EnvPanel = () => {
|
||||
className="flex h-6 w-6 cursor-pointer items-center justify-center"
|
||||
onClick={() => setShowEnvPanel(false)}
|
||||
>
|
||||
{/* eslint-disable-next-line hyoban/prefer-tailwind-icons */}
|
||||
<RiCloseLine className="h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -170,7 +208,7 @@ const EnvPanel = () => {
|
||||
setOpen={setShowVariableModal}
|
||||
env={currentVar}
|
||||
onSave={handleSave}
|
||||
onClose={() => setCurrentVar(undefined)}
|
||||
onClose={handleVariableModalClose}
|
||||
/>
|
||||
</div>
|
||||
<div className="grow overflow-y-auto rounded-b-2xl px-4">
|
||||
@@ -185,7 +223,7 @@ const EnvPanel = () => {
|
||||
</div>
|
||||
<RemoveEffectVarConfirm
|
||||
isShow={showRemoveVarConfirm}
|
||||
onCancel={() => setShowRemoveConfirm(false)}
|
||||
onCancel={() => setShowRemoveVarConfirm(false)}
|
||||
onConfirm={() => cacheForDelete && handleDelete(cacheForDelete)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import type { IterationDurationMap, NodeTracing } from '@/types/workflow'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { BlockEnum, NodeRunningStatus } from '../../../types'
|
||||
import IterationLogTrigger from '../iteration-log-trigger'
|
||||
|
||||
const createNodeTracing = (overrides: Partial<NodeTracing> = {}): NodeTracing => ({
|
||||
id: 'trace-1',
|
||||
index: 0,
|
||||
predecessor_node_id: '',
|
||||
node_id: 'iteration-node',
|
||||
node_type: BlockEnum.Iteration,
|
||||
title: 'Iteration',
|
||||
inputs: {},
|
||||
inputs_truncated: false,
|
||||
process_data: {},
|
||||
process_data_truncated: false,
|
||||
outputs: {},
|
||||
outputs_truncated: false,
|
||||
status: NodeRunningStatus.Succeeded,
|
||||
error: '',
|
||||
elapsed_time: 0.2,
|
||||
metadata: {
|
||||
iterator_length: 0,
|
||||
iterator_index: 0,
|
||||
loop_length: 0,
|
||||
loop_index: 0,
|
||||
},
|
||||
created_at: 1710000000,
|
||||
created_by: {
|
||||
id: 'user-1',
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
},
|
||||
finished_at: 1710000001,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createExecutionMetadata = (overrides: Partial<NonNullable<NodeTracing['execution_metadata']>> = {}) => ({
|
||||
total_tokens: 0,
|
||||
total_price: 0,
|
||||
currency: 'USD',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('IterationLogTrigger', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Structured Detail Handling', () => {
|
||||
it('should reconstruct structured iteration groups from execution metadata and include failed missing details', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onShowIterationResultList = vi.fn()
|
||||
const iterationDurationMap: IterationDurationMap = { 'parallel-1': 1.1, '1': 2.2 }
|
||||
const missingFailedIteration = [
|
||||
createNodeTracing({
|
||||
id: 'failed-step',
|
||||
status: NodeRunningStatus.Failed,
|
||||
execution_metadata: createExecutionMetadata({
|
||||
iteration_index: 2,
|
||||
}),
|
||||
}),
|
||||
]
|
||||
const allExecutions = [
|
||||
createNodeTracing({
|
||||
id: 'parallel-step',
|
||||
execution_metadata: createExecutionMetadata({
|
||||
parallel_mode_run_id: 'parallel-1',
|
||||
}),
|
||||
}),
|
||||
createNodeTracing({
|
||||
id: 'serial-step',
|
||||
execution_metadata: createExecutionMetadata({
|
||||
iteration_id: 'iteration-node',
|
||||
iteration_index: 1,
|
||||
}),
|
||||
}),
|
||||
]
|
||||
|
||||
render(
|
||||
<IterationLogTrigger
|
||||
nodeInfo={createNodeTracing({
|
||||
details: [missingFailedIteration],
|
||||
execution_metadata: createExecutionMetadata({
|
||||
iteration_duration_map: iterationDurationMap,
|
||||
}),
|
||||
})}
|
||||
allExecutions={allExecutions}
|
||||
onShowIterationResultList={onShowIterationResultList}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
|
||||
expect(onShowIterationResultList).toHaveBeenCalledWith(
|
||||
[
|
||||
[allExecutions[0]],
|
||||
[allExecutions[1]],
|
||||
missingFailedIteration,
|
||||
],
|
||||
iterationDurationMap,
|
||||
)
|
||||
})
|
||||
|
||||
it('should fall back to details and metadata length when duration map is unavailable', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onShowIterationResultList = vi.fn()
|
||||
const detailList = [[createNodeTracing({ id: 'detail-1' })]]
|
||||
|
||||
render(
|
||||
<IterationLogTrigger
|
||||
nodeInfo={createNodeTracing({
|
||||
details: detailList,
|
||||
metadata: {
|
||||
iterator_length: 3,
|
||||
iterator_index: 0,
|
||||
loop_length: 0,
|
||||
loop_index: 0,
|
||||
},
|
||||
})}
|
||||
onShowIterationResultList={onShowIterationResultList}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: /workflow\.nodes\.iteration\.iteration/ })).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
|
||||
expect(onShowIterationResultList).toHaveBeenCalledWith(detailList, {})
|
||||
})
|
||||
|
||||
it('should return an empty structured list when duration map exists without executions', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onShowIterationResultList = vi.fn()
|
||||
const iterationDurationMap: IterationDurationMap = { orphaned: 1.5 }
|
||||
|
||||
render(
|
||||
<IterationLogTrigger
|
||||
nodeInfo={createNodeTracing({
|
||||
execution_metadata: createExecutionMetadata({
|
||||
iteration_duration_map: iterationDurationMap,
|
||||
}),
|
||||
})}
|
||||
onShowIterationResultList={onShowIterationResultList}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
|
||||
expect(onShowIterationResultList).toHaveBeenCalledWith([], iterationDurationMap)
|
||||
})
|
||||
|
||||
it('should count failed iterations from allExecutions and ignore unmatched duration map keys', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onShowIterationResultList = vi.fn()
|
||||
const iterationDurationMap: IterationDurationMap = { orphaned: 0.6, 1: 1.1 }
|
||||
const allExecutions = [
|
||||
createNodeTracing({
|
||||
id: 'failed-serial-step',
|
||||
status: NodeRunningStatus.Failed,
|
||||
execution_metadata: createExecutionMetadata({
|
||||
iteration_id: 'iteration-node',
|
||||
iteration_index: 1,
|
||||
}),
|
||||
}),
|
||||
]
|
||||
|
||||
render(
|
||||
<IterationLogTrigger
|
||||
nodeInfo={createNodeTracing({
|
||||
details: [[createNodeTracing({ id: 'detail-success' })]],
|
||||
execution_metadata: createExecutionMetadata({
|
||||
iteration_duration_map: iterationDurationMap,
|
||||
}),
|
||||
})}
|
||||
allExecutions={allExecutions}
|
||||
onShowIterationResultList={onShowIterationResultList}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: /workflow\.nodes\.iteration\.error/i })).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
|
||||
expect(onShowIterationResultList).toHaveBeenCalledWith([[allExecutions[0]]], iterationDurationMap)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,54 @@ type IterationLogTriggerProps = {
|
||||
allExecutions?: NodeTracing[]
|
||||
onShowIterationResultList: (iterationResultList: NodeTracing[][], iterationResultDurationMap: IterationDurationMap) => void
|
||||
}
|
||||
|
||||
const getIterationDurationMap = (nodeInfo: NodeTracing) => {
|
||||
return nodeInfo.iterDurationMap || nodeInfo.execution_metadata?.iteration_duration_map || {}
|
||||
}
|
||||
|
||||
const getDisplayIterationCount = (nodeInfo: NodeTracing) => {
|
||||
const iterationDurationMap = nodeInfo.execution_metadata?.iteration_duration_map
|
||||
if (iterationDurationMap)
|
||||
return Object.keys(iterationDurationMap).length
|
||||
if (nodeInfo.details?.length)
|
||||
return nodeInfo.details.length
|
||||
return nodeInfo.metadata?.iterator_length ?? 0
|
||||
}
|
||||
|
||||
const getFailedIterationIndices = (
|
||||
details: NodeTracing[][] | undefined,
|
||||
nodeInfo: NodeTracing,
|
||||
allExecutions?: NodeTracing[],
|
||||
) => {
|
||||
if (!details?.length)
|
||||
return new Set<number>()
|
||||
|
||||
const failedIterationIndices = new Set<number>()
|
||||
|
||||
details.forEach((iteration, index) => {
|
||||
if (!iteration.some(item => item.status === NodeRunningStatus.Failed))
|
||||
return
|
||||
|
||||
const iterationIndex = iteration[0]?.execution_metadata?.iteration_index ?? index
|
||||
failedIterationIndices.add(iterationIndex)
|
||||
})
|
||||
|
||||
if (!nodeInfo.execution_metadata?.iteration_duration_map || !allExecutions)
|
||||
return failedIterationIndices
|
||||
|
||||
allExecutions.forEach((execution) => {
|
||||
if (
|
||||
execution.execution_metadata?.iteration_id === nodeInfo.node_id
|
||||
&& execution.status === NodeRunningStatus.Failed
|
||||
&& execution.execution_metadata?.iteration_index !== undefined
|
||||
) {
|
||||
failedIterationIndices.add(execution.execution_metadata.iteration_index)
|
||||
}
|
||||
})
|
||||
|
||||
return failedIterationIndices
|
||||
}
|
||||
|
||||
const IterationLogTrigger = ({
|
||||
nodeInfo,
|
||||
allExecutions,
|
||||
@@ -20,7 +68,7 @@ const IterationLogTrigger = ({
|
||||
}: IterationLogTriggerProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const filterNodesForInstance = (key: string): NodeTracing[] => {
|
||||
const getNodesForInstance = (key: string): NodeTracing[] => {
|
||||
if (!allExecutions)
|
||||
return []
|
||||
|
||||
@@ -43,97 +91,59 @@ const IterationLogTrigger = ({
|
||||
return []
|
||||
}
|
||||
|
||||
const getStructuredIterationList = () => {
|
||||
const iterationNodeMeta = nodeInfo.execution_metadata
|
||||
|
||||
if (!iterationNodeMeta?.iteration_duration_map)
|
||||
return nodeInfo.details || []
|
||||
|
||||
const structuredList = Object.keys(iterationNodeMeta.iteration_duration_map)
|
||||
.map(getNodesForInstance)
|
||||
.filter(branchNodes => branchNodes.length > 0)
|
||||
|
||||
if (!allExecutions || !nodeInfo.details?.length)
|
||||
return structuredList
|
||||
|
||||
const existingIterationIndices = new Set<number>()
|
||||
structuredList.forEach((iteration) => {
|
||||
iteration.forEach((node) => {
|
||||
if (node.execution_metadata?.iteration_index !== undefined)
|
||||
existingIterationIndices.add(node.execution_metadata.iteration_index)
|
||||
})
|
||||
})
|
||||
|
||||
nodeInfo.details.forEach((iteration, index) => {
|
||||
if (
|
||||
!existingIterationIndices.has(index)
|
||||
&& iteration.some(node => node.status === NodeRunningStatus.Failed)
|
||||
) {
|
||||
structuredList.push(iteration)
|
||||
}
|
||||
})
|
||||
|
||||
return structuredList.sort((a, b) => {
|
||||
const aIndex = a[0]?.execution_metadata?.iteration_index ?? 0
|
||||
const bIndex = b[0]?.execution_metadata?.iteration_index ?? 0
|
||||
return aIndex - bIndex
|
||||
})
|
||||
}
|
||||
|
||||
const handleOnShowIterationDetail = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
e.nativeEvent.stopImmediatePropagation()
|
||||
|
||||
const iterationNodeMeta = nodeInfo.execution_metadata
|
||||
const iterDurationMap = nodeInfo?.iterDurationMap || iterationNodeMeta?.iteration_duration_map || {}
|
||||
|
||||
let structuredList: NodeTracing[][] = []
|
||||
if (iterationNodeMeta?.iteration_duration_map) {
|
||||
const instanceKeys = Object.keys(iterationNodeMeta.iteration_duration_map)
|
||||
structuredList = instanceKeys
|
||||
.map(key => filterNodesForInstance(key))
|
||||
.filter(branchNodes => branchNodes.length > 0)
|
||||
|
||||
// Also include failed iterations that might not be in duration map
|
||||
if (allExecutions && nodeInfo.details?.length) {
|
||||
const existingIterationIndices = new Set<number>()
|
||||
structuredList.forEach((iteration) => {
|
||||
iteration.forEach((node) => {
|
||||
if (node.execution_metadata?.iteration_index !== undefined)
|
||||
existingIterationIndices.add(node.execution_metadata.iteration_index)
|
||||
})
|
||||
})
|
||||
|
||||
// Find failed iterations that are not in the structured list
|
||||
nodeInfo.details.forEach((iteration, index) => {
|
||||
if (!existingIterationIndices.has(index) && iteration.some(node => node.status === NodeRunningStatus.Failed))
|
||||
structuredList.push(iteration)
|
||||
})
|
||||
|
||||
// Sort by iteration index to maintain order
|
||||
structuredList.sort((a, b) => {
|
||||
const aIndex = a[0]?.execution_metadata?.iteration_index ?? 0
|
||||
const bIndex = b[0]?.execution_metadata?.iteration_index ?? 0
|
||||
return aIndex - bIndex
|
||||
})
|
||||
}
|
||||
}
|
||||
else if (nodeInfo.details?.length) {
|
||||
structuredList = nodeInfo.details
|
||||
}
|
||||
|
||||
onShowIterationResultList(structuredList, iterDurationMap)
|
||||
onShowIterationResultList(getStructuredIterationList(), getIterationDurationMap(nodeInfo))
|
||||
}
|
||||
|
||||
let displayIterationCount = 0
|
||||
const iterMap = nodeInfo.execution_metadata?.iteration_duration_map
|
||||
if (iterMap)
|
||||
displayIterationCount = Object.keys(iterMap).length
|
||||
else if (nodeInfo.details?.length)
|
||||
displayIterationCount = nodeInfo.details.length
|
||||
else if (nodeInfo.metadata?.iterator_length)
|
||||
displayIterationCount = nodeInfo.metadata.iterator_length
|
||||
|
||||
const getErrorCount = (details: NodeTracing[][] | undefined, iterationNodeMeta?: any) => {
|
||||
if (!details || details.length === 0)
|
||||
return 0
|
||||
|
||||
// Use Set to track failed iteration indices to avoid duplicate counting
|
||||
const failedIterationIndices = new Set<number>()
|
||||
|
||||
// Collect failed iteration indices from details
|
||||
details.forEach((iteration, index) => {
|
||||
if (iteration.some(item => item.status === NodeRunningStatus.Failed)) {
|
||||
// Try to get iteration index from first node, fallback to array index
|
||||
const iterationIndex = iteration[0]?.execution_metadata?.iteration_index ?? index
|
||||
failedIterationIndices.add(iterationIndex)
|
||||
}
|
||||
})
|
||||
|
||||
// If allExecutions exists, check for additional failed iterations
|
||||
if (iterationNodeMeta?.iteration_duration_map && allExecutions) {
|
||||
// Find all failed iteration nodes
|
||||
allExecutions.forEach((exec) => {
|
||||
if (exec.execution_metadata?.iteration_id === nodeInfo.node_id
|
||||
&& exec.status === NodeRunningStatus.Failed
|
||||
&& exec.execution_metadata?.iteration_index !== undefined) {
|
||||
failedIterationIndices.add(exec.execution_metadata.iteration_index)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return failedIterationIndices.size
|
||||
}
|
||||
const errorCount = getErrorCount(nodeInfo.details, nodeInfo.execution_metadata)
|
||||
const displayIterationCount = getDisplayIterationCount(nodeInfo)
|
||||
const errorCount = getFailedIterationIndices(nodeInfo.details, nodeInfo, allExecutions).size
|
||||
|
||||
return (
|
||||
<Button
|
||||
className="flex w-full cursor-pointer items-center gap-2 self-stretch rounded-lg border-none bg-components-button-tertiary-bg-hover px-3 py-2 hover:bg-components-button-tertiary-bg-hover"
|
||||
onClick={handleOnShowIterationDetail}
|
||||
>
|
||||
{/* eslint-disable-next-line hyoban/prefer-tailwind-icons */}
|
||||
<Iteration className="h-4 w-4 shrink-0 text-components-button-tertiary-text" />
|
||||
<div className="system-sm-medium flex-1 text-left text-components-button-tertiary-text">
|
||||
{t('nodes.iteration.iteration', { ns: 'workflow', count: displayIterationCount })}
|
||||
@@ -144,6 +154,7 @@ const IterationLogTrigger = ({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* eslint-disable-next-line hyoban/prefer-tailwind-icons */}
|
||||
<RiArrowRightSLine className="h-4 w-4 shrink-0 text-components-button-tertiary-text" />
|
||||
</Button>
|
||||
)
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { Viewport } from '@/next'
|
||||
import { Agentation } from 'agentation'
|
||||
import { Provider as JotaiProvider } from 'jotai/react'
|
||||
import { ThemeProvider } from 'next-themes'
|
||||
import { NuqsAdapter } from 'nuqs/adapters/next/app'
|
||||
import { IS_DEV } from '@/config'
|
||||
import GlobalPublicStoreProvider from '@/context/global-public-context'
|
||||
import { TanstackQueryInitializer } from '@/context/query-client'
|
||||
import { getDatasetMap } from '@/env'
|
||||
@@ -12,9 +10,10 @@ import { ToastProvider } from './components/base/toast'
|
||||
import { ToastHost } from './components/base/ui/toast'
|
||||
import { TooltipProvider } from './components/base/ui/tooltip'
|
||||
import BrowserInitializer from './components/browser-initializer'
|
||||
import { AgentationLoader } from './components/devtools/agentation-loader'
|
||||
import { ReactScanLoader } from './components/devtools/react-scan/loader'
|
||||
import LazySentryInitializer from './components/lazy-sentry-initializer'
|
||||
import { I18nServerProvider } from './components/provider/i18n-server'
|
||||
import SentryInitializer from './components/sentry-initializer'
|
||||
import RoutePrefixHandle from './routePrefixHandle'
|
||||
import './styles/globals.css'
|
||||
import './styles/markdown.scss'
|
||||
@@ -57,6 +56,7 @@ const LocaleLayout = async ({
|
||||
className="h-full select-auto"
|
||||
{...datasetMap}
|
||||
>
|
||||
<LazySentryInitializer />
|
||||
<div className="isolate h-full">
|
||||
<JotaiProvider>
|
||||
<ThemeProvider
|
||||
@@ -68,26 +68,24 @@ const LocaleLayout = async ({
|
||||
>
|
||||
<NuqsAdapter>
|
||||
<BrowserInitializer>
|
||||
<SentryInitializer>
|
||||
<TanstackQueryInitializer>
|
||||
<I18nServerProvider>
|
||||
<ToastHost timeout={5000} limit={3} />
|
||||
<ToastProvider>
|
||||
<GlobalPublicStoreProvider>
|
||||
<TooltipProvider delay={300} closeDelay={200}>
|
||||
{children}
|
||||
</TooltipProvider>
|
||||
</GlobalPublicStoreProvider>
|
||||
</ToastProvider>
|
||||
</I18nServerProvider>
|
||||
</TanstackQueryInitializer>
|
||||
</SentryInitializer>
|
||||
<TanstackQueryInitializer>
|
||||
<I18nServerProvider>
|
||||
<ToastHost timeout={5000} limit={3} />
|
||||
<ToastProvider>
|
||||
<GlobalPublicStoreProvider>
|
||||
<TooltipProvider delay={300} closeDelay={200}>
|
||||
{children}
|
||||
</TooltipProvider>
|
||||
</GlobalPublicStoreProvider>
|
||||
</ToastProvider>
|
||||
</I18nServerProvider>
|
||||
</TanstackQueryInitializer>
|
||||
</BrowserInitializer>
|
||||
</NuqsAdapter>
|
||||
</ThemeProvider>
|
||||
</JotaiProvider>
|
||||
<RoutePrefixHandle />
|
||||
{IS_DEV && <Agentation />}
|
||||
<AgentationLoader />
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -42,6 +42,8 @@ export const AMPLITUDE_API_KEY = getStringConfig(
|
||||
'',
|
||||
)
|
||||
|
||||
export const isAmplitudeEnabled = IS_CLOUD_EDITION && !!AMPLITUDE_API_KEY
|
||||
|
||||
export const IS_DEV = process.env.NODE_ENV === 'development'
|
||||
export const IS_PROD = process.env.NODE_ENV === 'production'
|
||||
|
||||
|
||||
@@ -1501,11 +1501,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/base/amplitude/AmplitudeProvider.tsx": {
|
||||
"react-refresh/only-export-components": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/base/amplitude/utils.ts": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
@@ -6592,9 +6587,6 @@
|
||||
"app/components/workflow/block-selector/tabs.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/workflow/block-selector/tool-picker.tsx": {
|
||||
@@ -6726,9 +6718,6 @@
|
||||
},
|
||||
"react-refresh/only-export-components": {
|
||||
"count": 1
|
||||
},
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/workflow/header/undo-redo.tsx": {
|
||||
@@ -6818,11 +6807,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/workflow/hooks/use-workflow-interactions.ts": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/workflow/hooks/use-workflow-run-event/use-workflow-agent-log.ts": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
@@ -7800,9 +7784,6 @@
|
||||
"app/components/workflow/nodes/human-input/components/variable-in-markdown.tsx": {
|
||||
"react-refresh/only-export-components": {
|
||||
"count": 2
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 8
|
||||
}
|
||||
},
|
||||
"app/components/workflow/nodes/human-input/node.tsx": {
|
||||
@@ -8465,7 +8446,7 @@
|
||||
},
|
||||
"app/components/workflow/nodes/loop/use-single-run-form-params.ts": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 4
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"app/components/workflow/nodes/parameter-extractor/components/extract-parameter/update.tsx": {
|
||||
@@ -9182,9 +9163,6 @@
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"unicorn/prefer-number-properties": {
|
||||
"count": 1
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user