Compare commits

...

13 Commits

Author SHA1 Message Date
Coding On Star
79edee0610 Merge branch 'main' into test/workflow-part-7 2026-03-24 15:34:15 +08:00
CodingOnStar
cfd5994a90 test(workflow): refactor GenericTable tests to use controlled component for state management
- Introduced a ControlledTable component to manage state within the GenericTable tests.
- Updated test interactions to utilize async waitFor for better handling of asynchronous updates.
- Enhanced test coverage for button interactions and their expected outcomes.
2026-03-24 15:33:40 +08:00
Stephen Zhou
0c3d11f920 refactor: lazy load large modules (#33888)
Some checks are pending
autofix.ci / autofix (push) Waiting to run
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Waiting to run
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Waiting to run
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Waiting to run
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Waiting to run
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Blocked by required conditions
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Blocked by required conditions
Main CI Pipeline / Check Changed Files (push) Waiting to run
Main CI Pipeline / API Tests (push) Blocked by required conditions
Main CI Pipeline / Web Tests (push) Blocked by required conditions
Main CI Pipeline / Style Check (push) Waiting to run
Main CI Pipeline / VDB Tests (push) Blocked by required conditions
Main CI Pipeline / DB Migration Test (push) Blocked by required conditions
2026-03-24 15:29:42 +08:00
CodingOnStar
d8704d7124 test(workflow): add tests for edge interactions, curl panel parsing, and iteration log behavior
- Implement tests for handling edge deletion by ID and source handle changes in useEdgesInteractions.
- Update curl-panel tests to use the curlParser module for parsing commands.
- Add tests to ensure iteration log correctly handles empty structured lists and counts failed iterations based on execution data.
2026-03-24 15:12:06 +08:00
QuantumGhost
1674f8c2fb fix: fix omitted app icon_type updates (#33988)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-24 15:10:05 +08:00
Zhanyuan Guo
7fe25f1365 fix(rate_limit): flush redis cache when __init__ is triggered by changing max_active_requests (#33830)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-24 15:08:55 +08:00
Coding On Star
98b2a36219 Merge branch 'main' into test/workflow-part-7 2026-03-24 14:43:08 +08:00
CodingOnStar
85d62c5a48 test(workflow): add comprehensive unit tests for loop and trigger webhook components 2026-03-24 14:21:33 +08:00
autofix-ci[bot]
5c074b6508 [autofix.ci] apply automated fixes 2026-03-24 03:29:27 +00:00
CodingOnStar
4ce3e23057 test(workflow): enhance tab component tests and improve tool state management 2026-03-24 11:26:26 +08:00
autofix-ci[bot]
2ba96f92b6 [autofix.ci] apply automated fixes 2026-03-24 02:56:42 +00:00
CodingOnStar
8695435607 test(workflow): add helper specs and raise targeted workflow coverage 2026-03-24 10:50:59 +08:00
CodingOnStar
169511e68b test(workflow): refactor low-risk components and add phase 1 coverage 2026-03-23 17:29:06 +08:00
98 changed files with 9786 additions and 2627 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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',
}))
})
})

View File

@@ -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()
})
})

View File

@@ -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()
})
})

View File

@@ -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>

View File

@@ -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
})

View File

@@ -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])

View File

@@ -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 }),
}))
})
})

View File

@@ -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
})

View File

@@ -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,
}
}

View File

@@ -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()
})
})
})

View File

@@ -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,

View File

@@ -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],
[],
)
})
})

View File

@@ -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>

View File

@@ -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)
})
})
})

View File

@@ -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>
)

View File

@@ -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>

View File

@@ -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'

View File

@@ -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
}