Compare commits

..

14 Commits

Author SHA1 Message Date
JzoNg
c964708ebe Merge branch 'main' into jzh 2026-03-18 18:07:20 +08:00
JzoNg
883eb498c0 Merge branch 'main' into jzh 2026-03-18 17:40:51 +08:00
wangxiaolei
a87b928079 feat: remove weaviate client __del__ method (#33593)
Co-authored-by: QuantumGhost <obelisk.reg+git@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-18 17:39:59 +08:00
yyh
93f9546353 refactor(web): migrate core toast call sites to base ui toast (#33643) 2026-03-18 16:53:55 +08:00
Coding On Star
db4deb1d6b test(workflow): reorganize specs into __tests__ and align with shared test infrastructure (#33625)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-18 16:40:28 +08:00
wangxiaolei
387e5a345f fix(api): make CreatorUserRole accept both end-user and end_user (#33638)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-18 14:54:12 +08:00
JzoNg
4d3738d225 Merge branch 'main' into feat/evaluation-fe 2026-03-17 10:42:44 +08:00
JzoNg
dd0dee739d Merge branch 'main' into jzh 2026-03-16 15:43:20 +08:00
zxhlyh
4d19914fcb Merge branch 'main' into feat/evaluation-fe 2026-03-16 10:47:37 +08:00
zxhlyh
887c7710e9 feat: evaluation 2026-03-16 10:46:33 +08:00
zxhlyh
7a722773c7 feat: snippet canvas 2026-03-13 17:45:04 +08:00
zxhlyh
a763aff58b feat: snippets list 2026-03-13 16:12:42 +08:00
zxhlyh
c1011f4e5c feat: add to snippet 2026-03-13 14:29:59 +08:00
zxhlyh
f7afa103a5 feat: select snippets 2026-03-13 13:43:29 +08:00
136 changed files with 9042 additions and 1676 deletions

View File

@@ -134,6 +134,7 @@ class EducationAutocompleteQuery(BaseModel):
class ChangeEmailSendPayload(BaseModel):
email: EmailStr
language: str | None = None
phase: str | None = None
token: str | None = None
@@ -547,17 +548,13 @@ class ChangeEmailSendEmailApi(Resource):
account = None
user_email = None
email_for_sending = args.email.lower()
send_phase = AccountService.CHANGE_EMAIL_PHASE_OLD
if args.token is not None:
send_phase = AccountService.CHANGE_EMAIL_PHASE_NEW
if args.phase is not None and args.phase == "new_email":
if args.token is None:
raise InvalidTokenError()
reset_data = AccountService.get_change_email_data(args.token)
if reset_data is None:
raise InvalidTokenError()
reset_token_phase = reset_data.get(AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY)
if reset_token_phase != AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED:
raise InvalidTokenError()
user_email = reset_data.get("email", "")
if user_email.lower() != current_user.email.lower():
@@ -577,7 +574,7 @@ class ChangeEmailSendEmailApi(Resource):
email=email_for_sending,
old_email=user_email,
language=language,
phase=send_phase,
phase=args.phase,
)
return {"result": "success", "data": token}
@@ -612,26 +609,12 @@ class ChangeEmailCheckApi(Resource):
AccountService.add_change_email_error_rate_limit(user_email)
raise EmailCodeError()
phase_transitions: dict[str, str] = {
AccountService.CHANGE_EMAIL_PHASE_OLD: AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED,
AccountService.CHANGE_EMAIL_PHASE_NEW: AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED,
}
token_phase = token_data.get(AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY)
if not isinstance(token_phase, str):
raise InvalidTokenError()
refreshed_phase = phase_transitions.get(token_phase)
if refreshed_phase is None:
raise InvalidTokenError()
# Verified, revoke the first token
AccountService.revoke_change_email_token(args.token)
# Refresh token data by generating a new token
_, new_token = AccountService.generate_change_email_token(
user_email,
code=args.code,
old_email=token_data.get("old_email"),
additional_data={AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: refreshed_phase},
user_email, code=args.code, old_email=token_data.get("old_email"), additional_data={}
)
AccountService.reset_change_email_error_rate_limit(user_email)
@@ -661,22 +644,13 @@ class ChangeEmailResetApi(Resource):
if not reset_data:
raise InvalidTokenError()
token_phase = reset_data.get(AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY)
if token_phase != AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED:
raise InvalidTokenError()
token_email = reset_data.get("email")
normalized_token_email = token_email.lower() if isinstance(token_email, str) else token_email
if normalized_token_email != normalized_new_email:
raise InvalidTokenError()
AccountService.revoke_change_email_token(args.token)
old_email = reset_data.get("old_email", "")
current_user, _ = current_account_with_tenant()
if current_user.email.lower() != old_email.lower():
raise AccountNotFound()
AccountService.revoke_change_email_token(args.token)
updated_account = AccountService.update_account_email(current_user, email=normalized_new_email)
AccountService.send_change_email_completed_notify_email(

View File

@@ -5,6 +5,7 @@ This module provides integration with Weaviate vector database for storing and r
document embeddings used in retrieval-augmented generation workflows.
"""
import atexit
import datetime
import json
import logging
@@ -37,6 +38,32 @@ _weaviate_client: weaviate.WeaviateClient | None = None
_weaviate_client_lock = threading.Lock()
def _shutdown_weaviate_client() -> None:
"""
Best-effort shutdown hook to close the module-level Weaviate client.
This is registered with atexit so that HTTP/gRPC resources are released
when the Python interpreter exits.
"""
global _weaviate_client
# Ensure thread-safety when accessing the shared client instance
with _weaviate_client_lock:
client = _weaviate_client
_weaviate_client = None
if client is not None:
try:
client.close()
except Exception:
# Best-effort cleanup; log at debug level and ignore errors.
logger.debug("Failed to close Weaviate client during shutdown", exc_info=True)
# Register the shutdown hook once per process.
atexit.register(_shutdown_weaviate_client)
class WeaviateConfig(BaseModel):
"""
Configuration model for Weaviate connection settings.
@@ -85,18 +112,6 @@ class WeaviateVector(BaseVector):
self._client = self._init_client(config)
self._attributes = attributes
def __del__(self):
"""
Destructor to properly close the Weaviate client connection.
Prevents connection leaks and resource warnings.
"""
if hasattr(self, "_client") and self._client is not None:
try:
self._client.close()
except Exception as e:
# Ignore errors during cleanup as object is being destroyed
logger.warning("Error closing Weaviate client %s", e, exc_info=True)
def _init_client(self, config: WeaviateConfig) -> weaviate.WeaviateClient:
"""
Initializes and returns a connected Weaviate client.

View File

@@ -11,6 +11,13 @@ class CreatorUserRole(StrEnum):
ACCOUNT = "account"
END_USER = "end_user"
@classmethod
def _missing_(cls, value):
if value == "end-user":
return cls.END_USER
else:
return super()._missing_(value)
class WorkflowRunTriggeredFrom(StrEnum):
DEBUGGING = "debugging"

View File

@@ -4,7 +4,6 @@ import logging
import secrets
import uuid
from datetime import UTC, datetime, timedelta
from enum import StrEnum
from hashlib import sha256
from typing import Any, cast
@@ -91,25 +90,12 @@ class TokenPair(BaseModel):
csrf_token: str
class ChangeEmailPhase(StrEnum):
OLD = "old_email"
OLD_VERIFIED = "old_email_verified"
NEW = "new_email"
NEW_VERIFIED = "new_email_verified"
REFRESH_TOKEN_PREFIX = "refresh_token:"
ACCOUNT_REFRESH_TOKEN_PREFIX = "account_refresh_token:"
REFRESH_TOKEN_EXPIRY = timedelta(days=dify_config.REFRESH_TOKEN_EXPIRE_DAYS)
class AccountService:
CHANGE_EMAIL_TOKEN_PHASE_KEY = "email_change_phase"
CHANGE_EMAIL_PHASE_OLD = ChangeEmailPhase.OLD
CHANGE_EMAIL_PHASE_OLD_VERIFIED = ChangeEmailPhase.OLD_VERIFIED
CHANGE_EMAIL_PHASE_NEW = ChangeEmailPhase.NEW
CHANGE_EMAIL_PHASE_NEW_VERIFIED = ChangeEmailPhase.NEW_VERIFIED
reset_password_rate_limiter = RateLimiter(prefix="reset_password_rate_limit", max_attempts=1, time_window=60 * 1)
email_register_rate_limiter = RateLimiter(prefix="email_register_rate_limit", max_attempts=1, time_window=60 * 1)
email_code_login_rate_limiter = RateLimiter(
@@ -566,20 +552,13 @@ class AccountService:
raise ValueError("Email must be provided.")
if not phase:
raise ValueError("phase must be provided.")
if phase not in (cls.CHANGE_EMAIL_PHASE_OLD, cls.CHANGE_EMAIL_PHASE_NEW):
raise ValueError("phase must be one of old_email or new_email.")
if cls.change_email_rate_limiter.is_rate_limited(account_email):
from controllers.console.auth.error import EmailChangeRateLimitExceededError
raise EmailChangeRateLimitExceededError(int(cls.change_email_rate_limiter.time_window / 60))
code, token = cls.generate_change_email_token(
account_email,
account,
old_email=old_email,
additional_data={cls.CHANGE_EMAIL_TOKEN_PHASE_KEY: phase},
)
code, token = cls.generate_change_email_token(account_email, account, old_email=old_email)
send_change_mail_task.delay(
language=language,

View File

@@ -950,16 +950,6 @@ class TestWorkflowAppService:
assert result_with_new_email["total"] == 3
assert all(log.created_by_role == CreatorUserRole.ACCOUNT for log in result_with_new_email["data"])
# Create another account in a different tenant using the original email.
# Querying by the old email should still fail for this app's tenant.
cross_tenant_account = AccountService.create_account(
email=original_email,
name=fake.name(),
interface_language="en-US",
password=fake.password(length=12),
)
TenantService.create_owner_tenant_if_not_exist(cross_tenant_account, name=fake.company())
# Old email unbound, is unexpected input, should raise ValueError
with pytest.raises(ValueError) as exc_info:
service.get_paginate_workflow_app_logs(

View File

@@ -4,7 +4,6 @@ from unittest.mock import MagicMock, patch
import pytest
from flask import Flask, g
from controllers.console.auth.error import InvalidTokenError
from controllers.console.workspace.account import (
AccountDeleteUpdateFeedbackApi,
ChangeEmailCheckApi,
@@ -53,7 +52,7 @@ class TestChangeEmailSend:
@patch("controllers.console.workspace.account.extract_remote_ip", return_value="127.0.0.1")
@patch("libs.login.check_csrf_token", return_value=None)
@patch("controllers.console.wraps.FeatureService.get_system_features")
def test_should_infer_new_email_phase_from_token(
def test_should_normalize_new_email_phase(
self,
mock_features,
mock_csrf,
@@ -69,16 +68,13 @@ class TestChangeEmailSend:
mock_features.return_value = SimpleNamespace(enable_change_email=True)
mock_account = _build_account("current@example.com", "acc1")
mock_current_account.return_value = (mock_account, None)
mock_get_change_data.return_value = {
"email": "current@example.com",
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED,
}
mock_get_change_data.return_value = {"email": "current@example.com"}
mock_send_email.return_value = "token-abc"
with app.test_request_context(
"/account/change-email",
method="POST",
json={"email": "New@Example.com", "language": "en-US", "token": "token-123"},
json={"email": "New@Example.com", "language": "en-US", "phase": "new_email", "token": "token-123"},
):
_set_logged_in_user(_build_account("tester@example.com", "tester"))
response = ChangeEmailSendEmailApi().post()
@@ -95,107 +91,6 @@ class TestChangeEmailSend:
mock_is_ip_limit.assert_called_once_with("127.0.0.1")
mock_csrf.assert_called_once()
@patch("controllers.console.wraps.db")
@patch("controllers.console.workspace.account.current_account_with_tenant")
@patch("controllers.console.workspace.account.db")
@patch("controllers.console.workspace.account.Session")
@patch("controllers.console.workspace.account.AccountService.get_account_by_email_with_case_fallback")
@patch("controllers.console.workspace.account.AccountService.send_change_email_email")
@patch("controllers.console.workspace.account.AccountService.is_email_send_ip_limit", return_value=False)
@patch("controllers.console.workspace.account.extract_remote_ip", return_value="127.0.0.1")
@patch("libs.login.check_csrf_token", return_value=None)
@patch("controllers.console.wraps.FeatureService.get_system_features")
def test_should_ignore_client_phase_and_use_old_phase_when_token_missing(
self,
mock_features,
mock_csrf,
mock_extract_ip,
mock_is_ip_limit,
mock_send_email,
mock_get_account_by_email,
mock_session_cls,
mock_account_db,
mock_current_account,
mock_db,
app,
):
_mock_wraps_db(mock_db)
mock_features.return_value = SimpleNamespace(enable_change_email=True)
mock_current_account.return_value = (_build_account("current@example.com", "current"), None)
existing_account = _build_account("old@example.com", "acc-old")
mock_get_account_by_email.return_value = existing_account
mock_send_email.return_value = "token-legacy"
mock_session = MagicMock()
mock_session_cm = MagicMock()
mock_session_cm.__enter__.return_value = mock_session
mock_session_cm.__exit__.return_value = None
mock_session_cls.return_value = mock_session_cm
mock_account_db.engine = MagicMock()
with app.test_request_context(
"/account/change-email",
method="POST",
json={"email": "old@example.com", "language": "en-US", "phase": "new_email"},
):
_set_logged_in_user(_build_account("tester@example.com", "tester"))
response = ChangeEmailSendEmailApi().post()
assert response == {"result": "success", "data": "token-legacy"}
mock_get_account_by_email.assert_called_once_with("old@example.com", session=mock_session)
mock_send_email.assert_called_once_with(
account=existing_account,
email="old@example.com",
old_email="old@example.com",
language="en-US",
phase=AccountService.CHANGE_EMAIL_PHASE_OLD,
)
mock_extract_ip.assert_called_once()
mock_is_ip_limit.assert_called_once_with("127.0.0.1")
mock_csrf.assert_called_once()
@patch("controllers.console.wraps.db")
@patch("controllers.console.workspace.account.current_account_with_tenant")
@patch("controllers.console.workspace.account.AccountService.get_change_email_data")
@patch("controllers.console.workspace.account.AccountService.send_change_email_email")
@patch("controllers.console.workspace.account.AccountService.is_email_send_ip_limit", return_value=False)
@patch("controllers.console.workspace.account.extract_remote_ip", return_value="127.0.0.1")
@patch("libs.login.check_csrf_token", return_value=None)
@patch("controllers.console.wraps.FeatureService.get_system_features")
def test_should_reject_unverified_old_email_token_for_new_email_phase(
self,
mock_features,
mock_csrf,
mock_extract_ip,
mock_is_ip_limit,
mock_send_email,
mock_get_change_data,
mock_current_account,
mock_db,
app,
):
_mock_wraps_db(mock_db)
mock_features.return_value = SimpleNamespace(enable_change_email=True)
mock_account = _build_account("current@example.com", "acc1")
mock_current_account.return_value = (mock_account, None)
mock_get_change_data.return_value = {
"email": "current@example.com",
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_OLD,
}
with app.test_request_context(
"/account/change-email",
method="POST",
json={"email": "New@Example.com", "language": "en-US", "token": "token-123"},
):
_set_logged_in_user(_build_account("tester@example.com", "tester"))
with pytest.raises(InvalidTokenError):
ChangeEmailSendEmailApi().post()
mock_send_email.assert_not_called()
mock_extract_ip.assert_called_once()
mock_is_ip_limit.assert_called_once_with("127.0.0.1")
mock_csrf.assert_called_once()
class TestChangeEmailValidity:
@patch("controllers.console.wraps.db")
@@ -227,12 +122,7 @@ class TestChangeEmailValidity:
mock_account = _build_account("user@example.com", "acc2")
mock_current_account.return_value = (mock_account, None)
mock_is_rate_limit.return_value = False
mock_get_data.return_value = {
"email": "user@example.com",
"code": "1234",
"old_email": "old@example.com",
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_OLD,
}
mock_get_data.return_value = {"email": "user@example.com", "code": "1234", "old_email": "old@example.com"}
mock_generate_token.return_value = (None, "new-token")
with app.test_request_context(
@@ -248,76 +138,11 @@ class TestChangeEmailValidity:
mock_add_rate.assert_not_called()
mock_revoke_token.assert_called_once_with("token-123")
mock_generate_token.assert_called_once_with(
"user@example.com",
code="1234",
old_email="old@example.com",
additional_data={
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED
},
"user@example.com", code="1234", old_email="old@example.com", additional_data={}
)
mock_reset_rate.assert_called_once_with("user@example.com")
mock_csrf.assert_called_once()
@patch("controllers.console.wraps.db")
@patch("controllers.console.workspace.account.current_account_with_tenant")
@patch("controllers.console.workspace.account.AccountService.reset_change_email_error_rate_limit")
@patch("controllers.console.workspace.account.AccountService.generate_change_email_token")
@patch("controllers.console.workspace.account.AccountService.revoke_change_email_token")
@patch("controllers.console.workspace.account.AccountService.add_change_email_error_rate_limit")
@patch("controllers.console.workspace.account.AccountService.get_change_email_data")
@patch("controllers.console.workspace.account.AccountService.is_change_email_error_rate_limit")
@patch("libs.login.check_csrf_token", return_value=None)
@patch("controllers.console.wraps.FeatureService.get_system_features")
def test_should_refresh_new_email_phase_to_verified(
self,
mock_features,
mock_csrf,
mock_is_rate_limit,
mock_get_data,
mock_add_rate,
mock_revoke_token,
mock_generate_token,
mock_reset_rate,
mock_current_account,
mock_db,
app,
):
_mock_wraps_db(mock_db)
mock_features.return_value = SimpleNamespace(enable_change_email=True)
mock_account = _build_account("old@example.com", "acc2")
mock_current_account.return_value = (mock_account, None)
mock_is_rate_limit.return_value = False
mock_get_data.return_value = {
"email": "new@example.com",
"code": "5678",
"old_email": "old@example.com",
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_NEW,
}
mock_generate_token.return_value = (None, "new-phase-token")
with app.test_request_context(
"/account/change-email/validity",
method="POST",
json={"email": "New@Example.com", "code": "5678", "token": "token-456"},
):
_set_logged_in_user(_build_account("tester@example.com", "tester"))
response = ChangeEmailCheckApi().post()
assert response == {"is_valid": True, "email": "new@example.com", "token": "new-phase-token"}
mock_is_rate_limit.assert_called_once_with("new@example.com")
mock_add_rate.assert_not_called()
mock_revoke_token.assert_called_once_with("token-456")
mock_generate_token.assert_called_once_with(
"new@example.com",
code="5678",
old_email="old@example.com",
additional_data={
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED
},
)
mock_reset_rate.assert_called_once_with("new@example.com")
mock_csrf.assert_called_once()
class TestChangeEmailReset:
@patch("controllers.console.wraps.db")
@@ -350,11 +175,7 @@ class TestChangeEmailReset:
mock_current_account.return_value = (current_user, None)
mock_is_freeze.return_value = False
mock_check_unique.return_value = True
mock_get_data.return_value = {
"old_email": "OLD@example.com",
"email": "new@example.com",
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED,
}
mock_get_data.return_value = {"old_email": "OLD@example.com"}
mock_account_after_update = _build_account("new@example.com", "acc3-updated")
mock_update_account.return_value = mock_account_after_update
@@ -373,106 +194,6 @@ class TestChangeEmailReset:
mock_send_notify.assert_called_once_with(email="new@example.com")
mock_csrf.assert_called_once()
@patch("controllers.console.wraps.db")
@patch("controllers.console.workspace.account.current_account_with_tenant")
@patch("controllers.console.workspace.account.AccountService.send_change_email_completed_notify_email")
@patch("controllers.console.workspace.account.AccountService.update_account_email")
@patch("controllers.console.workspace.account.AccountService.revoke_change_email_token")
@patch("controllers.console.workspace.account.AccountService.get_change_email_data")
@patch("controllers.console.workspace.account.AccountService.check_email_unique")
@patch("controllers.console.workspace.account.AccountService.is_account_in_freeze")
@patch("libs.login.check_csrf_token", return_value=None)
@patch("controllers.console.wraps.FeatureService.get_system_features")
def test_should_reject_old_phase_token_for_reset(
self,
mock_features,
mock_csrf,
mock_is_freeze,
mock_check_unique,
mock_get_data,
mock_revoke_token,
mock_update_account,
mock_send_notify,
mock_current_account,
mock_db,
app,
):
_mock_wraps_db(mock_db)
mock_features.return_value = SimpleNamespace(enable_change_email=True)
current_user = _build_account("old@example.com", "acc3")
mock_current_account.return_value = (current_user, None)
mock_is_freeze.return_value = False
mock_check_unique.return_value = True
mock_get_data.return_value = {
"old_email": "OLD@example.com",
"email": "old@example.com",
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_OLD,
}
with app.test_request_context(
"/account/change-email/reset",
method="POST",
json={"new_email": "new@example.com", "token": "token-123"},
):
_set_logged_in_user(_build_account("tester@example.com", "tester"))
with pytest.raises(InvalidTokenError):
ChangeEmailResetApi().post()
mock_revoke_token.assert_not_called()
mock_update_account.assert_not_called()
mock_send_notify.assert_not_called()
mock_csrf.assert_called_once()
@patch("controllers.console.wraps.db")
@patch("controllers.console.workspace.account.current_account_with_tenant")
@patch("controllers.console.workspace.account.AccountService.send_change_email_completed_notify_email")
@patch("controllers.console.workspace.account.AccountService.update_account_email")
@patch("controllers.console.workspace.account.AccountService.revoke_change_email_token")
@patch("controllers.console.workspace.account.AccountService.get_change_email_data")
@patch("controllers.console.workspace.account.AccountService.check_email_unique")
@patch("controllers.console.workspace.account.AccountService.is_account_in_freeze")
@patch("libs.login.check_csrf_token", return_value=None)
@patch("controllers.console.wraps.FeatureService.get_system_features")
def test_should_reject_mismatched_new_email_for_verified_token(
self,
mock_features,
mock_csrf,
mock_is_freeze,
mock_check_unique,
mock_get_data,
mock_revoke_token,
mock_update_account,
mock_send_notify,
mock_current_account,
mock_db,
app,
):
_mock_wraps_db(mock_db)
mock_features.return_value = SimpleNamespace(enable_change_email=True)
current_user = _build_account("old@example.com", "acc3")
mock_current_account.return_value = (current_user, None)
mock_is_freeze.return_value = False
mock_check_unique.return_value = True
mock_get_data.return_value = {
"old_email": "OLD@example.com",
"email": "another@example.com",
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED,
}
with app.test_request_context(
"/account/change-email/reset",
method="POST",
json={"new_email": "new@example.com", "token": "token-789"},
):
_set_logged_in_user(_build_account("tester@example.com", "tester"))
with pytest.raises(InvalidTokenError):
ChangeEmailResetApi().post()
mock_revoke_token.assert_not_called()
mock_update_account.assert_not_called()
mock_send_notify.assert_not_called()
mock_csrf.assert_called_once()
class TestAccountDeletionFeedback:
@patch("controllers.console.wraps.db")

View File

@@ -0,0 +1,19 @@
import pytest
from models.enums import CreatorUserRole
def test_creator_user_role_missing_maps_hyphen_to_enum():
# given an alias with hyphen
value = "end-user"
# when converting to enum (invokes StrEnum._missing_ override)
role = CreatorUserRole(value)
# then it should map to END_USER
assert role is CreatorUserRole.END_USER
def test_creator_user_role_missing_raises_for_unknown():
with pytest.raises(ValueError):
CreatorUserRole("unknown")

View File

@@ -11,6 +11,7 @@ import type { BasicPlan } from '@/app/components/billing/type'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { toast, ToastHost } from '@/app/components/base/ui/toast'
import { ALL_PLANS } from '@/app/components/billing/config'
import { PlanRange } from '@/app/components/billing/pricing/plan-switcher/plan-range-switcher'
import CloudPlanItem from '@/app/components/billing/pricing/plans/cloud-plan-item'
@@ -21,7 +22,6 @@ let mockAppCtx: Record<string, unknown> = {}
const mockFetchSubscriptionUrls = vi.fn()
const mockInvoices = vi.fn()
const mockOpenAsyncWindow = vi.fn()
const mockToastNotify = vi.fn()
// ─── Context mocks ───────────────────────────────────────────────────────────
vi.mock('@/context/app-context', () => ({
@@ -49,10 +49,6 @@ vi.mock('@/hooks/use-async-window-open', () => ({
useAsyncWindowOpen: () => mockOpenAsyncWindow,
}))
vi.mock('@/app/components/base/toast', () => ({
default: { notify: (args: unknown) => mockToastNotify(args) },
}))
// ─── Navigation mocks ───────────────────────────────────────────────────────
vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push: vi.fn() }),
@@ -82,12 +78,15 @@ const renderCloudPlanItem = ({
canPay = true,
}: RenderCloudPlanItemOptions = {}) => {
return render(
<CloudPlanItem
currentPlan={currentPlan}
plan={plan}
planRange={planRange}
canPay={canPay}
/>,
<>
<ToastHost timeout={0} />
<CloudPlanItem
currentPlan={currentPlan}
plan={plan}
planRange={planRange}
canPay={canPay}
/>
</>,
)
}
@@ -96,6 +95,7 @@ describe('Cloud Plan Payment Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
cleanup()
toast.close()
setupAppContext()
mockFetchSubscriptionUrls.mockResolvedValue({ url: 'https://pay.example.com/checkout' })
mockInvoices.mockResolvedValue({ url: 'https://billing.example.com/invoices' })
@@ -283,11 +283,7 @@ describe('Cloud Plan Payment Flow', () => {
await user.click(button)
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
}),
)
expect(screen.getByText('billing.buyPermissionDeniedTip')).toBeInTheDocument()
})
// Should not proceed with payment
expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled()

View File

@@ -10,12 +10,12 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { toast, ToastHost } from '@/app/components/base/ui/toast'
import { contactSalesUrl, getStartedWithCommunityUrl, getWithPremiumUrl } from '@/app/components/billing/config'
import SelfHostedPlanItem from '@/app/components/billing/pricing/plans/self-hosted-plan-item'
import { SelfHostedPlan } from '@/app/components/billing/type'
let mockAppCtx: Record<string, unknown> = {}
const mockToastNotify = vi.fn()
const originalLocation = window.location
let assignedHref = ''
@@ -40,10 +40,6 @@ vi.mock('@/app/components/base/icons/src/public/billing', () => ({
AwsMarketplaceDark: () => <span data-testid="icon-aws-dark" />,
}))
vi.mock('@/app/components/base/toast', () => ({
default: { notify: (args: unknown) => mockToastNotify(args) },
}))
vi.mock('@/app/components/billing/pricing/plans/self-hosted-plan-item/list', () => ({
default: ({ plan }: { plan: string }) => (
<div data-testid={`self-hosted-list-${plan}`}>Features</div>
@@ -57,10 +53,20 @@ const setupAppContext = (overrides: Record<string, unknown> = {}) => {
}
}
const renderSelfHostedPlanItem = (plan: SelfHostedPlan) => {
return render(
<>
<ToastHost timeout={0} />
<SelfHostedPlanItem plan={plan} />
</>,
)
}
describe('Self-Hosted Plan Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
cleanup()
toast.close()
setupAppContext()
// Mock window.location with minimal getter/setter (Location props are non-enumerable)
@@ -85,14 +91,14 @@ describe('Self-Hosted Plan Flow', () => {
// ─── 1. Plan Rendering ──────────────────────────────────────────────────
describe('Plan rendering', () => {
it('should render community plan with name and description', () => {
render(<SelfHostedPlanItem plan={SelfHostedPlan.community} />)
renderSelfHostedPlanItem(SelfHostedPlan.community)
expect(screen.getByText(/plans\.community\.name/i)).toBeInTheDocument()
expect(screen.getByText(/plans\.community\.description/i)).toBeInTheDocument()
})
it('should render premium plan with cloud provider icons', () => {
render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
renderSelfHostedPlanItem(SelfHostedPlan.premium)
expect(screen.getByText(/plans\.premium\.name/i)).toBeInTheDocument()
expect(screen.getByTestId('icon-azure')).toBeInTheDocument()
@@ -100,39 +106,39 @@ describe('Self-Hosted Plan Flow', () => {
})
it('should render enterprise plan without cloud provider icons', () => {
render(<SelfHostedPlanItem plan={SelfHostedPlan.enterprise} />)
renderSelfHostedPlanItem(SelfHostedPlan.enterprise)
expect(screen.getByText(/plans\.enterprise\.name/i)).toBeInTheDocument()
expect(screen.queryByTestId('icon-azure')).not.toBeInTheDocument()
})
it('should not show price tip for community (free) plan', () => {
render(<SelfHostedPlanItem plan={SelfHostedPlan.community} />)
renderSelfHostedPlanItem(SelfHostedPlan.community)
expect(screen.queryByText(/plans\.community\.priceTip/i)).not.toBeInTheDocument()
})
it('should show price tip for premium plan', () => {
render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
renderSelfHostedPlanItem(SelfHostedPlan.premium)
expect(screen.getByText(/plans\.premium\.priceTip/i)).toBeInTheDocument()
})
it('should render features list for each plan', () => {
const { unmount: unmount1 } = render(<SelfHostedPlanItem plan={SelfHostedPlan.community} />)
const { unmount: unmount1 } = renderSelfHostedPlanItem(SelfHostedPlan.community)
expect(screen.getByTestId('self-hosted-list-community')).toBeInTheDocument()
unmount1()
const { unmount: unmount2 } = render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
const { unmount: unmount2 } = renderSelfHostedPlanItem(SelfHostedPlan.premium)
expect(screen.getByTestId('self-hosted-list-premium')).toBeInTheDocument()
unmount2()
render(<SelfHostedPlanItem plan={SelfHostedPlan.enterprise} />)
renderSelfHostedPlanItem(SelfHostedPlan.enterprise)
expect(screen.getByTestId('self-hosted-list-enterprise')).toBeInTheDocument()
})
it('should show AWS marketplace icon for premium plan button', () => {
render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
renderSelfHostedPlanItem(SelfHostedPlan.premium)
expect(screen.getByTestId('icon-aws-light')).toBeInTheDocument()
})
@@ -142,7 +148,7 @@ describe('Self-Hosted Plan Flow', () => {
describe('Navigation flow', () => {
it('should redirect to GitHub when clicking community plan button', async () => {
const user = userEvent.setup()
render(<SelfHostedPlanItem plan={SelfHostedPlan.community} />)
renderSelfHostedPlanItem(SelfHostedPlan.community)
const button = screen.getByRole('button')
await user.click(button)
@@ -152,7 +158,7 @@ describe('Self-Hosted Plan Flow', () => {
it('should redirect to AWS Marketplace when clicking premium plan button', async () => {
const user = userEvent.setup()
render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
renderSelfHostedPlanItem(SelfHostedPlan.premium)
const button = screen.getByRole('button')
await user.click(button)
@@ -162,7 +168,7 @@ describe('Self-Hosted Plan Flow', () => {
it('should redirect to Typeform when clicking enterprise plan button', async () => {
const user = userEvent.setup()
render(<SelfHostedPlanItem plan={SelfHostedPlan.enterprise} />)
renderSelfHostedPlanItem(SelfHostedPlan.enterprise)
const button = screen.getByRole('button')
await user.click(button)
@@ -176,15 +182,13 @@ describe('Self-Hosted Plan Flow', () => {
it('should show error toast when non-manager clicks community button', async () => {
setupAppContext({ isCurrentWorkspaceManager: false })
const user = userEvent.setup()
render(<SelfHostedPlanItem plan={SelfHostedPlan.community} />)
renderSelfHostedPlanItem(SelfHostedPlan.community)
const button = screen.getByRole('button')
await user.click(button)
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
)
expect(screen.getByText('billing.buyPermissionDeniedTip')).toBeInTheDocument()
})
// Should NOT redirect
expect(assignedHref).toBe('')
@@ -193,15 +197,13 @@ describe('Self-Hosted Plan Flow', () => {
it('should show error toast when non-manager clicks premium button', async () => {
setupAppContext({ isCurrentWorkspaceManager: false })
const user = userEvent.setup()
render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
renderSelfHostedPlanItem(SelfHostedPlan.premium)
const button = screen.getByRole('button')
await user.click(button)
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
)
expect(screen.getByText('billing.buyPermissionDeniedTip')).toBeInTheDocument()
})
expect(assignedHref).toBe('')
})
@@ -209,15 +211,13 @@ describe('Self-Hosted Plan Flow', () => {
it('should show error toast when non-manager clicks enterprise button', async () => {
setupAppContext({ isCurrentWorkspaceManager: false })
const user = userEvent.setup()
render(<SelfHostedPlanItem plan={SelfHostedPlan.enterprise} />)
renderSelfHostedPlanItem(SelfHostedPlan.enterprise)
const button = screen.getByRole('button')
await user.click(button)
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
)
expect(screen.getByText('billing.buyPermissionDeniedTip')).toBeInTheDocument()
})
expect(assignedHref).toBe('')
})

View File

@@ -0,0 +1,11 @@
import Evaluation from '@/app/components/evaluation'
const Page = async (props: {
params: Promise<{ appId: string }>
}) => {
const { appId } = await props.params
return <Evaluation resourceType="workflow" resourceId={appId} />
}
export default Page

View File

@@ -7,6 +7,8 @@ import {
RiDashboard2Line,
RiFileList3Fill,
RiFileList3Line,
RiFlaskFill,
RiFlaskLine,
RiTerminalBoxFill,
RiTerminalBoxLine,
RiTerminalWindowFill,
@@ -67,40 +69,47 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
}>>([])
const getNavigationConfig = useCallback((appId: string, isCurrentWorkspaceEditor: boolean, mode: AppModeEnum) => {
const navConfig = [
...(isCurrentWorkspaceEditor
? [{
name: t('appMenus.promptEng', { ns: 'common' }),
href: `/app/${appId}/${(mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT) ? 'workflow' : 'configuration'}`,
icon: RiTerminalWindowLine,
selectedIcon: RiTerminalWindowFill,
}]
: []
),
{
name: t('appMenus.apiAccess', { ns: 'common' }),
href: `/app/${appId}/develop`,
icon: RiTerminalBoxLine,
selectedIcon: RiTerminalBoxFill,
},
...(isCurrentWorkspaceEditor
? [{
name: mode !== AppModeEnum.WORKFLOW
? t('appMenus.logAndAnn', { ns: 'common' })
: t('appMenus.logs', { ns: 'common' }),
href: `/app/${appId}/logs`,
icon: RiFileList3Line,
selectedIcon: RiFileList3Fill,
}]
: []
),
{
name: t('appMenus.overview', { ns: 'common' }),
href: `/app/${appId}/overview`,
icon: RiDashboard2Line,
selectedIcon: RiDashboard2Fill,
},
]
const navConfig = []
if (isCurrentWorkspaceEditor) {
navConfig.push({
name: t('appMenus.promptEng', { ns: 'common' }),
href: `/app/${appId}/${(mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT) ? 'workflow' : 'configuration'}`,
icon: RiTerminalWindowLine,
selectedIcon: RiTerminalWindowFill,
})
navConfig.push({
name: t('appMenus.evaluation', { ns: 'common' }),
href: `/app/${appId}/evaluation`,
icon: RiFlaskLine,
selectedIcon: RiFlaskFill,
})
}
navConfig.push({
name: t('appMenus.apiAccess', { ns: 'common' }),
href: `/app/${appId}/develop`,
icon: RiTerminalBoxLine,
selectedIcon: RiTerminalBoxFill,
})
if (isCurrentWorkspaceEditor) {
navConfig.push({
name: mode !== AppModeEnum.WORKFLOW
? t('appMenus.logAndAnn', { ns: 'common' })
: t('appMenus.logs', { ns: 'common' }),
href: `/app/${appId}/logs`,
icon: RiFileList3Line,
selectedIcon: RiFileList3Fill,
})
}
navConfig.push({
name: t('appMenus.overview', { ns: 'common' }),
href: `/app/${appId}/overview`,
icon: RiDashboard2Line,
selectedIcon: RiDashboard2Fill,
})
return navConfig
}, [t])

View File

@@ -0,0 +1,11 @@
import Evaluation from '@/app/components/evaluation'
const Page = async (props: {
params: Promise<{ datasetId: string }>
}) => {
const { datasetId } = await props.params
return <Evaluation resourceType="pipeline" resourceId={datasetId} />
}
export default Page

View File

@@ -6,6 +6,8 @@ import {
RiEqualizer2Line,
RiFileTextFill,
RiFileTextLine,
RiFlaskFill,
RiFlaskLine,
RiFocus2Fill,
RiFocus2Line,
} from '@remixicon/react'
@@ -86,20 +88,30 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
]
if (datasetRes?.provider !== 'external') {
baseNavigation.unshift({
name: t('datasetMenus.pipeline', { ns: 'common' }),
href: `/datasets/${datasetId}/pipeline`,
icon: PipelineLine as RemixiconComponentType,
selectedIcon: PipelineFill as RemixiconComponentType,
disabled: false,
})
baseNavigation.unshift({
name: t('datasetMenus.documents', { ns: 'common' }),
href: `/datasets/${datasetId}/documents`,
icon: RiFileTextLine,
selectedIcon: RiFileTextFill,
disabled: isButtonDisabledWithPipeline,
})
return [
{
name: t('datasetMenus.documents', { ns: 'common' }),
href: `/datasets/${datasetId}/documents`,
icon: RiFileTextLine,
selectedIcon: RiFileTextFill,
disabled: isButtonDisabledWithPipeline,
},
{
name: t('datasetMenus.pipeline', { ns: 'common' }),
href: `/datasets/${datasetId}/pipeline`,
icon: PipelineLine as RemixiconComponentType,
selectedIcon: PipelineFill as RemixiconComponentType,
disabled: false,
},
{
name: t('datasetMenus.evaluation', { ns: 'common' }),
href: `/datasets/${datasetId}/evaluation`,
icon: RiFlaskLine,
selectedIcon: RiFlaskFill,
disabled: false,
},
...baseNavigation,
]
}
return baseNavigation

View File

@@ -6,7 +6,7 @@ import Loading from '@/app/components/base/loading'
import { useAppContext } from '@/context/app-context'
import { usePathname, useRouter } from '@/next/navigation'
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/explore', '/tools'] as const
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/snippets', '/explore', '/tools'] as const
const isPathUnderRoute = (pathname: string, route: string) => pathname === route || pathname.startsWith(`${route}/`)

View File

@@ -0,0 +1,11 @@
import SnippetPage from '@/app/components/snippets'
const Page = async (props: {
params: Promise<{ snippetId: string }>
}) => {
const { snippetId } = await props.params
return <SnippetPage snippetId={snippetId} section="evaluation" />
}
export default Page

View File

@@ -0,0 +1,11 @@
import SnippetPage from '@/app/components/snippets'
const Page = async (props: {
params: Promise<{ snippetId: string }>
}) => {
const { snippetId } = await props.params
return <SnippetPage snippetId={snippetId} section="orchestrate" />
}
export default Page

View File

@@ -0,0 +1,21 @@
import Page from './page'
const mockRedirect = vi.fn()
vi.mock('next/navigation', () => ({
redirect: (path: string) => mockRedirect(path),
}))
describe('snippet detail redirect page', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should redirect legacy snippet detail routes to orchestrate', async () => {
await Page({
params: Promise.resolve({ snippetId: 'snippet-1' }),
})
expect(mockRedirect).toHaveBeenCalledWith('/snippets/snippet-1/orchestrate')
})
})

View File

@@ -0,0 +1,11 @@
import { redirect } from 'next/navigation'
const Page = async (props: {
params: Promise<{ snippetId: string }>
}) => {
const { snippetId } = await props.params
redirect(`/snippets/${snippetId}/orchestrate`)
}
export default Page

View File

@@ -0,0 +1,7 @@
import Apps from '@/app/components/apps'
const SnippetsPage = () => {
return <Apps pageType="snippets" />
}
export default SnippetsPage

View File

@@ -58,10 +58,11 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
}, 1000)
}
const sendEmail = async (email: string, token?: string) => {
const sendEmail = async (email: string, isOrigin: boolean, token?: string) => {
try {
const res = await sendVerifyCode({
email,
phase: isOrigin ? 'old_email' : 'new_email',
token,
})
startCount()
@@ -105,6 +106,7 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
const sendCodeToOriginEmail = async () => {
await sendEmail(
email,
true,
)
setStep(STEP.verifyOrigin)
}
@@ -160,6 +162,7 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
}
await sendEmail(
mail,
false,
stepToken,
)
setStep(STEP.verifyNew)

View File

@@ -13,7 +13,7 @@ import { useTranslation } from 'react-i18next'
import { Avatar } from '@/app/components/base/avatar'
import Button from '@/app/components/base/button'
import Loading from '@/app/components/base/loading'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { setPostLoginRedirect } from '@/app/signin/utils/post-login-redirect'
import { useRouter, useSearchParams } from '@/next/navigation'
@@ -91,9 +91,9 @@ export default function OAuthAuthorize() {
globalThis.location.href = url.toString()
}
catch (err: any) {
Toast.notify({
toast.add({
type: 'error',
message: `${t('error.authorizeFailed', { ns: 'oauth' })}: ${err.message}`,
title: `${t('error.authorizeFailed', { ns: 'oauth' })}: ${err.message}`,
})
}
}
@@ -102,10 +102,10 @@ export default function OAuthAuthorize() {
const invalidParams = !client_id || !redirect_uri
if ((invalidParams || isError) && !hasNotifiedRef.current) {
hasNotifiedRef.current = true
Toast.notify({
toast.add({
type: 'error',
message: invalidParams ? t('error.invalidParams', { ns: 'oauth' }) : t('error.authAppInfoFetchFailed', { ns: 'oauth' }),
duration: 0,
title: invalidParams ? t('error.invalidParams', { ns: 'oauth' }) : t('error.authAppInfoFetchFailed', { ns: 'oauth' }),
timeout: 0,
})
}
}, [client_id, redirect_uri, isError])

View File

@@ -165,6 +165,21 @@ describe('AppDetailNav', () => {
)
expect(screen.queryByTestId('extra-info')).not.toBeInTheDocument()
})
it('should render custom header and navigation when provided', () => {
render(
<AppDetailNav
navigation={navigation}
renderHeader={mode => <div data-testid="custom-header" data-mode={mode} />}
renderNavigation={mode => <div data-testid="custom-navigation" data-mode={mode} />}
/>,
)
expect(screen.getByTestId('custom-header')).toHaveAttribute('data-mode', 'expand')
expect(screen.getByTestId('custom-navigation')).toHaveAttribute('data-mode', 'expand')
expect(screen.queryByTestId('app-info')).not.toBeInTheDocument()
expect(screen.queryByTestId('nav-link-Overview')).not.toBeInTheDocument()
})
})
describe('Workflow canvas mode', () => {

View File

@@ -27,12 +27,16 @@ export type IAppDetailNavProps = {
disabled?: boolean
}>
extraInfo?: (modeState: string) => React.ReactNode
renderHeader?: (modeState: string) => React.ReactNode
renderNavigation?: (modeState: string) => React.ReactNode
}
const AppDetailNav = ({
navigation,
extraInfo,
iconType = 'app',
renderHeader,
renderNavigation,
}: IAppDetailNavProps) => {
const { appSidebarExpand, setAppSidebarExpand } = useAppStore(useShallow(state => ({
appSidebarExpand: state.appSidebarExpand,
@@ -104,10 +108,11 @@ const AppDetailNav = ({
expand ? 'p-2' : 'p-1',
)}
>
{iconType === 'app' && (
{renderHeader?.(appSidebarExpand)}
{!renderHeader && iconType === 'app' && (
<AppInfo expand={expand} />
)}
{iconType !== 'app' && (
{!renderHeader && iconType !== 'app' && (
<DatasetInfo expand={expand} />
)}
</div>
@@ -136,7 +141,8 @@ const AppDetailNav = ({
expand ? 'px-3 py-2' : 'p-3',
)}
>
{navigation.map((item, index) => {
{renderNavigation?.(appSidebarExpand)}
{!renderNavigation && navigation.map((item, index) => {
return (
<NavLink
key={index}

View File

@@ -262,4 +262,20 @@ describe('NavLink Animation and Layout Issues', () => {
expect(iconWrapper).toHaveClass('-ml-1')
})
})
describe('Button Mode', () => {
it('should render as an interactive button when href is omitted', () => {
const onClick = vi.fn()
render(<NavLink {...mockProps} href={undefined} active={true} onClick={onClick} />)
const buttonElement = screen.getByText('Orchestrate').closest('button')
expect(buttonElement).not.toBeNull()
expect(buttonElement).toHaveClass('bg-components-menu-item-bg-active')
expect(buttonElement).toHaveClass('text-text-accent-light-mode-only')
buttonElement?.click()
expect(onClick).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -14,13 +14,15 @@ export type NavIcon = React.ComponentType<
export type NavLinkProps = {
name: string
href: string
href?: string
iconMap: {
selected: NavIcon
normal: NavIcon
}
mode?: string
disabled?: boolean
active?: boolean
onClick?: () => void
}
const NavLink = ({
@@ -29,6 +31,8 @@ const NavLink = ({
iconMap,
mode = 'expand',
disabled = false,
active,
onClick,
}: NavLinkProps) => {
const segment = useSelectedLayoutSegment()
const formattedSegment = (() => {
@@ -39,8 +43,11 @@ const NavLink = ({
return res
})()
const isActive = href.toLowerCase().split('/')?.pop() === formattedSegment
const isActive = active ?? (href ? href.toLowerCase().split('/')?.pop() === formattedSegment : false)
const NavIcon = isActive ? iconMap.selected : iconMap.normal
const linkClassName = cn(isActive
? 'border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only system-sm-semibold'
: 'text-components-menu-item-text system-sm-medium hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pl-3 pr-1')
const renderIcon = () => (
<div className={cn(mode !== 'expand' && '-ml-1')}>
@@ -70,13 +77,32 @@ const NavLink = ({
)
}
if (!href) {
return (
<button
key={name}
type="button"
className={linkClassName}
title={mode === 'collapse' ? name : ''}
onClick={onClick}
>
{renderIcon()}
<span
className={cn('overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out', mode === 'expand'
? 'ml-2 max-w-none opacity-100'
: 'ml-0 max-w-0 opacity-0')}
>
{name}
</span>
</button>
)
}
return (
<Link
key={name}
href={href}
className={cn(isActive
? 'border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only system-sm-semibold'
: 'text-components-menu-item-text system-sm-medium hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pl-3 pr-1')}
className={linkClassName}
title={mode === 'collapse' ? name : ''}
>
{renderIcon()}

View File

@@ -0,0 +1,53 @@
'use client'
import type { SnippetDetail } from '@/models/snippet'
import * as React from 'react'
import AppIcon from '@/app/components/base/app-icon'
import Badge from '@/app/components/base/badge'
import { cn } from '@/utils/classnames'
type SnippetInfoProps = {
expand: boolean
snippet: SnippetDetail
}
const SnippetInfo = ({
expand,
snippet,
}: SnippetInfoProps) => {
return (
<div className={cn('flex flex-col', expand ? '' : 'p-1')}>
<div className="flex flex-col gap-2 p-2">
<div className="flex items-start gap-3">
<div className={cn(!expand && 'ml-1')}>
<AppIcon
size={expand ? 'large' : 'small'}
iconType="emoji"
icon={snippet.icon}
background={snippet.iconBackground}
/>
</div>
{expand && (
<div className="min-w-0 flex-1">
<div className="truncate text-text-secondary system-md-semibold">
{snippet.name}
</div>
{snippet.status && (
<div className="pt-1">
<Badge>{snippet.status}</Badge>
</div>
)}
</div>
)}
</div>
{expand && snippet.description && (
<p className="line-clamp-3 text-text-tertiary system-xs-regular">
{snippet.description}
</p>
)}
</div>
</div>
)
}
export default React.memo(SnippetInfo)

View File

@@ -39,8 +39,8 @@ vi.mock('../app-card', () => ({
vi.mock('@/app/components/explore/create-app-modal', () => ({
default: () => <div data-testid="create-from-template-modal" />,
}))
vi.mock('@/app/components/base/toast', () => ({
default: { notify: vi.fn() },
vi.mock('@/app/components/base/ui/toast', () => ({
toast: { add: vi.fn() },
}))
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: vi.fn(),

View File

@@ -12,7 +12,7 @@ import { trackEvent } from '@/app/components/base/amplitude'
import Divider from '@/app/components/base/divider'
import Input from '@/app/components/base/input'
import Loading from '@/app/components/base/loading'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import CreateAppModal from '@/app/components/explore/create-app-modal'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
@@ -137,9 +137,9 @@ const Apps = ({
})
setIsShowCreateModal(false)
Toast.notify({
toast.add({
type: 'success',
message: t('newApp.appCreated', { ns: 'app' }),
title: t('newApp.appCreated', { ns: 'app' }),
})
if (onSuccess)
onSuccess()
@@ -149,7 +149,7 @@ const Apps = ({
getRedirection(isCurrentWorkspaceEditor, { id: app.app_id!, mode }, push)
}
catch {
Toast.notify({ type: 'error', message: t('newApp.appCreateFailed', { ns: 'app' }) })
toast.add({ type: 'error', title: t('newApp.appCreateFailed', { ns: 'app' }) })
}
}

View File

@@ -1,4 +1,4 @@
import { act, fireEvent, screen } from '@testing-library/react'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
import { renderWithNuqs } from '@/test/nuqs-testing'
@@ -15,10 +15,13 @@ vi.mock('@/next/navigation', () => ({
const mockIsCurrentWorkspaceEditor = vi.fn(() => true)
const mockIsCurrentWorkspaceDatasetOperator = vi.fn(() => false)
const mockIsLoadingCurrentWorkspace = vi.fn(() => false)
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor(),
isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator(),
isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace(),
}),
}))
@@ -36,6 +39,7 @@ const mockQueryState = {
keywords: '',
isCreatedByMe: false,
}
vi.mock('../hooks/use-apps-query-state', () => ({
default: () => ({
query: mockQueryState,
@@ -45,6 +49,7 @@ vi.mock('../hooks/use-apps-query-state', () => ({
let mockOnDSLFileDropped: ((file: File) => void) | null = null
let mockDragging = false
vi.mock('../hooks/use-dsl-drag-drop', () => ({
useDSLDragDrop: ({ onDSLFileDropped }: { onDSLFileDropped: (file: File) => void }) => {
mockOnDSLFileDropped = onDSLFileDropped
@@ -59,6 +64,7 @@ const mockServiceState = {
error: null as Error | null,
hasNextPage: false,
isLoading: false,
isFetching: false,
isFetchingNextPage: false,
}
@@ -100,6 +106,7 @@ vi.mock('@/service/use-apps', () => ({
useInfiniteAppList: () => ({
data: defaultAppData,
isLoading: mockServiceState.isLoading,
isFetching: mockServiceState.isFetching,
isFetchingNextPage: mockServiceState.isFetchingNextPage,
fetchNextPage: mockFetchNextPage,
hasNextPage: mockServiceState.hasNextPage,
@@ -133,13 +140,21 @@ vi.mock('@/next/dynamic', () => ({
return React.createElement('div', { 'data-testid': 'tag-management-modal' })
}
}
if (fnString.includes('create-from-dsl-modal')) {
return function MockCreateFromDSLModal({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess: () => void }) {
if (!show)
return null
return React.createElement('div', { 'data-testid': 'create-dsl-modal' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-dsl-modal' }, 'Close'), React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'success-dsl-modal' }, 'Success'))
return React.createElement(
'div',
{ 'data-testid': 'create-dsl-modal' },
React.createElement('button', { 'data-testid': 'close-dsl-modal', 'onClick': onClose }, 'Close'),
React.createElement('button', { 'data-testid': 'success-dsl-modal', 'onClick': onSuccess }, 'Success'),
)
}
}
return () => null
},
}))
@@ -188,9 +203,8 @@ beforeAll(() => {
} as unknown as typeof IntersectionObserver
})
// Render helper wrapping with shared nuqs testing helper.
const renderList = (searchParams = '') => {
return renderWithNuqs(<List />, { searchParams })
const renderList = (props: React.ComponentProps<typeof List> = {}, searchParams = '') => {
return renderWithNuqs(<List {...props} />, { searchParams })
}
describe('List', () => {
@@ -202,11 +216,13 @@ describe('List', () => {
})
mockIsCurrentWorkspaceEditor.mockReturnValue(true)
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(false)
mockIsLoadingCurrentWorkspace.mockReturnValue(false)
mockDragging = false
mockOnDSLFileDropped = null
mockServiceState.error = null
mockServiceState.hasNextPage = false
mockServiceState.isLoading = false
mockServiceState.isFetching = false
mockServiceState.isFetchingNextPage = false
mockQueryState.tagIDs = []
mockQueryState.keywords = ''
@@ -215,372 +231,94 @@ describe('List', () => {
localStorage.clear()
})
describe('Rendering', () => {
it('should render without crashing', () => {
renderList()
expect(screen.getByText('app.types.all')).toBeInTheDocument()
})
it('should render tab slider with all app types', () => {
describe('Apps Mode', () => {
it('should render the apps route switch, dropdown filters, and app cards', () => {
renderList()
expect(screen.getByText('app.types.all')).toBeInTheDocument()
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
expect(screen.getByText('app.types.advanced')).toBeInTheDocument()
expect(screen.getByText('app.types.chatbot')).toBeInTheDocument()
expect(screen.getByText('app.types.agent')).toBeInTheDocument()
expect(screen.getByText('app.types.completion')).toBeInTheDocument()
})
it('should render search input', () => {
renderList()
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should render tag filter', () => {
renderList()
expect(screen.getByRole('link', { name: 'app.studio.apps' })).toHaveAttribute('href', '/apps')
expect(screen.getByRole('link', { name: 'workflow.tabs.snippets' })).toHaveAttribute('href', '/snippets')
expect(screen.getByText('app.studio.filters.types')).toBeInTheDocument()
expect(screen.getByText('app.studio.filters.creators')).toBeInTheDocument()
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
})
it('should render created by me checkbox', () => {
renderList()
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
})
it('should render app cards when apps exist', () => {
renderList()
expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument()
expect(screen.getByTestId('app-card-app-2')).toBeInTheDocument()
})
it('should render new app card for editors', () => {
renderList()
expect(screen.getByTestId('new-app-card')).toBeInTheDocument()
})
it('should render footer when branding is disabled', () => {
renderList()
expect(screen.getByTestId('footer')).toBeInTheDocument()
})
it('should render drop DSL hint for editors', () => {
renderList()
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
})
})
describe('Tab Navigation', () => {
it('should update URL when workflow tab is clicked', async () => {
it('should update the category query when selecting an app type from the dropdown', async () => {
const { onUrlUpdate } = renderList()
fireEvent.click(screen.getByText('app.types.workflow'))
fireEvent.click(screen.getByText('app.studio.filters.types'))
fireEvent.click(await screen.findByText('app.types.workflow'))
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(lastCall.searchParams.get('category')).toBe(AppModeEnum.WORKFLOW)
})
it('should update URL when all tab is clicked', async () => {
const { onUrlUpdate } = renderList('?category=workflow')
it('should keep the creators dropdown visual-only and not update app query state', async () => {
renderList()
fireEvent.click(screen.getByText('app.types.all'))
fireEvent.click(screen.getByText('app.studio.filters.creators'))
fireEvent.click(await screen.findByText('Evan'))
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
// nuqs removes the default value ('all') from URL params
expect(lastCall.searchParams.has('category')).toBe(false)
expect(mockSetQuery).not.toHaveBeenCalled()
expect(screen.getByText('app.studio.filters.creators +1')).toBeInTheDocument()
})
it('should render and close the DSL import modal when a file is dropped', () => {
renderList()
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
act(() => {
if (mockOnDSLFileDropped)
mockOnDSLFileDropped(mockFile)
})
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('close-dsl-modal'))
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
})
})
describe('Search Functionality', () => {
it('should render search input field', () => {
renderList()
expect(screen.getByRole('textbox')).toBeInTheDocument()
describe('Snippets Mode', () => {
it('should render the snippets create card and fake snippet card', () => {
renderList({ pageType: 'snippets' })
expect(screen.getByText('snippet.create')).toBeInTheDocument()
expect(screen.getByText('Tone Rewriter')).toBeInTheDocument()
expect(screen.getByText('Rewrites rough drafts into a concise, professional tone for internal stakeholder updates.')).toBeInTheDocument()
expect(screen.getByRole('link', { name: /Tone Rewriter/i })).toHaveAttribute('href', '/snippets/snippet-1')
expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
expect(screen.queryByTestId('app-card-app-1')).not.toBeInTheDocument()
})
it('should handle search input change', () => {
renderList()
it('should filter local snippets by the search input and show the snippet empty state', () => {
renderList({ pageType: 'snippets' })
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'test search' } })
fireEvent.change(input, { target: { value: 'missing snippet' } })
expect(mockSetQuery).toHaveBeenCalled()
expect(screen.queryByText('Tone Rewriter')).not.toBeInTheDocument()
expect(screen.getByText('workflow.tabs.noSnippetsFound')).toBeInTheDocument()
})
it('should handle search clear button click', () => {
mockQueryState.keywords = 'existing search'
it('should not render app-only controls in snippets mode', () => {
renderList({ pageType: 'snippets' })
renderList()
const clearButton = document.querySelector('.group')
expect(clearButton).toBeInTheDocument()
if (clearButton)
fireEvent.click(clearButton)
expect(mockSetQuery).toHaveBeenCalled()
})
})
describe('Tag Filter', () => {
it('should render tag filter component', () => {
renderList()
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
})
})
describe('Created By Me Filter', () => {
it('should render checkbox with correct label', () => {
renderList()
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
expect(screen.queryByText('app.studio.filters.types')).not.toBeInTheDocument()
expect(screen.queryByText('common.tag.placeholder')).not.toBeInTheDocument()
expect(screen.queryByText('app.newApp.dropDSLToCreateApp')).not.toBeInTheDocument()
})
it('should handle checkbox change', () => {
renderList()
it('should reserve the infinite-scroll anchor without fetching more pages', () => {
renderList({ pageType: 'snippets' })
const checkbox = screen.getByTestId('checkbox-undefined')
fireEvent.click(checkbox)
expect(mockSetQuery).toHaveBeenCalled()
})
})
describe('Non-Editor User', () => {
it('should not render new app card for non-editors', () => {
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
renderList()
expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
})
it('should not render drop DSL hint for non-editors', () => {
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
renderList()
expect(screen.queryByText(/drop dsl file to create app/i)).not.toBeInTheDocument()
})
})
describe('Dataset Operator Behavior', () => {
it('should not trigger redirect at component level for dataset operators', () => {
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(true)
renderList()
expect(mockReplace).not.toHaveBeenCalled()
})
})
describe('Local Storage Refresh', () => {
it('should call refetch when refresh key is set in localStorage', () => {
localStorage.setItem('needRefreshAppList', '1')
renderList()
expect(mockRefetch).toHaveBeenCalled()
expect(localStorage.getItem('needRefreshAppList')).toBeNull()
})
})
describe('Edge Cases', () => {
it('should handle multiple renders without issues', () => {
const { rerender } = renderWithNuqs(<List />)
expect(screen.getByText('app.types.all')).toBeInTheDocument()
rerender(<List />)
expect(screen.getByText('app.types.all')).toBeInTheDocument()
})
it('should render app cards correctly', () => {
renderList()
expect(screen.getByText('Test App 1')).toBeInTheDocument()
expect(screen.getByText('Test App 2')).toBeInTheDocument()
})
it('should render with all filter options visible', () => {
renderList()
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
})
})
describe('Dragging State', () => {
it('should show drop hint when DSL feature is enabled for editors', () => {
renderList()
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
})
it('should render dragging state overlay when dragging', () => {
mockDragging = true
const { container } = renderList()
expect(container).toBeInTheDocument()
})
})
describe('App Type Tabs', () => {
it('should render all app type tabs', () => {
renderList()
expect(screen.getByText('app.types.all')).toBeInTheDocument()
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
expect(screen.getByText('app.types.advanced')).toBeInTheDocument()
expect(screen.getByText('app.types.chatbot')).toBeInTheDocument()
expect(screen.getByText('app.types.agent')).toBeInTheDocument()
expect(screen.getByText('app.types.completion')).toBeInTheDocument()
})
it('should update URL for each app type tab click', async () => {
const { onUrlUpdate } = renderList()
const appTypeTexts = [
{ mode: AppModeEnum.WORKFLOW, text: 'app.types.workflow' },
{ mode: AppModeEnum.ADVANCED_CHAT, text: 'app.types.advanced' },
{ mode: AppModeEnum.CHAT, text: 'app.types.chatbot' },
{ mode: AppModeEnum.AGENT_CHAT, text: 'app.types.agent' },
{ mode: AppModeEnum.COMPLETION, text: 'app.types.completion' },
]
for (const { mode, text } of appTypeTexts) {
onUrlUpdate.mockClear()
fireEvent.click(screen.getByText(text))
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(lastCall.searchParams.get('category')).toBe(mode)
}
})
})
describe('App List Display', () => {
it('should display all app cards from data', () => {
renderList()
expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument()
expect(screen.getByTestId('app-card-app-2')).toBeInTheDocument()
})
it('should display app names correctly', () => {
renderList()
expect(screen.getByText('Test App 1')).toBeInTheDocument()
expect(screen.getByText('Test App 2')).toBeInTheDocument()
})
})
describe('Footer Visibility', () => {
it('should render footer when branding is disabled', () => {
renderList()
expect(screen.getByTestId('footer')).toBeInTheDocument()
})
})
describe('DSL File Drop', () => {
it('should handle DSL file drop and show modal', () => {
renderList()
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
act(() => {
if (mockOnDSLFileDropped)
mockOnDSLFileDropped(mockFile)
intersectionCallback?.([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver)
})
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
})
it('should close DSL modal when onClose is called', () => {
renderList()
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
act(() => {
if (mockOnDSLFileDropped)
mockOnDSLFileDropped(mockFile)
})
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('close-dsl-modal'))
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
})
it('should close DSL modal and refetch when onSuccess is called', () => {
renderList()
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
act(() => {
if (mockOnDSLFileDropped)
mockOnDSLFileDropped(mockFile)
})
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('success-dsl-modal'))
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
expect(mockRefetch).toHaveBeenCalled()
})
})
describe('Infinite Scroll', () => {
it('should call fetchNextPage when intersection observer triggers', () => {
mockServiceState.hasNextPage = true
renderList()
if (intersectionCallback) {
act(() => {
intersectionCallback!(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
})
}
expect(mockFetchNextPage).toHaveBeenCalled()
})
it('should not call fetchNextPage when not intersecting', () => {
mockServiceState.hasNextPage = true
renderList()
if (intersectionCallback) {
act(() => {
intersectionCallback!(
[{ isIntersecting: false } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
})
}
expect(mockFetchNextPage).not.toHaveBeenCalled()
})
it('should not call fetchNextPage when loading', () => {
mockServiceState.hasNextPage = true
mockServiceState.isLoading = true
renderList()
if (intersectionCallback) {
act(() => {
intersectionCallback!(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
})
}
expect(mockFetchNextPage).not.toHaveBeenCalled()
})
})
describe('Error State', () => {
it('should handle error state in useEffect', () => {
mockServiceState.error = new Error('Test error')
const { container } = renderList()
expect(container).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,15 @@
import { parseAsStringLiteral } from 'nuqs'
import { AppModes } from '@/types/app'
export const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const
export type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number]
const appListCategorySet = new Set<string>(APP_LIST_CATEGORY_VALUES)
export const isAppListCategory = (value: string): value is AppListCategory => {
return appListCategorySet.has(value)
}
export const parseAsAppListCategory = parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
.withDefault('all')
.withOptions({ history: 'push' })

View File

@@ -0,0 +1,71 @@
'use client'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuRadioItemIndicator,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames'
import { isAppListCategory } from './app-type-filter-shared'
const chipClassName = 'flex h-8 items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 text-[13px] leading-[18px] text-text-secondary hover:bg-components-input-bg-hover'
type AppTypeFilterProps = {
activeTab: import('./app-type-filter-shared').AppListCategory
onChange: (value: import('./app-type-filter-shared').AppListCategory) => void
}
const AppTypeFilter = ({
activeTab,
onChange,
}: AppTypeFilterProps) => {
const { t } = useTranslation()
const options = useMemo(() => ([
{ value: 'all', text: t('types.all', { ns: 'app' }), iconClassName: 'i-ri-apps-2-line' },
{ value: AppModeEnum.WORKFLOW, text: t('types.workflow', { ns: 'app' }), iconClassName: 'i-ri-exchange-2-line' },
{ value: AppModeEnum.ADVANCED_CHAT, text: t('types.advanced', { ns: 'app' }), iconClassName: 'i-ri-message-3-line' },
{ value: AppModeEnum.CHAT, text: t('types.chatbot', { ns: 'app' }), iconClassName: 'i-ri-message-3-line' },
{ value: AppModeEnum.AGENT_CHAT, text: t('types.agent', { ns: 'app' }), iconClassName: 'i-ri-robot-3-line' },
{ value: AppModeEnum.COMPLETION, text: t('types.completion', { ns: 'app' }), iconClassName: 'i-ri-file-4-line' },
]), [t])
const activeOption = options.find(option => option.value === activeTab)
const triggerLabel = activeTab === 'all' ? t('studio.filters.types', { ns: 'app' }) : activeOption?.text
return (
<DropdownMenu>
<DropdownMenuTrigger
render={(
<button
type="button"
className={cn(chipClassName, activeTab !== 'all' && 'shadow-xs')}
/>
)}
>
<span aria-hidden className={cn('h-4 w-4 shrink-0 text-text-tertiary', activeOption?.iconClassName ?? 'i-ri-apps-2-line')} />
<span>{triggerLabel}</span>
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary" />
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-start" popupClassName="w-[220px]">
<DropdownMenuRadioGroup value={activeTab} onValueChange={value => isAppListCategory(value) && onChange(value)}>
{options.map(option => (
<DropdownMenuRadioItem key={option.value} value={option.value}>
<span aria-hidden className={cn('h-4 w-4 shrink-0 text-text-tertiary', option.iconClassName)} />
<span>{option.text}</span>
<DropdownMenuRadioItemIndicator />
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
export default AppTypeFilter

View File

@@ -0,0 +1,128 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuCheckboxItemIndicator,
DropdownMenuContent,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
import { cn } from '@/utils/classnames'
type CreatorOption = {
id: string
name: string
isYou?: boolean
avatarClassName: string
}
const chipClassName = 'flex h-8 items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 text-[13px] leading-[18px] text-text-secondary hover:bg-components-input-bg-hover'
const creatorOptions: CreatorOption[] = [
{ id: 'evan', name: 'Evan', isYou: true, avatarClassName: 'bg-gradient-to-br from-[#ff9b3f] to-[#ff4d00]' },
{ id: 'jack', name: 'Jack', avatarClassName: 'bg-gradient-to-br from-[#fde68a] to-[#d6d3d1]' },
{ id: 'gigi', name: 'Gigi', avatarClassName: 'bg-gradient-to-br from-[#f9a8d4] to-[#a78bfa]' },
{ id: 'alice', name: 'Alice', avatarClassName: 'bg-gradient-to-br from-[#93c5fd] to-[#4f46e5]' },
{ id: 'mandy', name: 'Mandy', avatarClassName: 'bg-gradient-to-br from-[#374151] to-[#111827]' },
]
const CreatorsFilter = () => {
const { t } = useTranslation()
const [selectedCreatorIds, setSelectedCreatorIds] = useState<string[]>([])
const [keywords, setKeywords] = useState('')
const filteredCreators = useMemo(() => {
const normalizedKeywords = keywords.trim().toLowerCase()
if (!normalizedKeywords)
return creatorOptions
return creatorOptions.filter(creator => creator.name.toLowerCase().includes(normalizedKeywords))
}, [keywords])
const selectedCount = selectedCreatorIds.length
const triggerLabel = selectedCount > 0
? `${t('studio.filters.creators', { ns: 'app' })} +${selectedCount}`
: t('studio.filters.creators', { ns: 'app' })
const toggleCreator = useCallback((creatorId: string) => {
setSelectedCreatorIds((prev) => {
if (prev.includes(creatorId))
return prev.filter(id => id !== creatorId)
return [...prev, creatorId]
})
}, [])
const resetCreators = useCallback(() => {
setSelectedCreatorIds([])
setKeywords('')
}, [])
return (
<DropdownMenu>
<DropdownMenuTrigger
render={(
<button
type="button"
className={cn(chipClassName, selectedCount > 0 && 'border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs')}
/>
)}
>
<span aria-hidden className="i-ri-user-shared-line h-4 w-4 shrink-0 text-text-tertiary" />
<span>{triggerLabel}</span>
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary" />
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-start" popupClassName="w-[280px] p-0">
<div className="flex items-center gap-2 p-2 pb-1">
<Input
showLeftIcon
showClearIcon
value={keywords}
onChange={e => setKeywords(e.target.value)}
onClear={() => setKeywords('')}
placeholder={t('studio.filters.searchCreators', { ns: 'app' })}
/>
<button
type="button"
className="shrink-0 rounded-md px-2 py-1 text-xs font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
onClick={resetCreators}
>
{t('studio.filters.reset', { ns: 'app' })}
</button>
</div>
<div className="px-1 pb-1">
<DropdownMenuCheckboxItem
checked={selectedCreatorIds.length === 0}
onCheckedChange={resetCreators}
>
<span aria-hidden className="i-ri-user-line h-4 w-4 shrink-0 text-text-tertiary" />
<span>{t('studio.filters.allCreators', { ns: 'app' })}</span>
<DropdownMenuCheckboxItemIndicator />
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
{filteredCreators.map(creator => (
<DropdownMenuCheckboxItem
key={creator.id}
checked={selectedCreatorIds.includes(creator.id)}
onCheckedChange={() => toggleCreator(creator.id)}
>
<span className={cn('h-5 w-5 shrink-0 rounded-full border border-white', creator.avatarClassName)} />
<span className="flex min-w-0 grow items-center justify-between gap-2">
<span className="truncate">{creator.name}</span>
{creator.isYou && (
<span className="shrink-0 text-text-quaternary">{t('studio.filters.you', { ns: 'app' })}</span>
)}
</span>
<DropdownMenuCheckboxItemIndicator />
</DropdownMenuCheckboxItem>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
)
}
export default CreatorsFilter

View File

@@ -14,10 +14,20 @@ import CreateAppModal from '../explore/create-app-modal'
import TryApp from '../explore/try-app'
import List from './list'
const Apps = () => {
export type StudioPageType = 'apps' | 'snippets'
type AppsProps = {
pageType?: StudioPageType
}
const Apps = ({
pageType = 'apps',
}: AppsProps) => {
const { t } = useTranslation()
useDocumentTitle(t('menus.apps', { ns: 'common' }))
useDocumentTitle(pageType === 'apps'
? t('menus.apps', { ns: 'common' })
: t('tabs.snippets', { ns: 'workflow' }))
useEducationInit()
const [currentTryAppParams, setCurrentTryAppParams] = useState<TryAppSelection | undefined>(undefined)
@@ -101,7 +111,7 @@ const Apps = () => {
}}
>
<div className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
<List controlRefreshList={controlRefreshList} />
<List controlRefreshList={controlRefreshList} pageType={pageType} />
{isShowTryAppPanel && (
<TryApp
appId={currentTryAppParams?.appId || ''}

View File

@@ -1,25 +1,30 @@
'use client'
import type { FC } from 'react'
import type { StudioPageType } from '.'
import type { SnippetListItem } from '@/models/snippet'
import type { App } from '@/types/app'
import { useDebounceFn } from 'ahooks'
import { parseAsStringLiteral, useQueryState } from 'nuqs'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useQueryState } from 'nuqs'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
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'
import { CheckModal } from '@/hooks/use-pay'
import dynamic from '@/next/dynamic'
import Link from '@/next/link'
import { useInfiniteAppList } from '@/service/use-apps'
import { AppModeEnum, AppModes } from '@/types/app'
import { getSnippetListMock } from '@/service/use-snippets'
import { cn } from '@/utils/classnames'
import AppCard from './app-card'
import { AppCardSkeleton } from './app-card-skeleton'
import AppTypeFilter from './app-type-filter'
import { parseAsAppListCategory } from './app-type-filter-shared'
import CreatorsFilter from './creators-filter'
import Empty from './empty'
import Footer from './footer'
import useAppsQueryState from './hooks/use-apps-query-state'
@@ -33,25 +38,104 @@ const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-fro
ssr: false,
})
const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const
type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number]
const appListCategorySet = new Set<string>(APP_LIST_CATEGORY_VALUES)
const isAppListCategory = (value: string): value is AppListCategory => {
return appListCategorySet.has(value)
const StudioRouteSwitch = ({ pageType, appsLabel, snippetsLabel }: { pageType: StudioPageType, appsLabel: string, snippetsLabel: string }) => {
return (
<div className="flex items-center rounded-lg border-[0.5px] border-divider-subtle bg-[rgba(200,206,218,0.2)] p-[1px]">
<Link
href="/apps"
className={cn(
'flex h-8 items-center rounded-lg px-3 text-[14px] leading-5 text-text-secondary',
pageType === 'apps' && 'bg-components-card-bg font-semibold text-text-primary shadow-xs',
pageType !== 'apps' && 'font-medium',
)}
>
{appsLabel}
</Link>
<Link
href="/snippets"
className={cn(
'flex h-8 items-center rounded-lg px-3 text-[14px] leading-5 text-text-secondary',
pageType === 'snippets' && 'bg-components-card-bg font-semibold text-text-primary shadow-xs',
pageType !== 'snippets' && 'font-medium',
)}
>
{snippetsLabel}
</Link>
</div>
)
}
const parseAsAppListCategory = parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
.withDefault('all')
.withOptions({ history: 'push' })
const SnippetCreateCard = () => {
const { t } = useTranslation('snippet')
return (
<div className="relative col-span-1 inline-flex h-[160px] flex-col justify-between rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg transition-opacity">
<div className="grow rounded-t-xl p-2">
<div className="px-6 pb-1 pt-2 text-xs font-medium leading-[18px] text-text-tertiary">{t('create')}</div>
<div className="mb-1 flex w-full items-center rounded-lg px-6 py-[7px] text-[13px] font-medium leading-[18px] text-text-tertiary">
<span aria-hidden className="i-ri-sticky-note-add-line mr-2 h-4 w-4 shrink-0" />
{t('newApp.startFromBlank', { ns: 'app' })}
</div>
<div className="flex w-full items-center rounded-lg px-6 py-[7px] text-[13px] font-medium leading-[18px] text-text-tertiary">
<span aria-hidden className="i-ri-file-upload-line mr-2 h-4 w-4 shrink-0" />
{t('importDSL', { ns: 'app' })}
</div>
</div>
</div>
)
}
const SnippetCard = ({
snippet,
}: {
snippet: SnippetListItem
}) => {
return (
<Link href={`/snippets/${snippet.id}/orchestrate`} className="group col-span-1">
<article className="relative inline-flex h-[160px] w-full flex-col rounded-xl border border-components-card-border bg-components-card-bg shadow-sm transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg">
{snippet.status && (
<div className="absolute right-0 top-0 rounded-bl-lg rounded-tr-xl bg-background-default-dimmed px-2 py-1 text-[10px] font-medium uppercase leading-3 text-text-placeholder">
{snippet.status}
</div>
)}
<div className="flex h-[66px] items-center gap-3 px-[14px] pb-3 pt-[14px]">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-divider-regular text-xl text-white" style={{ background: snippet.iconBackground }}>
<span aria-hidden>{snippet.icon}</span>
</div>
<div className="w-0 grow py-[1px]">
<div className="truncate text-sm font-semibold leading-5 text-text-secondary" title={snippet.name}>
{snippet.name}
</div>
</div>
</div>
<div className="h-[58px] px-[14px] text-xs leading-normal text-text-tertiary">
<div className="line-clamp-2" title={snippet.description}>
{snippet.description}
</div>
</div>
<div className="mt-auto flex items-center gap-1 px-[14px] pb-3 pt-2 text-xs leading-4 text-text-tertiary">
<span className="truncate">{snippet.author}</span>
<span>·</span>
<span className="truncate">{snippet.updatedAt}</span>
<span>·</span>
<span className="truncate">{snippet.usage}</span>
</div>
</article>
</Link>
)
}
type Props = {
controlRefreshList?: number
pageType?: StudioPageType
}
const List: FC<Props> = ({
controlRefreshList = 0,
pageType = 'apps',
}) => {
const { t } = useTranslation()
const isAppsPage = pageType === 'apps'
const { systemFeatures } = useGlobalPublicStore()
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
@@ -61,18 +145,21 @@ const List: FC<Props> = ({
)
const { query: { tagIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = useAppsQueryState()
const [isCreatedByMe, setIsCreatedByMe] = useState(queryIsCreatedByMe)
const [tagFilterValue, setTagFilterValue] = useState<string[]>(tagIDs)
const [searchKeywords, setSearchKeywords] = useState(keywords)
const newAppCardRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [appKeywords, setAppKeywords] = useState(keywords)
const [snippetKeywords, setSnippetKeywords] = useState('')
const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false)
const [droppedDSLFile, setDroppedDSLFile] = useState<File | undefined>()
const setKeywords = useCallback((keywords: string) => {
setQuery(prev => ({ ...prev, keywords }))
const containerRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const newAppCardRef = useRef<HTMLDivElement>(null)
const setKeywords = useCallback((nextKeywords: string) => {
setQuery(prev => ({ ...prev, keywords: nextKeywords }))
}, [setQuery])
const setTagIDs = useCallback((tagIDs: string[]) => {
setQuery(prev => ({ ...prev, tagIDs }))
const setTagIDs = useCallback((nextTagIDs: string[]) => {
setQuery(prev => ({ ...prev, tagIDs: nextTagIDs }))
}, [setQuery])
const handleDSLFileDropped = useCallback((file: File) => {
@@ -83,15 +170,15 @@ const List: FC<Props> = ({
const { dragging } = useDSLDragDrop({
onDSLFileDropped: handleDSLFileDropped,
containerRef,
enabled: isCurrentWorkspaceEditor,
enabled: isAppsPage && isCurrentWorkspaceEditor,
})
const appListQueryParams = {
page: 1,
limit: 30,
name: searchKeywords,
name: appKeywords,
tag_ids: tagIDs,
is_created_by_me: isCreatedByMe,
is_created_by_me: queryIsCreatedByMe,
...(activeTab !== 'all' ? { mode: activeTab } : {}),
}
@@ -104,48 +191,40 @@ const List: FC<Props> = ({
hasNextPage,
error,
refetch,
} = useInfiniteAppList(appListQueryParams, { enabled: !isCurrentWorkspaceDatasetOperator })
} = useInfiniteAppList(appListQueryParams, {
enabled: isAppsPage && !isCurrentWorkspaceDatasetOperator,
})
useEffect(() => {
if (controlRefreshList > 0) {
if (isAppsPage && controlRefreshList > 0)
refetch()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [controlRefreshList])
const anchorRef = useRef<HTMLDivElement>(null)
const options = [
{ value: 'all', text: t('types.all', { ns: 'app' }), icon: <span className="i-ri-apps-2-line mr-1 h-[14px] w-[14px]" /> },
{ value: AppModeEnum.WORKFLOW, text: t('types.workflow', { ns: 'app' }), icon: <span className="i-ri-exchange-2-line mr-1 h-[14px] w-[14px]" /> },
{ value: AppModeEnum.ADVANCED_CHAT, text: t('types.advanced', { ns: 'app' }), icon: <span className="i-ri-message-3-line mr-1 h-[14px] w-[14px]" /> },
{ value: AppModeEnum.CHAT, text: t('types.chatbot', { ns: 'app' }), icon: <span className="i-ri-message-3-line mr-1 h-[14px] w-[14px]" /> },
{ value: AppModeEnum.AGENT_CHAT, text: t('types.agent', { ns: 'app' }), icon: <span className="i-ri-robot-3-line mr-1 h-[14px] w-[14px]" /> },
{ value: AppModeEnum.COMPLETION, text: t('types.completion', { ns: 'app' }), icon: <span className="i-ri-file-4-line mr-1 h-[14px] w-[14px]" /> },
]
}, [controlRefreshList, isAppsPage, refetch])
useEffect(() => {
if (!isAppsPage)
return
if (localStorage.getItem(NEED_REFRESH_APP_LIST_KEY) === '1') {
localStorage.removeItem(NEED_REFRESH_APP_LIST_KEY)
refetch()
}
}, [refetch])
}, [isAppsPage, refetch])
useEffect(() => {
if (isCurrentWorkspaceDatasetOperator)
return
const hasMore = hasNextPage ?? true
const hasMore = isAppsPage ? (hasNextPage ?? true) : false
let observer: IntersectionObserver | undefined
if (error) {
if (observer)
observer.disconnect()
observer?.disconnect()
return
}
if (anchorRef.current && containerRef.current) {
// Calculate dynamic rootMargin: clamps to 100-200px range, using 20% of container height as the base value for better responsiveness
const containerHeight = containerRef.current.clientHeight
const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200)) // Clamps to 100-200px range, using 20% of container height as the base value
const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200))
observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !isLoading && !isFetchingNextPage && !error && hasMore)
@@ -153,110 +232,148 @@ const List: FC<Props> = ({
}, {
root: containerRef.current,
rootMargin: `${dynamicMargin}px`,
threshold: 0.1, // Trigger when 10% of the anchor element is visible
threshold: 0.1,
})
observer.observe(anchorRef.current)
}
return () => observer?.disconnect()
}, [isLoading, isFetchingNextPage, fetchNextPage, error, hasNextPage, isCurrentWorkspaceDatasetOperator])
}, [error, fetchNextPage, hasNextPage, isAppsPage, isCurrentWorkspaceDatasetOperator, isFetchingNextPage, isLoading])
const { run: handleSearch } = useDebounceFn(() => {
setSearchKeywords(keywords)
const { run: handleAppSearch } = useDebounceFn((value: string) => {
setAppKeywords(value)
}, { wait: 500 })
const handleKeywordsChange = (value: string) => {
setKeywords(value)
handleSearch()
}
const { run: handleTagsUpdate } = useDebounceFn(() => {
setTagIDs(tagFilterValue)
const handleKeywordsChange = useCallback((value: string) => {
if (isAppsPage) {
setKeywords(value)
handleAppSearch(value)
return
}
setSnippetKeywords(value)
}, [handleAppSearch, isAppsPage, setKeywords])
const { run: handleTagsUpdate } = useDebounceFn((value: string[]) => {
setTagIDs(value)
}, { wait: 500 })
const handleTagsChange = (value: string[]) => {
const handleTagsChange = useCallback((value: string[]) => {
setTagFilterValue(value)
handleTagsUpdate()
}
handleTagsUpdate(value)
}, [handleTagsUpdate])
const handleCreatedByMeChange = useCallback(() => {
const newValue = !isCreatedByMe
setIsCreatedByMe(newValue)
setQuery(prev => ({ ...prev, isCreatedByMe: newValue }))
}, [isCreatedByMe, setQuery])
const appItems = useMemo<App[]>(() => {
return (data?.pages ?? []).flatMap(({ data: apps }) => apps)
}, [data?.pages])
const pages = data?.pages ?? []
const hasAnyApp = (pages[0]?.total ?? 0) > 0
// Show skeleton during initial load or when refetching with no previous data
const showSkeleton = isLoading || (isFetching && pages.length === 0)
const snippetItems = useMemo(() => getSnippetListMock(), [])
const filteredSnippetItems = useMemo(() => {
const normalizedKeywords = snippetKeywords.trim().toLowerCase()
if (!normalizedKeywords)
return snippetItems
return snippetItems.filter(item =>
item.name.toLowerCase().includes(normalizedKeywords)
|| item.description.toLowerCase().includes(normalizedKeywords),
)
}, [snippetItems, snippetKeywords])
const showSkeleton = isAppsPage && (isLoading || (isFetching && data?.pages?.length === 0))
const hasAnyApp = (data?.pages?.[0]?.total ?? 0) > 0
const hasAnySnippet = filteredSnippetItems.length > 0
const currentKeywords = isAppsPage ? keywords : snippetKeywords
return (
<>
<div ref={containerRef} className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
{dragging && (
<div className="absolute inset-0 z-50 m-0.5 rounded-2xl border-2 border-dashed border-components-dropzone-border-accent bg-[rgba(21,90,239,0.14)] p-2">
</div>
<div className="absolute inset-0 z-50 m-0.5 rounded-2xl border-2 border-dashed border-components-dropzone-border-accent bg-[rgba(21,90,239,0.14)] p-2" />
)}
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-y-2 bg-background-body px-12 pb-5 pt-7">
<TabSliderNew
value={activeTab}
onChange={(nextValue) => {
if (isAppListCategory(nextValue))
setActiveTab(nextValue)
}}
options={options}
/>
<div className="flex items-center gap-2">
<CheckboxWithLabel
className="mr-2"
label={t('showMyCreatedAppsOnly', { ns: 'app' })}
isChecked={isCreatedByMe}
onChange={handleCreatedByMeChange}
<div className="flex flex-wrap items-center gap-2">
<StudioRouteSwitch
pageType={pageType}
appsLabel={t('studio.apps', { ns: 'app' })}
snippetsLabel={t('tabs.snippets', { ns: 'workflow' })}
/>
<TagFilter type="app" value={tagFilterValue} onChange={handleTagsChange} />
{isAppsPage && (
<AppTypeFilter
activeTab={activeTab}
onChange={(value) => {
void setActiveTab(value)
}}
/>
)}
<CreatorsFilter />
{isAppsPage && (
<TagFilter type="app" value={tagFilterValue} onChange={handleTagsChange} />
)}
</div>
<div className="flex items-center gap-2">
<Input
showLeftIcon
showClearIcon
wrapperClassName="w-[200px]"
value={keywords}
placeholder={isAppsPage ? undefined : t('tabs.searchSnippets', { ns: 'workflow' })}
value={currentKeywords}
onChange={e => handleKeywordsChange(e.target.value)}
onClear={() => handleKeywordsChange('')}
/>
</div>
</div>
<div className={cn(
'relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5 2k:grid-cols-6',
!hasAnyApp && 'overflow-hidden',
isAppsPage && !hasAnyApp && 'overflow-hidden',
)}
>
{(isCurrentWorkspaceEditor || isLoadingCurrentWorkspace) && (
<NewAppCard
ref={newAppCardRef}
isLoading={isLoadingCurrentWorkspace}
onSuccess={refetch}
selectedAppType={activeTab}
className={cn(!hasAnyApp && 'z-10')}
/>
isAppsPage
? (
<NewAppCard
ref={newAppCardRef}
isLoading={isLoadingCurrentWorkspace}
onSuccess={refetch}
selectedAppType={activeTab}
className={cn(!hasAnyApp && 'z-10')}
/>
)
: <SnippetCreateCard />
)}
{(() => {
if (showSkeleton)
return <AppCardSkeleton count={6} />
if (hasAnyApp) {
return pages.flatMap(({ data: apps }) => apps).map(app => (
<AppCard key={app.id} app={app} onRefresh={refetch} />
))
}
{showSkeleton && <AppCardSkeleton count={6} />}
// No apps - show empty state
return <Empty />
})()}
{isFetchingNextPage && (
{!showSkeleton && isAppsPage && hasAnyApp && appItems.map(app => (
<AppCard key={app.id} app={app} onRefresh={refetch} />
))}
{!showSkeleton && !isAppsPage && hasAnySnippet && filteredSnippetItems.map(snippet => (
<SnippetCard key={snippet.id} snippet={snippet} />
))}
{!showSkeleton && isAppsPage && !hasAnyApp && <Empty />}
{!showSkeleton && !isAppsPage && !hasAnySnippet && (
<div className="col-span-full flex min-h-[240px] items-center justify-center rounded-xl border border-dashed border-divider-regular bg-components-card-bg p-6 text-center text-sm text-text-tertiary">
{t('tabs.noSnippetsFound', { ns: 'workflow' })}
</div>
)}
{isAppsPage && isFetchingNextPage && (
<AppCardSkeleton count={3} />
)}
</div>
{isCurrentWorkspaceEditor && (
{isAppsPage && isCurrentWorkspaceEditor && (
<div
className={`flex items-center justify-center gap-2 py-4 ${dragging ? 'text-text-accent' : 'text-text-quaternary'}`}
className={cn(
'flex items-center justify-center gap-2 py-4',
dragging ? 'text-text-accent' : 'text-text-quaternary',
)}
role="region"
aria-label={t('newApp.dropDSLToCreateApp', { ns: 'app' })}
>
@@ -264,17 +381,18 @@ const List: FC<Props> = ({
<span className="system-xs-regular">{t('newApp.dropDSLToCreateApp', { ns: 'app' })}</span>
</div>
)}
{!systemFeatures.branding.enabled && (
<Footer />
)}
<CheckModal />
<div ref={anchorRef} className="h-0"> </div>
{showTagManagementModal && (
{isAppsPage && showTagManagementModal && (
<TagManagementModal type="app" show={showTagManagementModal} />
)}
</div>
{showCreateFromDSLModal && (
{isAppsPage && showCreateFromDSLModal && (
<CreateFromDSLModal
show={showCreateFromDSLModal}
onClose={() => {

View File

@@ -1,8 +1,15 @@
'use client'
/**
* @deprecated Use `@/app/components/base/ui/toast` instead.
* This module will be removed after migration is complete.
* See: https://github.com/langgenius/dify/issues/32811
*/
import type { ReactNode } from 'react'
import { createContext, useContext } from 'use-context-selector'
/** @deprecated Use `@/app/components/base/ui/toast` instead. See issue #32811. */
export type IToastProps = {
type?: 'success' | 'error' | 'warning' | 'info'
size?: 'md' | 'sm'
@@ -19,5 +26,8 @@ type IToastContext = {
close: () => void
}
/** @deprecated Use `@/app/components/base/ui/toast` instead. See issue #32811. */
export const ToastContext = createContext<IToastContext>({} as IToastContext)
/** @deprecated Use `@/app/components/base/ui/toast` instead. See issue #32811. */
export const useToastContext = () => useContext(ToastContext)

View File

@@ -1,4 +1,11 @@
'use client'
/**
* @deprecated Use `@/app/components/base/ui/toast` instead.
* This component will be removed after migration is complete.
* See: https://github.com/langgenius/dify/issues/32811
*/
import type { ReactNode } from 'react'
import type { IToastProps } from './context'
import { noop } from 'es-toolkit/function'
@@ -12,6 +19,7 @@ import { ToastContext, useToastContext } from './context'
export type ToastHandle = {
clear?: VoidFunction
}
const Toast = ({
type = 'info',
size = 'md',
@@ -74,6 +82,7 @@ const Toast = ({
)
}
/** @deprecated Use `@/app/components/base/ui/toast` instead. See issue #32811. */
export const ToastProvider = ({
children,
}: {

View File

@@ -1,22 +1,16 @@
import type { Mock } from 'vitest'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { toast, ToastHost } from '@/app/components/base/ui/toast'
import { useAppContext } from '@/context/app-context'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { fetchSubscriptionUrls } from '@/service/billing'
import { consoleClient } from '@/service/client'
import Toast from '../../../../../base/toast'
import { ALL_PLANS } from '../../../../config'
import { Plan } from '../../../../type'
import { PlanRange } from '../../../plan-switcher/plan-range-switcher'
import CloudPlanItem from '../index'
vi.mock('../../../../../base/toast', () => ({
default: {
notify: vi.fn(),
},
}))
vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
@@ -47,11 +41,19 @@ const mockUseAppContext = useAppContext as Mock
const mockUseAsyncWindowOpen = useAsyncWindowOpen as Mock
const mockBillingInvoices = consoleClient.billing.invoices as Mock
const mockFetchSubscriptionUrls = fetchSubscriptionUrls as Mock
const mockToastNotify = Toast.notify as Mock
let assignedHref = ''
const originalLocation = window.location
const renderWithToastHost = (ui: React.ReactNode) => {
return render(
<>
<ToastHost timeout={0} />
{ui}
</>,
)
}
beforeAll(() => {
Object.defineProperty(window, 'location', {
configurable: true,
@@ -68,6 +70,7 @@ beforeAll(() => {
beforeEach(() => {
vi.clearAllMocks()
toast.close()
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: true })
mockUseAsyncWindowOpen.mockReturnValue(vi.fn(async open => await open()))
mockBillingInvoices.mockResolvedValue({ url: 'https://billing.example' })
@@ -163,7 +166,7 @@ describe('CloudPlanItem', () => {
it('should show toast when non-manager tries to buy a plan', () => {
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: false })
render(
renderWithToastHost(
<CloudPlanItem
plan={Plan.professional}
currentPlan={Plan.sandbox}
@@ -173,10 +176,7 @@ describe('CloudPlanItem', () => {
)
fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' }))
expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
message: 'billing.buyPermissionDeniedTip',
}))
expect(screen.getByText('billing.buyPermissionDeniedTip')).toBeInTheDocument()
expect(mockBillingInvoices).not.toHaveBeenCalled()
})

View File

@@ -4,11 +4,11 @@ import type { BasicPlan } from '../../../type'
import * as React from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from '@/app/components/base/ui/toast'
import { useAppContext } from '@/context/app-context'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { fetchSubscriptionUrls } from '@/service/billing'
import { consoleClient } from '@/service/client'
import Toast from '../../../../base/toast'
import { ALL_PLANS } from '../../../config'
import { Plan } from '../../../type'
import { Professional, Sandbox, Team } from '../../assets'
@@ -66,10 +66,9 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({
return
if (!isCurrentWorkspaceManager) {
Toast.notify({
toast.add({
type: 'error',
message: t('buyPermissionDeniedTip', { ns: 'billing' }),
className: 'z-[1001]',
title: t('buyPermissionDeniedTip', { ns: 'billing' }),
})
return
}
@@ -83,7 +82,7 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({
throw new Error('Failed to open billing page')
}, {
onError: (err) => {
Toast.notify({ type: 'error', message: err.message || String(err) })
toast.add({ type: 'error', title: err.message || String(err) })
},
})
return
@@ -111,34 +110,34 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({
{
isMostPopularPlan && (
<div className="flex items-center justify-center bg-saas-dify-blue-static px-1.5 py-1">
<span className="system-2xs-semibold-uppercase text-text-primary-on-surface">
<span className="text-text-primary-on-surface system-2xs-semibold-uppercase">
{t('plansCommon.mostPopular', { ns: 'billing' })}
</span>
</div>
)
}
</div>
<div className="system-sm-regular text-text-secondary">{t(`${i18nPrefix}.description`, { ns: 'billing' })}</div>
<div className="text-text-secondary system-sm-regular">{t(`${i18nPrefix}.description`, { ns: 'billing' })}</div>
</div>
</div>
{/* Price */}
<div className="flex items-end gap-x-2 px-1 pb-8 pt-4">
{isFreePlan && (
<span className="title-4xl-semi-bold text-text-primary">{t('plansCommon.free', { ns: 'billing' })}</span>
<span className="text-text-primary title-4xl-semi-bold">{t('plansCommon.free', { ns: 'billing' })}</span>
)}
{!isFreePlan && (
<>
{isYear && (
<span className="title-4xl-semi-bold text-text-quaternary line-through">
<span className="text-text-quaternary line-through title-4xl-semi-bold">
$
{planInfo.price * 12}
</span>
)}
<span className="title-4xl-semi-bold text-text-primary">
<span className="text-text-primary title-4xl-semi-bold">
$
{isYear ? planInfo.price * 10 : planInfo.price}
</span>
<span className="system-md-regular pb-0.5 text-text-tertiary">
<span className="pb-0.5 text-text-tertiary system-md-regular">
{t('plansCommon.priceTip', { ns: 'billing' })}
{t(`plansCommon.${!isYear ? 'month' : 'year'}`, { ns: 'billing' })}
</span>

View File

@@ -1,8 +1,8 @@
import type { Mock } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { toast, ToastHost } from '@/app/components/base/ui/toast'
import { useAppContext } from '@/context/app-context'
import Toast from '../../../../../base/toast'
import { contactSalesUrl, getStartedWithCommunityUrl, getWithPremiumUrl } from '../../../../config'
import { SelfHostedPlan } from '../../../../type'
import SelfHostedPlanItem from '../index'
@@ -16,12 +16,6 @@ vi.mock('../list', () => ({
),
}))
vi.mock('../../../../../base/toast', () => ({
default: {
notify: vi.fn(),
},
}))
vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
@@ -35,11 +29,19 @@ vi.mock('../../../assets', () => ({
}))
const mockUseAppContext = useAppContext as Mock
const mockToastNotify = Toast.notify as Mock
let assignedHref = ''
const originalLocation = window.location
const renderWithToastHost = (ui: React.ReactNode) => {
return render(
<>
<ToastHost timeout={0} />
{ui}
</>,
)
}
beforeAll(() => {
Object.defineProperty(window, 'location', {
configurable: true,
@@ -56,6 +58,7 @@ beforeAll(() => {
beforeEach(() => {
vi.clearAllMocks()
toast.close()
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: true })
assignedHref = ''
})
@@ -90,13 +93,10 @@ describe('SelfHostedPlanItem', () => {
it('should show toast when non-manager tries to proceed', () => {
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: false })
render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
renderWithToastHost(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
fireEvent.click(screen.getByRole('button', { name: /billing\.plans\.premium\.btnText/ }))
expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
message: 'billing.buyPermissionDeniedTip',
}))
expect(screen.getByText('billing.buyPermissionDeniedTip')).toBeInTheDocument()
})
it('should redirect to community url when community plan button clicked', () => {

View File

@@ -4,9 +4,9 @@ import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { Azure, GoogleCloud } from '@/app/components/base/icons/src/public/billing'
import { toast } from '@/app/components/base/ui/toast'
import { useAppContext } from '@/context/app-context'
import { cn } from '@/utils/classnames'
import Toast from '../../../../base/toast'
import { contactSalesUrl, getStartedWithCommunityUrl, getWithPremiumUrl } from '../../../config'
import { SelfHostedPlan } from '../../../type'
import { Community, Enterprise, EnterpriseNoise, Premium, PremiumNoise } from '../../assets'
@@ -56,10 +56,9 @@ const SelfHostedPlanItem: FC<SelfHostedPlanItemProps> = ({
const handleGetPayUrl = useCallback(() => {
// Only workspace manager can buy plan
if (!isCurrentWorkspaceManager) {
Toast.notify({
toast.add({
type: 'error',
message: t('buyPermissionDeniedTip', { ns: 'billing' }),
className: 'z-[1001]',
title: t('buyPermissionDeniedTip', { ns: 'billing' }),
})
return
}
@@ -82,18 +81,18 @@ const SelfHostedPlanItem: FC<SelfHostedPlanItemProps> = ({
{/* Noise Effect */}
{STYLE_MAP[plan].noise}
<div className="flex flex-col px-5 py-4">
<div className=" flex flex-col gap-y-6 px-1 pt-10">
<div className="flex flex-col gap-y-6 px-1 pt-10">
{STYLE_MAP[plan].icon}
<div className="flex min-h-[104px] flex-col gap-y-2">
<div className="text-[30px] font-medium leading-[1.2] text-text-primary">{t(`${i18nPrefix}.name`, { ns: 'billing' })}</div>
<div className="system-md-regular line-clamp-2 text-text-secondary">{t(`${i18nPrefix}.description`, { ns: 'billing' })}</div>
<div className="line-clamp-2 text-text-secondary system-md-regular">{t(`${i18nPrefix}.description`, { ns: 'billing' })}</div>
</div>
</div>
{/* Price */}
<div className="flex items-end gap-x-2 px-1 pb-8 pt-4">
<div className="title-4xl-semi-bold shrink-0 text-text-primary">{t(`${i18nPrefix}.price`, { ns: 'billing' })}</div>
<div className="shrink-0 text-text-primary title-4xl-semi-bold">{t(`${i18nPrefix}.price`, { ns: 'billing' })}</div>
{!isFreePlan && (
<span className="system-md-regular pb-0.5 text-text-tertiary">
<span className="pb-0.5 text-text-tertiary system-md-regular">
{t(`${i18nPrefix}.priceTip`, { ns: 'billing' })}
</span>
)}
@@ -114,7 +113,7 @@ const SelfHostedPlanItem: FC<SelfHostedPlanItemProps> = ({
<GoogleCloud />
</div>
</div>
<span className="system-xs-regular text-text-tertiary">
<span className="text-text-tertiary system-xs-regular">
{t('plans.premium.comingSoon', { ns: 'billing' })}
</span>
</div>

View File

@@ -1,6 +1,6 @@
import type * as React from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { toast, ToastHost } from '@/app/components/base/ui/toast'
import { ChunkingMode } from '@/models/datasets'
import { IndexingType } from '../../../create/step-two'
@@ -13,14 +13,7 @@ vi.mock('@/next/navigation', () => ({
}),
}))
const mockNotify = vi.fn()
vi.mock('use-context-selector', async (importOriginal) => {
const actual = await importOriginal() as Record<string, unknown>
return {
...actual,
useContext: () => ({ notify: mockNotify }),
}
})
const toastAddSpy = vi.spyOn(toast, 'add')
// Mock dataset detail context
let mockIndexingTechnique = IndexingType.QUALIFIED
@@ -51,11 +44,6 @@ vi.mock('@/service/knowledge/use-segment', () => ({
}),
}))
// Mock app store
vi.mock('@/app/components/app/store', () => ({
useStore: () => ({ appSidebarExpand: 'expand' }),
}))
vi.mock('../completed/common/action-buttons', () => ({
default: ({ handleCancel, handleSave, loading, actionType }: { handleCancel: () => void, handleSave: () => void, loading: boolean, actionType: string }) => (
<div data-testid="action-buttons">
@@ -139,6 +127,8 @@ vi.mock('@/app/components/datasets/common/image-uploader/image-uploader-in-chunk
describe('NewSegmentModal', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useRealTimers()
toast.close()
mockFullScreen = false
mockIndexingTechnique = IndexingType.QUALIFIED
})
@@ -258,7 +248,7 @@ describe('NewSegmentModal', () => {
fireEvent.click(screen.getByTestId('save-btn'))
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(
expect(toastAddSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
}),
@@ -272,7 +262,7 @@ describe('NewSegmentModal', () => {
fireEvent.click(screen.getByTestId('save-btn'))
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(
expect(toastAddSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
}),
@@ -287,7 +277,7 @@ describe('NewSegmentModal', () => {
fireEvent.click(screen.getByTestId('save-btn'))
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(
expect(toastAddSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
}),
@@ -337,7 +327,7 @@ describe('NewSegmentModal', () => {
fireEvent.click(screen.getByTestId('save-btn'))
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(
expect(toastAddSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'success',
}),
@@ -430,10 +420,9 @@ describe('NewSegmentModal', () => {
})
})
describe('CustomButton in success notification', () => {
it('should call viewNewlyAddedChunk when custom button is clicked', async () => {
describe('Action button in success notification', () => {
it('should call viewNewlyAddedChunk when the toast action is clicked', async () => {
const mockViewNewlyAddedChunk = vi.fn()
mockNotify.mockImplementation(() => {})
mockAddSegment.mockImplementation((_params: unknown, options: { onSuccess: () => void, onSettled: () => void }) => {
options.onSuccess()
@@ -442,37 +431,25 @@ describe('NewSegmentModal', () => {
})
render(
<NewSegmentModal
{...defaultProps}
docForm={ChunkingMode.text}
viewNewlyAddedChunk={mockViewNewlyAddedChunk}
/>,
<>
<ToastHost timeout={0} />
<NewSegmentModal
{...defaultProps}
docForm={ChunkingMode.text}
viewNewlyAddedChunk={mockViewNewlyAddedChunk}
/>
</>,
)
// Enter content and save
fireEvent.change(screen.getByTestId('question-input'), { target: { value: 'Test content' } })
fireEvent.click(screen.getByTestId('save-btn'))
const actionButton = await screen.findByRole('button', { name: 'common.operation.view' })
fireEvent.click(actionButton)
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'success',
customComponent: expect.anything(),
}),
)
expect(mockViewNewlyAddedChunk).toHaveBeenCalledTimes(1)
})
// Extract customComponent from the notify call args
const notifyCallArgs = mockNotify.mock.calls[0][0] as { customComponent?: React.ReactElement }
expect(notifyCallArgs.customComponent).toBeDefined()
const customComponent = notifyCallArgs.customComponent!
const { container: btnContainer } = render(customComponent)
const viewButton = btnContainer.querySelector('.system-xs-semibold.text-text-accent') as HTMLElement
expect(viewButton).toBeInTheDocument()
fireEvent.click(viewButton)
// Assert that viewNewlyAddedChunk was called via the onClick handler (lines 66-67)
expect(mockViewNewlyAddedChunk).toHaveBeenCalled()
})
})
@@ -599,9 +576,8 @@ describe('NewSegmentModal', () => {
})
})
describe('onSave delayed call', () => {
it('should call onSave after timeout in success handler', async () => {
vi.useFakeTimers()
describe('onSave after success', () => {
it('should call onSave immediately after save succeeds', async () => {
const mockOnSave = vi.fn()
mockAddSegment.mockImplementation((_params: unknown, options: { onSuccess: () => void, onSettled: () => void }) => {
options.onSuccess()
@@ -611,15 +587,12 @@ describe('NewSegmentModal', () => {
render(<NewSegmentModal {...defaultProps} onSave={mockOnSave} docForm={ChunkingMode.text} />)
// Enter content and save
fireEvent.change(screen.getByTestId('question-input'), { target: { value: 'Test content' } })
fireEvent.click(screen.getByTestId('save-btn'))
// Fast-forward timer
vi.advanceTimersByTime(3000)
expect(mockOnSave).toHaveBeenCalled()
vi.useRealTimers()
await waitFor(() => {
expect(mockOnSave).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -1,5 +1,6 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { toast, ToastHost } from '@/app/components/base/ui/toast'
import NewChildSegmentModal from '../new-child-segment'
@@ -10,14 +11,7 @@ vi.mock('@/next/navigation', () => ({
}),
}))
const mockNotify = vi.fn()
vi.mock('use-context-selector', async (importOriginal) => {
const actual = await importOriginal() as Record<string, unknown>
return {
...actual,
useContext: () => ({ notify: mockNotify }),
}
})
const toastAddSpy = vi.spyOn(toast, 'add')
// Mock document context
let mockParentMode = 'paragraph'
@@ -48,11 +42,6 @@ vi.mock('@/service/knowledge/use-segment', () => ({
}),
}))
// Mock app store
vi.mock('@/app/components/app/store', () => ({
useStore: () => ({ appSidebarExpand: 'expand' }),
}))
vi.mock('../common/action-buttons', () => ({
default: ({ handleCancel, handleSave, loading, actionType, isChildChunk }: { handleCancel: () => void, handleSave: () => void, loading: boolean, actionType: string, isChildChunk?: boolean }) => (
<div data-testid="action-buttons">
@@ -103,6 +92,8 @@ vi.mock('../common/segment-index-tag', () => ({
describe('NewChildSegmentModal', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useRealTimers()
toast.close()
mockFullScreen = false
mockParentMode = 'paragraph'
})
@@ -198,7 +189,7 @@ describe('NewChildSegmentModal', () => {
fireEvent.click(screen.getByTestId('save-btn'))
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(
expect(toastAddSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
}),
@@ -253,7 +244,7 @@ describe('NewChildSegmentModal', () => {
fireEvent.click(screen.getByTestId('save-btn'))
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(
expect(toastAddSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'success',
}),
@@ -374,35 +365,62 @@ describe('NewChildSegmentModal', () => {
// View newly added chunk
describe('View Newly Added Chunk', () => {
it('should show custom button in full-doc mode after save', async () => {
it('should call viewNewlyAddedChildChunk when the toast action is clicked', async () => {
mockParentMode = 'full-doc'
const mockViewNewlyAddedChildChunk = vi.fn()
mockAddChildSegment.mockImplementation((_params, options) => {
options.onSuccess({ data: { id: 'new-child-id' } })
options.onSettled()
return Promise.resolve()
})
render(<NewChildSegmentModal {...defaultProps} />)
render(
<>
<ToastHost timeout={0} />
<NewChildSegmentModal
{...defaultProps}
viewNewlyAddedChildChunk={mockViewNewlyAddedChildChunk}
/>
</>,
)
// Enter valid content
fireEvent.change(screen.getByTestId('content-input'), {
target: { value: 'Valid content' },
})
fireEvent.click(screen.getByTestId('save-btn'))
// Assert - success notification with custom component
const actionButton = await screen.findByRole('button', { name: 'common.operation.view' })
fireEvent.click(actionButton)
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'success',
customComponent: expect.anything(),
}),
)
expect(mockViewNewlyAddedChildChunk).toHaveBeenCalledTimes(1)
})
})
it('should not show custom button in paragraph mode after save', async () => {
it('should call onSave immediately in full-doc mode after save succeeds', async () => {
mockParentMode = 'full-doc'
const mockOnSave = vi.fn()
mockAddChildSegment.mockImplementation((_params, options) => {
options.onSuccess({ data: { id: 'new-child-id' } })
options.onSettled()
return Promise.resolve()
})
render(<NewChildSegmentModal {...defaultProps} onSave={mockOnSave} />)
fireEvent.change(screen.getByTestId('content-input'), {
target: { value: 'Valid content' },
})
fireEvent.click(screen.getByTestId('save-btn'))
await waitFor(() => {
expect(mockOnSave).toHaveBeenCalledTimes(1)
})
})
it('should call onSave with the new child chunk in paragraph mode', async () => {
mockParentMode = 'paragraph'
const mockOnSave = vi.fn()
mockAddChildSegment.mockImplementation((_params, options) => {

View File

@@ -1,13 +1,10 @@
import type { FC } from 'react'
import type { ChildChunkDetail, SegmentUpdater } from '@/models/datasets'
import { RiCloseLine, RiExpandDiagonalLine } from '@remixicon/react'
import { memo, useMemo, useRef, useState } from 'react'
import { memo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { useShallow } from 'zustand/react/shallow'
import { useStore as useAppStore } from '@/app/components/app/store'
import Divider from '@/app/components/base/divider'
import { ToastContext } from '@/app/components/base/toast/context'
import { toast } from '@/app/components/base/ui/toast'
import { ChunkingMode } from '@/models/datasets'
import { useParams } from '@/next/navigation'
import { useAddChildSegment } from '@/service/knowledge/use-segment'
@@ -35,39 +32,15 @@ const NewChildSegmentModal: FC<NewChildSegmentModalProps> = ({
viewNewlyAddedChildChunk,
}) => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const [content, setContent] = useState('')
const { datasetId, documentId } = useParams<{ datasetId: string, documentId: string }>()
const [loading, setLoading] = useState(false)
const [addAnother, setAddAnother] = useState(true)
const fullScreen = useSegmentListContext(s => s.fullScreen)
const toggleFullScreen = useSegmentListContext(s => s.toggleFullScreen)
const { appSidebarExpand } = useAppStore(useShallow(state => ({
appSidebarExpand: state.appSidebarExpand,
})))
const parentMode = useDocumentContext(s => s.parentMode)
const refreshTimer = useRef<any>(null)
const isFullDocMode = useMemo(() => {
return parentMode === 'full-doc'
}, [parentMode])
const CustomButton = (
<>
<Divider type="vertical" className="mx-1 h-3 bg-divider-regular" />
<button
type="button"
className="text-text-accent system-xs-semibold"
onClick={() => {
clearTimeout(refreshTimer.current)
viewNewlyAddedChildChunk?.()
}}
>
{t('operation.view', { ns: 'common' })}
</button>
</>
)
const isFullDocMode = parentMode === 'full-doc'
const handleCancel = (actionType: 'esc' | 'add' = 'esc') => {
if (actionType === 'esc' || !addAnother)
@@ -80,26 +53,27 @@ const NewChildSegmentModal: FC<NewChildSegmentModalProps> = ({
const params: SegmentUpdater = { content: '' }
if (!content.trim())
return notify({ type: 'error', message: t('segment.contentEmpty', { ns: 'datasetDocuments' }) })
return toast.add({ type: 'error', title: t('segment.contentEmpty', { ns: 'datasetDocuments' }) })
params.content = content
setLoading(true)
await addChildSegment({ datasetId, documentId, segmentId: chunkId, body: params }, {
onSuccess(res) {
notify({
toast.add({
type: 'success',
message: t('segment.childChunkAdded', { ns: 'datasetDocuments' }),
className: `!w-[296px] !bottom-0 ${appSidebarExpand === 'expand' ? '!left-[216px]' : '!left-14'}
!top-auto !right-auto !mb-[52px] !ml-11`,
customComponent: isFullDocMode && CustomButton,
title: t('segment.childChunkAdded', { ns: 'datasetDocuments' }),
actionProps: isFullDocMode
? {
children: t('operation.view', { ns: 'common' }),
onClick: viewNewlyAddedChildChunk,
}
: undefined,
})
handleCancel('add')
setContent('')
if (isFullDocMode) {
refreshTimer.current = setTimeout(() => {
onSave()
}, 3000)
onSave()
}
else {
onSave(res.data)
@@ -111,10 +85,8 @@ const NewChildSegmentModal: FC<NewChildSegmentModalProps> = ({
})
}
const wordCountText = useMemo(() => {
const count = content.length
return `${formatNumber(count)} ${t('segment.characters', { ns: 'datasetDocuments', count })}`
}, [content.length])
const count = content.length
const wordCountText = `${formatNumber(count)} ${t('segment.characters', { ns: 'datasetDocuments', count })}`
return (
<div className="flex h-full flex-col">

View File

@@ -2,13 +2,10 @@ import type { FC } from 'react'
import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types'
import type { SegmentUpdater } from '@/models/datasets'
import { RiCloseLine, RiExpandDiagonalLine } from '@remixicon/react'
import { memo, useCallback, useMemo, useRef, useState } from 'react'
import { memo, useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { useShallow } from 'zustand/react/shallow'
import { useStore as useAppStore } from '@/app/components/app/store'
import Divider from '@/app/components/base/divider'
import { ToastContext } from '@/app/components/base/toast/context'
import { toast } from '@/app/components/base/ui/toast'
import ImageUploaderInChunk from '@/app/components/datasets/common/image-uploader/image-uploader-in-chunk'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { ChunkingMode } from '@/models/datasets'
@@ -39,7 +36,6 @@ const NewSegmentModal: FC<NewSegmentModalProps> = ({
viewNewlyAddedChunk,
}) => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const [question, setQuestion] = useState('')
const [answer, setAnswer] = useState('')
const [attachments, setAttachments] = useState<FileEntity[]>([])
@@ -50,27 +46,7 @@ const NewSegmentModal: FC<NewSegmentModalProps> = ({
const fullScreen = useSegmentListContext(s => s.fullScreen)
const toggleFullScreen = useSegmentListContext(s => s.toggleFullScreen)
const indexingTechnique = useDatasetDetailContextWithSelector(s => s.dataset?.indexing_technique)
const { appSidebarExpand } = useAppStore(useShallow(state => ({
appSidebarExpand: state.appSidebarExpand,
})))
const [imageUploaderKey, setImageUploaderKey] = useState(Date.now())
const refreshTimer = useRef<any>(null)
const CustomButton = useMemo(() => (
<>
<Divider type="vertical" className="mx-1 h-3 bg-divider-regular" />
<button
type="button"
className="text-text-accent system-xs-semibold"
onClick={() => {
clearTimeout(refreshTimer.current)
viewNewlyAddedChunk()
}}
>
{t('operation.view', { ns: 'common' })}
</button>
</>
), [viewNewlyAddedChunk, t])
const [imageUploaderKey, setImageUploaderKey] = useState(() => Date.now())
const handleCancel = useCallback((actionType: 'esc' | 'add' = 'esc') => {
if (actionType === 'esc' || !addAnother)
@@ -87,15 +63,15 @@ const NewSegmentModal: FC<NewSegmentModalProps> = ({
const params: SegmentUpdater = { content: '', attachment_ids: [] }
if (docForm === ChunkingMode.qa) {
if (!question.trim()) {
return notify({
return toast.add({
type: 'error',
message: t('segment.questionEmpty', { ns: 'datasetDocuments' }),
title: t('segment.questionEmpty', { ns: 'datasetDocuments' }),
})
}
if (!answer.trim()) {
return notify({
return toast.add({
type: 'error',
message: t('segment.answerEmpty', { ns: 'datasetDocuments' }),
title: t('segment.answerEmpty', { ns: 'datasetDocuments' }),
})
}
@@ -104,9 +80,9 @@ const NewSegmentModal: FC<NewSegmentModalProps> = ({
}
else {
if (!question.trim()) {
return notify({
return toast.add({
type: 'error',
message: t('segment.contentEmpty', { ns: 'datasetDocuments' }),
title: t('segment.contentEmpty', { ns: 'datasetDocuments' }),
})
}
@@ -122,12 +98,13 @@ const NewSegmentModal: FC<NewSegmentModalProps> = ({
setLoading(true)
await addSegment({ datasetId, documentId, body: params }, {
onSuccess() {
notify({
toast.add({
type: 'success',
message: t('segment.chunkAdded', { ns: 'datasetDocuments' }),
className: `!w-[296px] !bottom-0 ${appSidebarExpand === 'expand' ? '!left-[216px]' : '!left-14'}
!top-auto !right-auto !mb-[52px] !ml-11`,
customComponent: CustomButton,
title: t('segment.chunkAdded', { ns: 'datasetDocuments' }),
actionProps: {
children: t('operation.view', { ns: 'common' }),
onClick: viewNewlyAddedChunk,
},
})
handleCancel('add')
setQuestion('')
@@ -135,20 +112,16 @@ const NewSegmentModal: FC<NewSegmentModalProps> = ({
setAttachments([])
setImageUploaderKey(Date.now())
setKeywords([])
refreshTimer.current = setTimeout(() => {
onSave()
}, 3000)
onSave()
},
onSettled() {
setLoading(false)
},
})
}, [docForm, keywords, addSegment, datasetId, documentId, question, answer, attachments, notify, t, appSidebarExpand, CustomButton, handleCancel, onSave])
}, [docForm, keywords, addSegment, datasetId, documentId, question, answer, attachments, t, handleCancel, onSave, viewNewlyAddedChunk])
const wordCountText = useMemo(() => {
const count = docForm === ChunkingMode.qa ? (question.length + answer.length) : question.length
return `${formatNumber(count)} ${t('segment.characters', { ns: 'datasetDocuments', count })}`
}, [question.length, answer.length, docForm, t])
const count = docForm === ChunkingMode.qa ? (question.length + answer.length) : question.length
const wordCountText = `${formatNumber(count)} ${t('segment.characters', { ns: 'datasetDocuments', count })}`
const isECOIndexing = indexingTechnique === IndexingType.ECONOMICAL

View File

@@ -21,11 +21,11 @@ vi.mock('@/context/i18n', () => ({
useDocLink: () => (path?: string) => `https://docs.dify.ai/en${path || ''}`,
}))
const mockNotify = vi.fn()
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({
notify: mockNotify,
}),
const mockNotify = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
add: mockNotify,
},
}))
// Mock modal context
@@ -164,7 +164,7 @@ describe('ExternalKnowledgeBaseConnector', () => {
// Verify success notification
expect(mockNotify).toHaveBeenCalledWith({
type: 'success',
message: 'External Knowledge Base Connected Successfully',
title: 'External Knowledge Base Connected Successfully',
})
// Verify navigation back
@@ -206,7 +206,7 @@ describe('ExternalKnowledgeBaseConnector', () => {
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'Failed to connect External Knowledge Base',
title: 'Failed to connect External Knowledge Base',
})
})
@@ -228,7 +228,7 @@ describe('ExternalKnowledgeBaseConnector', () => {
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'Failed to connect External Knowledge Base',
title: 'Failed to connect External Knowledge Base',
})
})
@@ -274,7 +274,7 @@ describe('ExternalKnowledgeBaseConnector', () => {
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'success',
message: 'External Knowledge Base Connected Successfully',
title: 'External Knowledge Base Connected Successfully',
})
})
})

View File

@@ -4,13 +4,12 @@ import type { CreateKnowledgeBaseReq } from '@/app/components/datasets/external-
import * as React from 'react'
import { useState } from 'react'
import { trackEvent } from '@/app/components/base/amplitude'
import { useToastContext } from '@/app/components/base/toast/context'
import { toast } from '@/app/components/base/ui/toast'
import ExternalKnowledgeBaseCreate from '@/app/components/datasets/external-knowledge-base/create'
import { useRouter } from '@/next/navigation'
import { createExternalKnowledgeBase } from '@/service/datasets'
const ExternalKnowledgeBaseConnector = () => {
const { notify } = useToastContext()
const [loading, setLoading] = useState(false)
const router = useRouter()
@@ -19,7 +18,7 @@ const ExternalKnowledgeBaseConnector = () => {
setLoading(true)
const result = await createExternalKnowledgeBase({ body: formValue })
if (result && result.id) {
notify({ type: 'success', message: 'External Knowledge Base Connected Successfully' })
toast.add({ type: 'success', title: 'External Knowledge Base Connected Successfully' })
trackEvent('create_external_knowledge_base', {
provider: formValue.provider,
name: formValue.name,
@@ -30,7 +29,7 @@ const ExternalKnowledgeBaseConnector = () => {
}
catch (error) {
console.error('Error creating external knowledge base:', error)
notify({ type: 'error', message: 'Failed to connect External Knowledge Base' })
toast.add({ type: 'error', title: 'Failed to connect External Knowledge Base' })
}
setLoading(false)
}

View File

@@ -0,0 +1,112 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import Evaluation from '..'
import { getEvaluationMockConfig } from '../mock'
import { useEvaluationStore } from '../store'
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelList: () => ({
data: [{
provider: 'openai',
models: [{ model: 'gpt-4o-mini' }],
}],
}),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
default: ({ defaultModel }: { defaultModel?: { provider: string, model: string } }) => (
<div data-testid="evaluation-model-selector">
{defaultModel ? `${defaultModel.provider}:${defaultModel.model}` : 'empty'}
</div>
),
}))
describe('Evaluation', () => {
beforeEach(() => {
useEvaluationStore.setState({ resources: {} })
})
it('should search, add metrics, and create a batch history record', async () => {
vi.useFakeTimers()
render(<Evaluation resourceType="workflow" resourceId="app-1" />)
expect(screen.getByTestId('evaluation-model-selector')).toHaveTextContent('openai:gpt-4o-mini')
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.add' }))
expect(screen.getByTestId('evaluation-metric-loading')).toBeInTheDocument()
await act(async () => {
vi.advanceTimersByTime(200)
})
fireEvent.change(screen.getByPlaceholderText('evaluation.metrics.searchPlaceholder'), {
target: { value: 'does-not-exist' },
})
await act(async () => {
vi.advanceTimersByTime(200)
})
expect(screen.getByText('evaluation.metrics.noResults')).toBeInTheDocument()
fireEvent.change(screen.getByPlaceholderText('evaluation.metrics.searchPlaceholder'), {
target: { value: 'faith' },
})
await act(async () => {
vi.advanceTimersByTime(200)
})
fireEvent.click(screen.getByRole('button', { name: /Faithfulness/i }))
expect(screen.getAllByText('Faithfulness').length).toBeGreaterThan(0)
fireEvent.click(screen.getByRole('button', { name: 'evaluation.batch.run' }))
expect(screen.getByText('evaluation.batch.status.running')).toBeInTheDocument()
await act(async () => {
vi.advanceTimersByTime(1300)
})
expect(screen.getByText('evaluation.batch.status.success')).toBeInTheDocument()
expect(screen.getByText('Workflow evaluation batch')).toBeInTheDocument()
vi.useRealTimers()
})
it('should render time placeholders and hide the value row for empty operators', () => {
const resourceType = 'workflow'
const resourceId = 'app-2'
const store = useEvaluationStore.getState()
const config = getEvaluationMockConfig(resourceType)
const timeField = config.fieldOptions.find(field => field.type === 'time')!
let groupId = ''
let itemId = ''
act(() => {
store.ensureResource(resourceType, resourceId)
store.setJudgeModel(resourceType, resourceId, 'openai::gpt-4o-mini')
const group = useEvaluationStore.getState().resources['workflow:app-2'].conditions[0]
groupId = group.id
itemId = group.items[0].id
store.updateConditionField(resourceType, resourceId, groupId, itemId, timeField.id)
store.updateConditionOperator(resourceType, resourceId, groupId, itemId, 'before')
})
let rerender: ReturnType<typeof render>['rerender']
act(() => {
({ rerender } = render(<Evaluation resourceType={resourceType} resourceId={resourceId} />))
})
expect(screen.getByText('evaluation.conditions.selectTime')).toBeInTheDocument()
act(() => {
store.updateConditionOperator(resourceType, resourceId, groupId, itemId, 'is_empty')
rerender(<Evaluation resourceType={resourceType} resourceId={resourceId} />)
})
expect(screen.queryByText('evaluation.conditions.selectTime')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,96 @@
import { getEvaluationMockConfig } from '../mock'
import {
getAllowedOperators,
isCustomMetricConfigured,
requiresConditionValue,
useEvaluationStore,
} from '../store'
describe('evaluation store', () => {
beforeEach(() => {
useEvaluationStore.setState({ resources: {} })
})
it('should configure a custom metric mapping to a valid state', () => {
const resourceType = 'workflow'
const resourceId = 'app-1'
const store = useEvaluationStore.getState()
const config = getEvaluationMockConfig(resourceType)
store.ensureResource(resourceType, resourceId)
store.addCustomMetric(resourceType, resourceId)
const initialMetric = useEvaluationStore.getState().resources['workflow:app-1'].metrics.find(metric => metric.kind === 'custom-workflow')
expect(initialMetric).toBeDefined()
expect(isCustomMetricConfigured(initialMetric!)).toBe(false)
store.setCustomMetricWorkflow(resourceType, resourceId, initialMetric!.id, config.workflowOptions[0].id)
store.updateCustomMetricMapping(resourceType, resourceId, initialMetric!.id, initialMetric!.customConfig!.mappings[0].id, {
sourceFieldId: config.fieldOptions[0].id,
targetVariableId: config.workflowOptions[0].targetVariables[0].id,
})
const configuredMetric = useEvaluationStore.getState().resources['workflow:app-1'].metrics.find(metric => metric.id === initialMetric!.id)
expect(isCustomMetricConfigured(configuredMetric!)).toBe(true)
})
it('should add and remove builtin metrics', () => {
const resourceType = 'workflow'
const resourceId = 'app-2'
const store = useEvaluationStore.getState()
const config = getEvaluationMockConfig(resourceType)
store.ensureResource(resourceType, resourceId)
store.addBuiltinMetric(resourceType, resourceId, config.builtinMetrics[1].id)
const addedMetric = useEvaluationStore.getState().resources['workflow:app-2'].metrics.find(metric => metric.optionId === config.builtinMetrics[1].id)
expect(addedMetric).toBeDefined()
store.removeMetric(resourceType, resourceId, addedMetric!.id)
expect(useEvaluationStore.getState().resources['workflow:app-2'].metrics.some(metric => metric.id === addedMetric!.id)).toBe(false)
})
it('should update condition groups and adapt operators to field types', () => {
const resourceType = 'pipeline'
const resourceId = 'dataset-1'
const store = useEvaluationStore.getState()
const config = getEvaluationMockConfig(resourceType)
store.ensureResource(resourceType, resourceId)
const initialGroup = useEvaluationStore.getState().resources['pipeline:dataset-1'].conditions[0]
store.setConditionGroupOperator(resourceType, resourceId, initialGroup.id, 'or')
store.addConditionGroup(resourceType, resourceId)
const booleanField = config.fieldOptions.find(field => field.type === 'boolean')!
const currentItem = useEvaluationStore.getState().resources['pipeline:dataset-1'].conditions[0].items[0]
store.updateConditionField(resourceType, resourceId, initialGroup.id, currentItem.id, booleanField.id)
const updatedGroup = useEvaluationStore.getState().resources['pipeline:dataset-1'].conditions[0]
expect(updatedGroup.logicalOperator).toBe('or')
expect(updatedGroup.items[0].operator).toBe('is')
expect(getAllowedOperators(resourceType, booleanField.id)).toEqual(['is', 'is_not'])
})
it('should support time fields and clear values for empty operators', () => {
const resourceType = 'workflow'
const resourceId = 'app-3'
const store = useEvaluationStore.getState()
const config = getEvaluationMockConfig(resourceType)
store.ensureResource(resourceType, resourceId)
const timeField = config.fieldOptions.find(field => field.type === 'time')!
const item = useEvaluationStore.getState().resources['workflow:app-3'].conditions[0].items[0]
store.updateConditionField(resourceType, resourceId, useEvaluationStore.getState().resources['workflow:app-3'].conditions[0].id, item.id, timeField.id)
store.updateConditionOperator(resourceType, resourceId, useEvaluationStore.getState().resources['workflow:app-3'].conditions[0].id, item.id, 'is_empty')
const updatedItem = useEvaluationStore.getState().resources['workflow:app-3'].conditions[0].items[0]
expect(getAllowedOperators(resourceType, timeField.id)).toEqual(['is', 'before', 'after', 'is_empty', 'is_not_empty'])
expect(requiresConditionValue('is_empty')).toBe(false)
expect(updatedItem.value).toBeNull()
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,184 @@
import type {
ComparisonOperator,
EvaluationFieldOption,
EvaluationMockConfig,
EvaluationResourceType,
MetricOption,
} from './types'
const judgeModels = [
{
id: 'gpt-4.1-mini',
label: 'GPT-4.1 mini',
provider: 'OpenAI',
},
{
id: 'claude-3-7-sonnet',
label: 'Claude 3.7 Sonnet',
provider: 'Anthropic',
},
{
id: 'gemini-2.0-flash',
label: 'Gemini 2.0 Flash',
provider: 'Google',
},
]
const builtinMetrics: MetricOption[] = [
{
id: 'answer-correctness',
label: 'Answer Correctness',
description: 'Compares the response with the expected answer and scores factual alignment.',
group: 'quality',
badges: ['LLM', 'Built-in'],
},
{
id: 'faithfulness',
label: 'Faithfulness',
description: 'Checks whether the answer stays grounded in the retrieved evidence.',
group: 'quality',
badges: ['LLM', 'Retrieval'],
},
{
id: 'relevance',
label: 'Relevance',
description: 'Evaluates how directly the answer addresses the original request.',
group: 'quality',
badges: ['LLM'],
},
{
id: 'latency',
label: 'Latency',
description: 'Captures runtime responsiveness for the full execution path.',
group: 'operations',
badges: ['System'],
},
{
id: 'token-usage',
label: 'Token Usage',
description: 'Tracks prompt and completion token consumption for the run.',
group: 'operations',
badges: ['System'],
},
{
id: 'tool-success-rate',
label: 'Tool Success Rate',
description: 'Measures whether each required tool invocation finishes without failure.',
group: 'operations',
badges: ['Workflow'],
},
]
const workflowOptions = [
{
id: 'workflow-precision-review',
label: 'Precision Review Workflow',
description: 'Custom evaluator for nuanced quality review.',
targetVariables: [
{ id: 'query', label: 'query' },
{ id: 'answer', label: 'answer' },
{ id: 'reference', label: 'reference' },
],
},
{
id: 'workflow-risk-review',
label: 'Risk Review Workflow',
description: 'Custom evaluator for policy and escalation checks.',
targetVariables: [
{ id: 'input', label: 'input' },
{ id: 'output', label: 'output' },
],
},
]
const workflowFields: EvaluationFieldOption[] = [
{ id: 'app.input.query', label: 'Query', group: 'App Input', type: 'string' },
{ id: 'app.input.locale', label: 'Locale', group: 'App Input', type: 'enum', options: [{ value: 'en-US', label: 'en-US' }, { value: 'zh-Hans', label: 'zh-Hans' }] },
{ id: 'app.output.answer', label: 'Answer', group: 'App Output', type: 'string' },
{ id: 'app.output.score', label: 'Score', group: 'App Output', type: 'number' },
{ id: 'app.output.published_at', label: 'Publication Date', group: 'App Output', type: 'time' },
{ id: 'system.has_context', label: 'Has Context', group: 'System', type: 'boolean' },
]
const pipelineFields: EvaluationFieldOption[] = [
{ id: 'dataset.input.document_id', label: 'Document ID', group: 'Dataset', type: 'string' },
{ id: 'dataset.input.chunk_count', label: 'Chunk Count', group: 'Dataset', type: 'number' },
{ id: 'dataset.input.updated_at', label: 'Updated At', group: 'Dataset', type: 'time' },
{ id: 'retrieval.output.hit_rate', label: 'Hit Rate', group: 'Retrieval', type: 'number' },
{ id: 'retrieval.output.source', label: 'Source', group: 'Retrieval', type: 'enum', options: [{ value: 'bm25', label: 'BM25' }, { value: 'hybrid', label: 'Hybrid' }] },
{ id: 'pipeline.output.published', label: 'Published', group: 'Output', type: 'boolean' },
]
const snippetFields: EvaluationFieldOption[] = [
{ id: 'snippet.input.blog_url', label: 'Blog URL', group: 'Snippet Input', type: 'string' },
{ id: 'snippet.input.platforms', label: 'Platforms', group: 'Snippet Input', type: 'string' },
{ id: 'snippet.output.content', label: 'Generated Content', group: 'Snippet Output', type: 'string' },
{ id: 'snippet.output.length', label: 'Output Length', group: 'Snippet Output', type: 'number' },
{ id: 'snippet.output.scheduled_at', label: 'Scheduled At', group: 'Snippet Output', type: 'time' },
{ id: 'system.requires_review', label: 'Requires Review', group: 'System', type: 'boolean' },
]
export const getComparisonOperators = (fieldType: EvaluationFieldOption['type']): ComparisonOperator[] => {
if (fieldType === 'number')
return ['is', 'is_not', 'greater_than', 'less_than', 'greater_or_equal', 'less_or_equal', 'is_empty', 'is_not_empty']
if (fieldType === 'time')
return ['is', 'before', 'after', 'is_empty', 'is_not_empty']
if (fieldType === 'boolean' || fieldType === 'enum')
return ['is', 'is_not']
return ['contains', 'not_contains', 'is', 'is_not', 'is_empty', 'is_not_empty']
}
export const getDefaultOperator = (fieldType: EvaluationFieldOption['type']): ComparisonOperator => {
return getComparisonOperators(fieldType)[0]
}
export const getEvaluationMockConfig = (resourceType: EvaluationResourceType): EvaluationMockConfig => {
if (resourceType === 'pipeline') {
return {
judgeModels,
builtinMetrics,
workflowOptions,
fieldOptions: pipelineFields,
templateFileName: 'pipeline-evaluation-template.csv',
batchRequirements: [
'Include one row per retrieval scenario.',
'Provide the expected source or target chunk for each case.',
'Keep numeric metrics in plain number format.',
],
historySummaryLabel: 'Pipeline evaluation batch',
}
}
if (resourceType === 'snippet') {
return {
judgeModels,
builtinMetrics,
workflowOptions,
fieldOptions: snippetFields,
templateFileName: 'snippet-evaluation-template.csv',
batchRequirements: [
'Include one row per snippet execution case.',
'Provide the expected final content or acceptance rule.',
'Keep optional fields empty when not used.',
],
historySummaryLabel: 'Snippet evaluation batch',
}
}
return {
judgeModels,
builtinMetrics,
workflowOptions,
fieldOptions: workflowFields,
templateFileName: 'workflow-evaluation-template.csv',
batchRequirements: [
'Include one row per workflow test case.',
'Provide both user input and expected answer when available.',
'Keep boolean columns as true or false.',
],
historySummaryLabel: 'Workflow evaluation batch',
}
}

View File

@@ -0,0 +1,635 @@
import type {
BatchTestRecord,
ComparisonOperator,
EvaluationFieldOption,
EvaluationMetric,
EvaluationResourceState,
EvaluationResourceType,
JudgmentConditionGroup,
} from './types'
import { create } from 'zustand'
import { getComparisonOperators, getDefaultOperator, getEvaluationMockConfig } from './mock'
type EvaluationStore = {
resources: Record<string, EvaluationResourceState>
ensureResource: (resourceType: EvaluationResourceType, resourceId: string) => void
setJudgeModel: (resourceType: EvaluationResourceType, resourceId: string, judgeModelId: string) => void
addBuiltinMetric: (resourceType: EvaluationResourceType, resourceId: string, optionId: string) => void
addCustomMetric: (resourceType: EvaluationResourceType, resourceId: string) => void
removeMetric: (resourceType: EvaluationResourceType, resourceId: string, metricId: string) => void
setCustomMetricWorkflow: (resourceType: EvaluationResourceType, resourceId: string, metricId: string, workflowId: string) => void
addCustomMetricMapping: (resourceType: EvaluationResourceType, resourceId: string, metricId: string) => void
updateCustomMetricMapping: (
resourceType: EvaluationResourceType,
resourceId: string,
metricId: string,
mappingId: string,
patch: { sourceFieldId?: string | null, targetVariableId?: string | null },
) => void
removeCustomMetricMapping: (resourceType: EvaluationResourceType, resourceId: string, metricId: string, mappingId: string) => void
addConditionGroup: (resourceType: EvaluationResourceType, resourceId: string) => void
removeConditionGroup: (resourceType: EvaluationResourceType, resourceId: string, groupId: string) => void
setConditionGroupOperator: (resourceType: EvaluationResourceType, resourceId: string, groupId: string, logicalOperator: 'and' | 'or') => void
addConditionItem: (resourceType: EvaluationResourceType, resourceId: string, groupId: string) => void
removeConditionItem: (resourceType: EvaluationResourceType, resourceId: string, groupId: string, itemId: string) => void
updateConditionField: (resourceType: EvaluationResourceType, resourceId: string, groupId: string, itemId: string, fieldId: string) => void
updateConditionOperator: (resourceType: EvaluationResourceType, resourceId: string, groupId: string, itemId: string, operator: ComparisonOperator) => void
updateConditionValue: (
resourceType: EvaluationResourceType,
resourceId: string,
groupId: string,
itemId: string,
value: string | number | boolean | null,
) => void
setBatchTab: (resourceType: EvaluationResourceType, resourceId: string, tab: EvaluationResourceState['activeBatchTab']) => void
setUploadedFileName: (resourceType: EvaluationResourceType, resourceId: string, uploadedFileName: string | null) => void
runBatchTest: (resourceType: EvaluationResourceType, resourceId: string) => void
}
const buildResourceKey = (resourceType: EvaluationResourceType, resourceId: string) => `${resourceType}:${resourceId}`
const initialResourceCache: Record<string, EvaluationResourceState> = {}
const createId = (prefix: string) => `${prefix}-${Math.random().toString(36).slice(2, 10)}`
export const conditionOperatorsWithoutValue: ComparisonOperator[] = ['is_empty', 'is_not_empty']
export const requiresConditionValue = (operator: ComparisonOperator) => !conditionOperatorsWithoutValue.includes(operator)
const getConditionValue = (
field: EvaluationFieldOption | undefined,
operator: ComparisonOperator,
previousValue: string | number | boolean | null = null,
) => {
if (!field || !requiresConditionValue(operator))
return null
if (field.type === 'boolean')
return typeof previousValue === 'boolean' ? previousValue : null
if (field.type === 'enum')
return typeof previousValue === 'string' ? previousValue : null
if (field.type === 'number')
return typeof previousValue === 'number' ? previousValue : null
return typeof previousValue === 'string' ? previousValue : null
}
const buildConditionItem = (resourceType: EvaluationResourceType) => {
const field = getEvaluationMockConfig(resourceType).fieldOptions[0]
const operator = field ? getDefaultOperator(field.type) : 'contains'
return {
id: createId('condition'),
fieldId: field?.id ?? null,
operator,
value: getConditionValue(field, operator),
}
}
const buildInitialState = (resourceType: EvaluationResourceType): EvaluationResourceState => {
const config = getEvaluationMockConfig(resourceType)
const defaultMetric = config.builtinMetrics[0]
return {
judgeModelId: null,
metrics: defaultMetric
? [{
id: createId('metric'),
optionId: defaultMetric.id,
kind: 'builtin',
label: defaultMetric.label,
description: defaultMetric.description,
badges: defaultMetric.badges,
}]
: [],
conditions: [{
id: createId('group'),
logicalOperator: 'and',
items: [buildConditionItem(resourceType)],
}],
activeBatchTab: 'input-fields',
uploadedFileName: null,
batchRecords: [],
}
}
const withResourceState = (
resources: EvaluationStore['resources'],
resourceType: EvaluationResourceType,
resourceId: string,
) => {
const resourceKey = buildResourceKey(resourceType, resourceId)
return {
resourceKey,
resource: resources[resourceKey] ?? buildInitialState(resourceType),
}
}
const updateMetric = (
metrics: EvaluationMetric[],
metricId: string,
updater: (metric: EvaluationMetric) => EvaluationMetric,
) => metrics.map(metric => metric.id === metricId ? updater(metric) : metric)
const updateConditionGroup = (
groups: JudgmentConditionGroup[],
groupId: string,
updater: (group: JudgmentConditionGroup) => JudgmentConditionGroup,
) => groups.map(group => group.id === groupId ? updater(group) : group)
export const isCustomMetricConfigured = (metric: EvaluationMetric) => {
if (metric.kind !== 'custom-workflow')
return true
if (!metric.customConfig?.workflowId)
return false
return metric.customConfig.mappings.length > 0
&& metric.customConfig.mappings.every(mapping => !!mapping.sourceFieldId && !!mapping.targetVariableId)
}
export const isEvaluationRunnable = (state: EvaluationResourceState) => {
return !!state.judgeModelId
&& state.metrics.length > 0
&& state.metrics.every(isCustomMetricConfigured)
&& state.conditions.some(group => group.items.length > 0)
}
export const useEvaluationStore = create<EvaluationStore>((set, get) => ({
resources: {},
ensureResource: (resourceType, resourceId) => {
const resourceKey = buildResourceKey(resourceType, resourceId)
if (get().resources[resourceKey])
return
set(state => ({
resources: {
...state.resources,
[resourceKey]: buildInitialState(resourceType),
},
}))
},
setJudgeModel: (resourceType, resourceId, judgeModelId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
judgeModelId,
},
},
}
})
},
addBuiltinMetric: (resourceType, resourceId, optionId) => {
const option = getEvaluationMockConfig(resourceType).builtinMetrics.find(metric => metric.id === optionId)
if (!option)
return
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
if (resource.metrics.some(metric => metric.optionId === optionId && metric.kind === 'builtin'))
return state
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
metrics: [
...resource.metrics,
{
id: createId('metric'),
optionId: option.id,
kind: 'builtin',
label: option.label,
description: option.description,
badges: option.badges,
},
],
},
},
}
})
},
addCustomMetric: (resourceType, resourceId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
metrics: [
...resource.metrics,
{
id: createId('metric'),
optionId: createId('custom'),
kind: 'custom-workflow',
label: 'Custom Evaluator',
description: 'Map workflow variables to your evaluation inputs.',
badges: ['Workflow'],
customConfig: {
workflowId: null,
mappings: [{
id: createId('mapping'),
sourceFieldId: null,
targetVariableId: null,
}],
},
},
],
},
},
}
})
},
removeMetric: (resourceType, resourceId, metricId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
metrics: resource.metrics.filter(metric => metric.id !== metricId),
},
},
}
})
},
setCustomMetricWorkflow: (resourceType, resourceId, metricId, workflowId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
metrics: updateMetric(resource.metrics, metricId, metric => ({
...metric,
customConfig: metric.customConfig
? {
...metric.customConfig,
workflowId,
mappings: metric.customConfig.mappings.map(mapping => ({
...mapping,
targetVariableId: null,
})),
}
: metric.customConfig,
})),
},
},
}
})
},
addCustomMetricMapping: (resourceType, resourceId, metricId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
metrics: updateMetric(resource.metrics, metricId, metric => ({
...metric,
customConfig: metric.customConfig
? {
...metric.customConfig,
mappings: [
...metric.customConfig.mappings,
{
id: createId('mapping'),
sourceFieldId: null,
targetVariableId: null,
},
],
}
: metric.customConfig,
})),
},
},
}
})
},
updateCustomMetricMapping: (resourceType, resourceId, metricId, mappingId, patch) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
metrics: updateMetric(resource.metrics, metricId, metric => ({
...metric,
customConfig: metric.customConfig
? {
...metric.customConfig,
mappings: metric.customConfig.mappings.map(mapping => mapping.id === mappingId ? { ...mapping, ...patch } : mapping),
}
: metric.customConfig,
})),
},
},
}
})
},
removeCustomMetricMapping: (resourceType, resourceId, metricId, mappingId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
metrics: updateMetric(resource.metrics, metricId, metric => ({
...metric,
customConfig: metric.customConfig
? {
...metric.customConfig,
mappings: metric.customConfig.mappings.filter(mapping => mapping.id !== mappingId),
}
: metric.customConfig,
})),
},
},
}
})
},
addConditionGroup: (resourceType, resourceId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: [
...resource.conditions,
{
id: createId('group'),
logicalOperator: 'and',
items: [buildConditionItem(resourceType)],
},
],
},
},
}
})
},
removeConditionGroup: (resourceType, resourceId, groupId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: resource.conditions.filter(group => group.id !== groupId),
},
},
}
})
},
setConditionGroupOperator: (resourceType, resourceId, groupId, logicalOperator) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
logicalOperator,
})),
},
},
}
})
},
addConditionItem: (resourceType, resourceId, groupId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
items: [
...group.items,
buildConditionItem(resourceType),
],
})),
},
},
}
})
},
removeConditionItem: (resourceType, resourceId, groupId, itemId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
items: group.items.filter(item => item.id !== itemId),
})),
},
},
}
})
},
updateConditionField: (resourceType, resourceId, groupId, itemId, fieldId) => {
const field = getEvaluationMockConfig(resourceType).fieldOptions.find(option => option.id === fieldId)
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
items: group.items.map((item) => {
if (item.id !== itemId)
return item
return {
...item,
fieldId,
operator: field ? getDefaultOperator(field.type) : item.operator,
value: getConditionValue(field, field ? getDefaultOperator(field.type) : item.operator),
}
}),
})),
},
},
}
})
},
updateConditionOperator: (resourceType, resourceId, groupId, itemId, operator) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
const fieldOptions = getEvaluationMockConfig(resourceType).fieldOptions
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
items: group.items.map((item) => {
if (item.id !== itemId)
return item
const field = fieldOptions.find(option => option.id === item.fieldId)
return {
...item,
operator,
value: getConditionValue(field, operator, item.value),
}
}),
})),
},
},
}
})
},
updateConditionValue: (resourceType, resourceId, groupId, itemId, value) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
items: group.items.map(item => item.id === itemId ? { ...item, value } : item),
})),
},
},
}
})
},
setBatchTab: (resourceType, resourceId, tab) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
activeBatchTab: tab,
},
},
}
})
},
setUploadedFileName: (resourceType, resourceId, uploadedFileName) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
uploadedFileName,
},
},
}
})
},
runBatchTest: (resourceType, resourceId) => {
const config = getEvaluationMockConfig(resourceType)
const recordId = createId('batch')
const nextRecord: BatchTestRecord = {
id: recordId,
fileName: get().resources[buildResourceKey(resourceType, resourceId)]?.uploadedFileName ?? config.templateFileName,
status: 'running',
startedAt: new Date().toLocaleTimeString(),
summary: config.historySummaryLabel,
}
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
activeBatchTab: 'history',
batchRecords: [nextRecord, ...resource.batchRecords],
},
},
}
})
window.setTimeout(() => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
batchRecords: resource.batchRecords.map(record => record.id === recordId
? {
...record,
status: resource.metrics.length > 1 ? 'success' : 'failed',
}
: record),
},
},
}
})
}, 1200)
},
}))
export const useEvaluationResource = (resourceType: EvaluationResourceType, resourceId: string) => {
const resourceKey = buildResourceKey(resourceType, resourceId)
return useEvaluationStore(state => state.resources[resourceKey] ?? (initialResourceCache[resourceKey] ??= buildInitialState(resourceType)))
}
export const getAllowedOperators = (resourceType: EvaluationResourceType, fieldId: string | null) => {
const field = getEvaluationMockConfig(resourceType).fieldOptions.find(option => option.id === fieldId)
if (!field)
return ['contains'] as ComparisonOperator[]
return getComparisonOperators(field.type)
}

View File

@@ -0,0 +1,117 @@
export type EvaluationResourceType = 'workflow' | 'pipeline' | 'snippet'
export type MetricKind = 'builtin' | 'custom-workflow'
export type BatchTestTab = 'input-fields' | 'history'
export type FieldType = 'string' | 'number' | 'boolean' | 'enum' | 'time'
export type ComparisonOperator
= | 'contains'
| 'not_contains'
| 'is'
| 'is_not'
| 'is_empty'
| 'is_not_empty'
| 'greater_than'
| 'less_than'
| 'greater_or_equal'
| 'less_or_equal'
| 'before'
| 'after'
export type JudgeModelOption = {
id: string
label: string
provider: string
}
export type MetricOption = {
id: string
label: string
description: string
group: string
badges: string[]
}
export type EvaluationWorkflowOption = {
id: string
label: string
description: string
targetVariables: Array<{
id: string
label: string
}>
}
export type EvaluationFieldOption = {
id: string
label: string
group: string
type: FieldType
options?: Array<{
value: string
label: string
}>
}
export type CustomMetricMapping = {
id: string
sourceFieldId: string | null
targetVariableId: string | null
}
export type CustomMetricConfig = {
workflowId: string | null
mappings: CustomMetricMapping[]
}
export type EvaluationMetric = {
id: string
optionId: string
kind: MetricKind
label: string
description: string
badges: string[]
customConfig?: CustomMetricConfig
}
export type JudgmentConditionItem = {
id: string
fieldId: string | null
operator: ComparisonOperator
value: string | number | boolean | null
}
export type JudgmentConditionGroup = {
id: string
logicalOperator: 'and' | 'or'
items: JudgmentConditionItem[]
}
export type BatchTestRecord = {
id: string
fileName: string
status: 'running' | 'success' | 'failed'
startedAt: string
summary: string
}
export type EvaluationResourceState = {
judgeModelId: string | null
metrics: EvaluationMetric[]
conditions: JudgmentConditionGroup[]
activeBatchTab: BatchTestTab
uploadedFileName: string | null
batchRecords: BatchTestRecord[]
}
export type EvaluationMockConfig = {
judgeModels: JudgeModelOption[]
builtinMetrics: MetricOption[]
workflowOptions: EvaluationWorkflowOption[]
fieldOptions: EvaluationFieldOption[]
templateFileName: string
batchRequirements: string[]
historySummaryLabel: string
}

View File

@@ -36,30 +36,13 @@ const TransferOwnershipModal = ({ onClose, show }: Props) => {
const [stepToken, setStepToken] = useState<string>('')
const [newOwner, setNewOwner] = useState<string>('')
const [isTransfer, setIsTransfer] = useState<boolean>(false)
const timerRef = React.useRef<ReturnType<typeof setInterval> | null>(null)
React.useEffect(() => {
return () => {
if (timerRef.current) {
clearInterval(timerRef.current)
timerRef.current = null
}
}
}, [])
const startCount = () => {
if (timerRef.current) {
clearInterval(timerRef.current)
timerRef.current = null
}
setTime(60)
timerRef.current = setInterval(() => {
const timer = setInterval(() => {
setTime((prev) => {
if (prev <= 0) {
if (timerRef.current) {
clearInterval(timerRef.current)
timerRef.current = null
}
clearInterval(timer)
return 0
}
return prev - 1

View File

@@ -43,10 +43,10 @@ vi.mock('@/context/provider-context', () => ({
}),
}))
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({
notify: mockNotify,
}),
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
add: mockNotify,
},
}))
vi.mock('../../hooks', () => ({
@@ -150,7 +150,7 @@ describe('SystemModel', () => {
expect(mockUpdateDefaultModel).toHaveBeenCalledTimes(1)
expect(mockNotify).toHaveBeenCalledWith({
type: 'success',
message: 'Modified successfully',
title: 'Modified successfully',
})
expect(mockInvalidateDefaultModel).toHaveBeenCalledTimes(5)
expect(mockUpdateModelList).toHaveBeenCalledTimes(5)

View File

@@ -6,13 +6,13 @@ import type {
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import { useToastContext } from '@/app/components/base/toast/context'
import {
Dialog,
DialogCloseButton,
DialogContent,
DialogTitle,
} from '@/app/components/base/ui/dialog'
import { toast } from '@/app/components/base/ui/toast'
import {
Tooltip,
TooltipContent,
@@ -64,7 +64,6 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
isLoading,
}) => {
const { t } = useTranslation()
const { notify } = useToastContext()
const { isCurrentWorkspaceManager } = useAppContext()
const { textGenerationModelList } = useProviderContext()
const updateModelList = useUpdateModelList()
@@ -124,7 +123,7 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
},
})
if (res.result === 'success') {
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
toast.add({ type: 'success', title: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
setOpen(false)
const allModelTypes = [ModelTypeEnum.textGeneration, ModelTypeEnum.textEmbedding, ModelTypeEnum.rerank, ModelTypeEnum.speech2text, ModelTypeEnum.tts]

View File

@@ -105,7 +105,7 @@ const AppNav = () => {
icon={<RiRobot2Line className="h-4 w-4" />}
activeIcon={<RiRobot2Fill className="h-4 w-4" />}
text={t('menus.apps', { ns: 'common' })}
activeSegment={['apps', 'app']}
activeSegment={['apps', 'app', 'snippets']}
link="/apps"
curNav={appDetail}
navigationItems={navItems}

View File

@@ -14,7 +14,7 @@ const HeaderWrapper = ({
children,
}: HeaderWrapperProps) => {
const pathname = usePathname()
const isBordered = ['/apps', '/datasets/create', '/tools'].includes(pathname)
const isBordered = ['/apps', '/snippets', '/datasets/create', '/tools'].includes(pathname)
// Check if the current path is a workflow canvas & fullscreen
const inWorkflowCanvas = pathname.endsWith('/workflow')
const isPipelineCanvas = pathname.endsWith('/pipeline')

View File

@@ -4,7 +4,7 @@ import { DeleteConfirm } from '../delete-confirm'
const mockRefetch = vi.fn()
const mockDelete = vi.fn()
const mockToast = vi.fn()
const mockToastAdd = vi.hoisted(() => vi.fn())
vi.mock('../use-subscription-list', () => ({
useSubscriptionList: () => ({ refetch: mockRefetch }),
@@ -14,9 +14,9 @@ vi.mock('@/service/use-triggers', () => ({
useDeleteTriggerSubscription: () => ({ mutate: mockDelete, isPending: false }),
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: (args: { type: string, message: string }) => mockToast(args),
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
add: mockToastAdd,
},
}))
@@ -42,7 +42,7 @@ describe('DeleteConfirm', () => {
fireEvent.click(screen.getByRole('button', { name: /pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.confirm/ }))
expect(mockDelete).not.toHaveBeenCalled()
expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
expect(mockToastAdd).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
})
it('should allow deletion after matching input name', () => {
@@ -87,6 +87,6 @@ describe('DeleteConfirm', () => {
fireEvent.click(screen.getByRole('button', { name: /pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.confirm/ }))
expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'error', message: 'network error' }))
expect(mockToastAdd).toHaveBeenCalledWith(expect.objectContaining({ type: 'error', title: 'network error' }))
})
})

View File

@@ -1,8 +1,16 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Confirm from '@/app/components/base/confirm'
import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import { toast } from '@/app/components/base/ui/toast'
import { useDeleteTriggerSubscription } from '@/service/use-triggers'
import { useSubscriptionList } from './use-subscription-list'
@@ -23,58 +31,74 @@ export const DeleteConfirm = (props: Props) => {
const { t } = useTranslation()
const [inputName, setInputName] = useState('')
const handleOpenChange = (open: boolean) => {
if (isDeleting)
return
if (!open)
onClose(false)
}
const onConfirm = () => {
if (workflowsInUse > 0 && inputName !== currentName) {
Toast.notify({
toast.add({
type: 'error',
message: t(`${tPrefix}.confirmInputWarning`, { ns: 'pluginTrigger' }),
// temporarily
className: 'z-[10000001]',
title: t(`${tPrefix}.confirmInputWarning`, { ns: 'pluginTrigger' }),
})
return
}
deleteSubscription(currentId, {
onSuccess: () => {
Toast.notify({
toast.add({
type: 'success',
message: t(`${tPrefix}.success`, { ns: 'pluginTrigger', name: currentName }),
className: 'z-[10000001]',
title: t(`${tPrefix}.success`, { ns: 'pluginTrigger', name: currentName }),
})
refetch?.()
onClose(true)
},
onError: (error: any) => {
Toast.notify({
onError: (error: unknown) => {
toast.add({
type: 'error',
message: error?.message || t(`${tPrefix}.error`, { ns: 'pluginTrigger', name: currentName }),
className: 'z-[10000001]',
title: error instanceof Error ? error.message : t(`${tPrefix}.error`, { ns: 'pluginTrigger', name: currentName }),
})
},
})
}
return (
<Confirm
title={t(`${tPrefix}.title`, { ns: 'pluginTrigger', name: currentName })}
confirmText={t(`${tPrefix}.confirm`, { ns: 'pluginTrigger' })}
content={workflowsInUse > 0
? (
<>
{t(`${tPrefix}.contentWithApps`, { ns: 'pluginTrigger', count: workflowsInUse })}
<div className="system-sm-medium mb-2 mt-6 text-text-secondary">{t(`${tPrefix}.confirmInputTip`, { ns: 'pluginTrigger', name: currentName })}</div>
<AlertDialog open={isShow} onOpenChange={handleOpenChange}>
<AlertDialogContent backdropProps={{ forceRender: true }}>
<div className="flex flex-col gap-2 px-6 pb-4 pt-6">
<AlertDialogTitle title={t(`${tPrefix}.title`, { ns: 'pluginTrigger', name: currentName })} className="w-full truncate text-text-primary title-2xl-semi-bold">
{t(`${tPrefix}.title`, { ns: 'pluginTrigger', name: currentName })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full whitespace-pre-wrap break-words text-text-tertiary system-md-regular">
{workflowsInUse > 0
? t(`${tPrefix}.contentWithApps`, { ns: 'pluginTrigger', count: workflowsInUse })
: t(`${tPrefix}.content`, { ns: 'pluginTrigger' })}
</AlertDialogDescription>
{workflowsInUse > 0 && (
<div className="mt-6">
<div className="mb-2 text-text-secondary system-sm-medium">
{t(`${tPrefix}.confirmInputTip`, { ns: 'pluginTrigger', name: currentName })}
</div>
<Input
value={inputName}
onChange={e => setInputName(e.target.value)}
placeholder={t(`${tPrefix}.confirmInputPlaceholder`, { ns: 'pluginTrigger', name: currentName })}
/>
</>
)
: t(`${tPrefix}.content`, { ns: 'pluginTrigger' })}
isShow={isShow}
isLoading={isDeleting}
isDisabled={isDeleting}
onConfirm={onConfirm}
onCancel={() => onClose(false)}
maskClosable={false}
/>
</div>
)}
</div>
<AlertDialogActions>
<AlertDialogCancelButton disabled={isDeleting}>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton loading={isDeleting} disabled={isDeleting} onClick={onConfirm}>
{t(`${tPrefix}.confirm`, { ns: 'pluginTrigger' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -0,0 +1,171 @@
import type { SnippetDetailPayload } from '@/models/snippet'
import { fireEvent, render, screen } from '@testing-library/react'
import { PipelineInputVarType } from '@/models/pipeline'
import SnippetPage from '..'
import { useSnippetDetailStore } from '../store'
const mockUseSnippetDetail = vi.fn()
vi.mock('@/service/use-snippets', () => ({
useSnippetDetail: (snippetId: string) => mockUseSnippetDetail(snippetId),
}))
vi.mock('@/service/use-common', () => ({
useFileUploadConfig: () => ({
data: undefined,
}),
}))
vi.mock('@/hooks/use-breakpoints', () => ({
default: () => 'desktop',
MediaType: { mobile: 'mobile', desktop: 'desktop' },
}))
vi.mock('@/app/components/workflow', () => ({
default: ({ children }: { children: React.ReactNode }) => (
<div data-testid="workflow-default-context">{children}</div>
),
WorkflowWithInnerContext: ({ children, viewport }: { children: React.ReactNode, viewport?: { zoom?: number } }) => (
<div data-testid="workflow-inner-context">
<span data-testid="workflow-viewport-zoom">{viewport?.zoom ?? 'none'}</span>
{children}
</div>
),
}))
vi.mock('@/app/components/workflow/context', () => ({
WorkflowContextProvider: ({ children }: { children: React.ReactNode }) => (
<div data-testid="workflow-context-provider">{children}</div>
),
}))
vi.mock('@/app/components/app-sidebar', () => ({
default: ({
renderHeader,
renderNavigation,
}: {
renderHeader?: (modeState: string) => React.ReactNode
renderNavigation?: (modeState: string) => React.ReactNode
}) => (
<div data-testid="app-sidebar">
<div data-testid="app-sidebar-header">{renderHeader?.('expand')}</div>
<div data-testid="app-sidebar-navigation">{renderNavigation?.('expand')}</div>
</div>
),
}))
vi.mock('@/app/components/app-sidebar/nav-link', () => ({
default: ({ name, onClick }: { name: string, onClick?: () => void }) => (
<button type="button" onClick={onClick}>{name}</button>
),
}))
vi.mock('@/app/components/workflow/panel', () => ({
default: ({ components }: { components?: { left?: React.ReactNode, right?: React.ReactNode } }) => (
<div data-testid="workflow-panel">
<div data-testid="workflow-panel-left">{components?.left}</div>
<div data-testid="workflow-panel-right">{components?.right}</div>
</div>
),
}))
vi.mock('@/app/components/workflow/utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/workflow/utils')>()
return {
...actual,
initialNodes: (nodes: unknown[]) => nodes,
initialEdges: (edges: unknown[]) => edges,
}
})
vi.mock('react-sortablejs', () => ({
ReactSortable: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
const mockSnippetDetail: SnippetDetailPayload = {
snippet: {
id: 'snippet-1',
name: 'Tone Rewriter',
description: 'A static snippet mock.',
author: 'Evan',
updatedAt: 'Updated 2h ago',
usage: 'Used 19 times',
icon: '🪄',
iconBackground: '#E0EAFF',
status: 'Draft',
},
graph: {
viewport: { x: 0, y: 0, zoom: 1 },
nodes: [],
edges: [],
},
inputFields: [
{
type: PipelineInputVarType.textInput,
label: 'Blog URL',
variable: 'blog_url',
required: true,
options: [],
placeholder: 'Paste a source article URL',
max_length: 256,
},
],
uiMeta: {
inputFieldCount: 1,
checklistCount: 2,
autoSavedAt: 'Auto-saved · a few seconds ago',
},
}
describe('SnippetPage', () => {
beforeEach(() => {
vi.clearAllMocks()
useSnippetDetailStore.getState().reset()
mockUseSnippetDetail.mockReturnValue({
data: mockSnippetDetail,
isLoading: false,
})
})
it('should render the snippet detail shell', () => {
render(<SnippetPage snippetId="snippet-1" />)
expect(screen.getByText('Tone Rewriter')).toBeInTheDocument()
expect(screen.getByText('A static snippet mock.')).toBeInTheDocument()
expect(screen.getByTestId('app-sidebar')).toBeInTheDocument()
expect(screen.getByTestId('workflow-context-provider')).toBeInTheDocument()
expect(screen.getByTestId('workflow-default-context')).toBeInTheDocument()
expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument()
expect(screen.getByTestId('workflow-viewport-zoom').textContent).toBe('1')
})
it('should open the input field panel and editor', () => {
render(<SnippetPage snippetId="snippet-1" />)
fireEvent.click(screen.getAllByRole('button', { name: /snippet\.inputFieldButton/i })[0])
expect(screen.getAllByText('snippet.panelTitle').length).toBeGreaterThan(0)
fireEvent.click(screen.getAllByRole('button', { name: /datasetPipeline\.inputFieldPanel\.addInputField/i })[0])
expect(screen.getAllByText('datasetPipeline.inputFieldPanel.addInputField').length).toBeGreaterThan(1)
})
it('should toggle the publish menu', () => {
render(<SnippetPage snippetId="snippet-1" />)
fireEvent.click(screen.getByRole('button', { name: /snippet\.publishButton/i }))
expect(screen.getByText('snippet.publishMenuCurrentDraft')).toBeInTheDocument()
})
it('should render a controlled not found state', () => {
mockUseSnippetDetail.mockReturnValue({
data: null,
isLoading: false,
})
render(<SnippetPage snippetId="missing-snippet" />)
expect(screen.getByText('snippet.notFoundTitle')).toBeInTheDocument()
expect(screen.getByText('snippet.notFoundDescription')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,54 @@
'use client'
import type { FormData } from '@/app/components/rag-pipeline/components/panel/input-field/editor/form/types'
import type { SnippetInputField } from '@/models/snippet'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import InputFieldForm from '@/app/components/rag-pipeline/components/panel/input-field/editor/form'
import { convertFormDataToINputField, convertToInputFieldFormData } from '@/app/components/rag-pipeline/components/panel/input-field/editor/utils'
type SnippetInputFieldEditorProps = {
field?: SnippetInputField | null
onClose: () => void
onSubmit: (field: SnippetInputField) => void
}
const SnippetInputFieldEditor = ({
field,
onClose,
onSubmit,
}: SnippetInputFieldEditorProps) => {
const { t } = useTranslation()
const initialData = useMemo(() => {
return convertToInputFieldFormData(field || undefined)
}, [field])
const handleSubmit = useCallback((value: FormData) => {
onSubmit(convertFormDataToINputField(value))
}, [onSubmit])
return (
<div className="relative mr-1 flex h-fit max-h-full w-[min(400px,calc(100vw-24px))] flex-col overflow-y-auto rounded-2xl border border-components-panel-border bg-components-panel-bg shadow-2xl shadow-shadow-shadow-9">
<div className="flex items-center pb-1 pl-4 pr-11 pt-3.5 text-text-primary system-xl-semibold">
{field ? t('inputFieldPanel.editInputField', { ns: 'datasetPipeline' }) : t('inputFieldPanel.addInputField', { ns: 'datasetPipeline' })}
</div>
<button
type="button"
className="absolute right-2.5 top-2.5 flex h-8 w-8 items-center justify-center"
onClick={onClose}
>
<span aria-hidden className="i-ri-close-line h-4 w-4 text-text-tertiary" />
</button>
<InputFieldForm
initialData={initialData}
supportFile
onCancel={onClose}
onSubmit={handleSubmit}
isEditMode={!!field}
/>
</div>
)
}
export default SnippetInputFieldEditor

View File

@@ -0,0 +1,119 @@
'use client'
import type { SortableItem } from '@/app/components/rag-pipeline/components/panel/input-field/field-list/types'
import type { SnippetInputField } from '@/models/snippet'
import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Divider from '@/app/components/base/divider'
import FieldListContainer from '@/app/components/rag-pipeline/components/panel/input-field/field-list/field-list-container'
type SnippetInputFieldPanelProps = {
fields: SnippetInputField[]
onClose: () => void
onAdd: () => void
onEdit: (field: SnippetInputField) => void
onRemove: (index: number) => void
onPrimarySortChange: (fields: SnippetInputField[]) => void
onSecondarySortChange: (fields: SnippetInputField[]) => void
}
const toInputFields = (list: SortableItem[]) => {
return list.map((item) => {
const { id: _id, chosen: _chosen, selected: _selected, ...field } = item
return field
})
}
const SnippetInputFieldPanel = ({
fields,
onClose,
onAdd,
onEdit,
onRemove,
onPrimarySortChange,
onSecondarySortChange,
}: SnippetInputFieldPanelProps) => {
const { t } = useTranslation('snippet')
const primaryFields = fields.slice(0, 2)
const secondaryFields = fields.slice(2)
const handlePrimaryRemove = useCallback((index: number) => {
onRemove(index)
}, [onRemove])
const handleSecondaryRemove = useCallback((index: number) => {
onRemove(index + primaryFields.length)
}, [onRemove, primaryFields.length])
const handlePrimaryEdit = useCallback((id: string) => {
const field = primaryFields.find(item => item.variable === id)
if (field)
onEdit(field)
}, [onEdit, primaryFields])
const handleSecondaryEdit = useCallback((id: string) => {
const field = secondaryFields.find(item => item.variable === id)
if (field)
onEdit(field)
}, [onEdit, secondaryFields])
return (
<div className="mr-1 flex h-full w-[min(400px,calc(100vw-24px))] flex-col rounded-2xl border border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-5">
<div className="flex items-start justify-between gap-3 px-4 pb-2 pt-4">
<div className="min-w-0">
<div className="text-text-primary system-xl-semibold">
{t('panelTitle')}
</div>
<div className="pt-1 text-text-tertiary system-sm-regular">
{t('panelDescription')}
</div>
</div>
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover"
onClick={onClose}
>
<span aria-hidden className="i-ri-close-line h-4 w-4" />
</button>
</div>
<div className="px-4 pb-2">
<Button variant="secondary" size="small" className="w-full justify-center gap-1" onClick={onAdd}>
<span aria-hidden className="i-ri-add-line h-4 w-4" />
{t('inputFieldPanel.addInputField', { ns: 'datasetPipeline' })}
</Button>
</div>
<div className="flex grow flex-col overflow-y-auto">
<div className="px-4 pb-1 pt-2 text-text-secondary system-xs-semibold-uppercase">
{t('panelPrimaryGroup')}
</div>
<FieldListContainer
className="flex flex-col gap-y-1 px-4 pb-2"
inputFields={primaryFields}
onListSortChange={list => onPrimarySortChange(toInputFields(list))}
onRemoveField={handlePrimaryRemove}
onEditField={handlePrimaryEdit}
/>
<div className="px-4 py-2">
<Divider type="horizontal" className="bg-divider-subtle" />
</div>
<div className="px-4 pb-1 text-text-secondary system-xs-semibold-uppercase">
{t('panelSecondaryGroup')}
</div>
<FieldListContainer
className="flex flex-col gap-y-1 px-4 pb-4"
inputFields={secondaryFields}
onListSortChange={list => onSecondarySortChange(toInputFields(list))}
onRemoveField={handleSecondaryRemove}
onEditField={handleSecondaryEdit}
/>
</div>
</div>
)
}
export default memo(SnippetInputFieldPanel)

View File

@@ -0,0 +1,29 @@
'use client'
import type { SnippetDetailUIModel } from '@/models/snippet'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
const PublishMenu = ({
uiMeta,
}: {
uiMeta: SnippetDetailUIModel
}) => {
const { t } = useTranslation('snippet')
return (
<div className="w-80 rounded-2xl border border-components-panel-border bg-components-panel-bg p-4 shadow-[0px_20px_24px_-4px_rgba(9,9,11,0.08),0px_8px_8px_-4px_rgba(9,9,11,0.03)]">
<div className="text-text-tertiary system-xs-semibold-uppercase">
{t('publishMenuCurrentDraft')}
</div>
<div className="pt-1 text-text-secondary system-sm-medium">
{uiMeta.autoSavedAt}
</div>
<Button variant="primary" size="small" className="mt-4 w-full justify-center">
{t('publishButton')}
</Button>
</div>
)
}
export default PublishMenu

View File

@@ -0,0 +1,106 @@
'use client'
import type { SnippetDetailUIModel, SnippetInputField } from '@/models/snippet'
import SnippetInputFieldEditor from './input-field-editor'
import SnippetInputFieldPanel from './panel'
import PublishMenu from './publish-menu'
import SnippetHeader from './snippet-header'
import SnippetWorkflowPanel from './workflow-panel'
type SnippetChildrenProps = {
fields: SnippetInputField[]
uiMeta: SnippetDetailUIModel
editingField: SnippetInputField | null
isEditorOpen: boolean
isInputPanelOpen: boolean
isPublishMenuOpen: boolean
onToggleInputPanel: () => void
onTogglePublishMenu: () => void
onCloseInputPanel: () => void
onOpenEditor: (field?: SnippetInputField | null) => void
onCloseEditor: () => void
onSubmitField: (field: SnippetInputField) => void
onRemoveField: (index: number) => void
onPrimarySortChange: (fields: SnippetInputField[]) => void
onSecondarySortChange: (fields: SnippetInputField[]) => void
}
const SnippetChildren = ({
fields,
uiMeta,
editingField,
isEditorOpen,
isInputPanelOpen,
isPublishMenuOpen,
onToggleInputPanel,
onTogglePublishMenu,
onCloseInputPanel,
onOpenEditor,
onCloseEditor,
onSubmitField,
onRemoveField,
onPrimarySortChange,
onSecondarySortChange,
}: SnippetChildrenProps) => {
return (
<>
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-24 bg-gradient-to-b from-background-body to-transparent" />
<SnippetHeader
inputFieldCount={fields.length}
onToggleInputPanel={onToggleInputPanel}
onTogglePublishMenu={onTogglePublishMenu}
/>
<SnippetWorkflowPanel
fields={fields}
editingField={editingField}
isEditorOpen={isEditorOpen}
isInputPanelOpen={isInputPanelOpen}
onCloseInputPanel={onCloseInputPanel}
onOpenEditor={onOpenEditor}
onCloseEditor={onCloseEditor}
onSubmitField={onSubmitField}
onRemoveField={onRemoveField}
onPrimarySortChange={onPrimarySortChange}
onSecondarySortChange={onSecondarySortChange}
/>
{isPublishMenuOpen && (
<div className="absolute right-3 top-14 z-20">
<PublishMenu uiMeta={uiMeta} />
</div>
)}
{isInputPanelOpen && (
<div className="pointer-events-none absolute inset-y-3 right-3 z-30 flex justify-end">
<div className="pointer-events-auto h-full xl:hidden">
<SnippetInputFieldPanel
fields={fields}
onClose={onCloseInputPanel}
onAdd={() => onOpenEditor()}
onEdit={onOpenEditor}
onRemove={onRemoveField}
onPrimarySortChange={onPrimarySortChange}
onSecondarySortChange={onSecondarySortChange}
/>
</div>
</div>
)}
{isEditorOpen && (
<div className="pointer-events-none absolute inset-0 z-40 flex items-center justify-center bg-black/10 px-3 xl:hidden">
<div className="pointer-events-auto w-full max-w-md">
<SnippetInputFieldEditor
field={editingField}
onClose={onCloseEditor}
onSubmit={onSubmitField}
/>
</div>
</div>
)}
</>
)
}
export default SnippetChildren

View File

@@ -0,0 +1,61 @@
'use client'
import { useTranslation } from 'react-i18next'
type SnippetHeaderProps = {
inputFieldCount: number
onToggleInputPanel: () => void
onTogglePublishMenu: () => void
}
const SnippetHeader = ({
inputFieldCount,
onToggleInputPanel,
onTogglePublishMenu,
}: SnippetHeaderProps) => {
const { t } = useTranslation('snippet')
return (
<div className="absolute right-3 top-3 z-20 flex flex-wrap items-center justify-end gap-2">
<button
type="button"
className="flex items-center gap-2 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 py-2 text-text-secondary shadow-xs backdrop-blur"
onClick={onToggleInputPanel}
>
<span className="text-[13px] font-medium leading-4">{t('inputFieldButton')}</span>
<span className="rounded-md border border-divider-deep px-1.5 py-0.5 text-[10px] font-medium leading-3 text-text-tertiary">
{inputFieldCount}
</span>
</button>
<button
type="button"
className="flex items-center gap-2 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 py-2 text-text-accent shadow-xs backdrop-blur"
>
<span aria-hidden className="i-ri-play-mini-fill h-4 w-4" />
<span className="text-[13px] font-medium leading-4">{t('testRunButton')}</span>
<span className="rounded-md bg-state-accent-active px-1.5 py-0.5 text-[10px] font-semibold leading-3 text-text-accent">R</span>
</button>
<div className="relative">
<button
type="button"
className="flex items-center gap-1 rounded-lg bg-components-button-primary-bg px-3 py-2 text-white shadow-[0px_2px_2px_-1px_rgba(0,0,0,0.12),0px_1px_1px_-1px_rgba(0,0,0,0.12),0px_0px_0px_0.5px_rgba(9,9,11,0.05)]"
onClick={onTogglePublishMenu}
>
<span className="text-[13px] font-medium leading-4">{t('publishButton')}</span>
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4" />
</button>
</div>
<button
type="button"
className="flex h-9 w-9 items-center justify-center rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg text-text-tertiary shadow-xs"
>
<span aria-hidden className="i-ri-more-2-line h-4 w-4" />
</button>
</div>
)
}
export default SnippetHeader

View File

@@ -0,0 +1,198 @@
'use client'
import type { NavIcon } from '@/app/components/app-sidebar/nav-link'
import type { WorkflowProps } from '@/app/components/workflow'
import type { SnippetDetailPayload, SnippetInputField, SnippetSection } from '@/models/snippet'
import {
RiFlaskFill,
RiFlaskLine,
RiGitBranchFill,
RiGitBranchLine,
} from '@remixicon/react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useShallow } from 'zustand/react/shallow'
import AppSideBar from '@/app/components/app-sidebar'
import NavLink from '@/app/components/app-sidebar/nav-link'
import SnippetInfo from '@/app/components/app-sidebar/snippet-info'
import { useStore as useAppStore } from '@/app/components/app/store'
import Toast from '@/app/components/base/toast'
import Evaluation from '@/app/components/evaluation'
import { WorkflowWithInnerContext } from '@/app/components/workflow'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { useSnippetDetailStore } from '../store'
import SnippetChildren from './snippet-children'
type SnippetMainProps = {
payload: SnippetDetailPayload
snippetId: string
section: SnippetSection
} & Pick<WorkflowProps, 'nodes' | 'edges' | 'viewport'>
const ORCHESTRATE_ICONS: { normal: NavIcon, selected: NavIcon } = {
normal: RiGitBranchLine,
selected: RiGitBranchFill,
}
const EVALUATION_ICONS: { normal: NavIcon, selected: NavIcon } = {
normal: RiFlaskLine,
selected: RiFlaskFill,
}
const SnippetMain = ({
payload,
snippetId,
section,
nodes,
edges,
viewport,
}: SnippetMainProps) => {
const { t } = useTranslation('snippet')
const { graph, snippet, uiMeta } = payload
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const [fields, setFields] = useState<SnippetInputField[]>(payload.inputFields)
const setAppSidebarExpand = useAppStore(state => state.setAppSidebarExpand)
const {
editingField,
isEditorOpen,
isInputPanelOpen,
isPublishMenuOpen,
closeEditor,
openEditor,
reset,
setInputPanelOpen,
toggleInputPanel,
togglePublishMenu,
} = useSnippetDetailStore(useShallow(state => ({
editingField: state.editingField,
isEditorOpen: state.isEditorOpen,
isInputPanelOpen: state.isInputPanelOpen,
isPublishMenuOpen: state.isPublishMenuOpen,
closeEditor: state.closeEditor,
openEditor: state.openEditor,
reset: state.reset,
setInputPanelOpen: state.setInputPanelOpen,
toggleInputPanel: state.toggleInputPanel,
togglePublishMenu: state.togglePublishMenu,
})))
useEffect(() => {
reset()
}, [reset, snippetId])
useEffect(() => {
const localeMode = localStorage.getItem('app-detail-collapse-or-expand') || 'expand'
const mode = isMobile ? 'collapse' : 'expand'
setAppSidebarExpand(isMobile ? mode : localeMode)
}, [isMobile, setAppSidebarExpand])
const primaryFields = useMemo(() => fields.slice(0, 2), [fields])
const secondaryFields = useMemo(() => fields.slice(2), [fields])
const handlePrimarySortChange = (newFields: SnippetInputField[]) => {
setFields([...newFields, ...secondaryFields])
}
const handleSecondarySortChange = (newFields: SnippetInputField[]) => {
setFields([...primaryFields, ...newFields])
}
const handleRemoveField = (index: number) => {
setFields(current => current.filter((_, currentIndex) => currentIndex !== index))
}
const handleSubmitField = (field: SnippetInputField) => {
const originalVariable = editingField?.variable
const duplicated = fields.some(item => item.variable === field.variable && item.variable !== originalVariable)
if (duplicated) {
Toast.notify({
type: 'error',
message: t('inputFieldPanel.error.variableDuplicate', { ns: 'datasetPipeline' }),
})
return
}
if (originalVariable)
setFields(current => current.map(item => item.variable === originalVariable ? field : item))
else
setFields(current => [...current, field])
closeEditor()
}
const handleToggleInputPanel = () => {
if (isInputPanelOpen)
closeEditor()
toggleInputPanel()
}
const handleCloseInputPanel = () => {
closeEditor()
setInputPanelOpen(false)
}
return (
<div className="relative flex h-full overflow-hidden bg-background-body">
<AppSideBar
navigation={[]}
renderHeader={mode => <SnippetInfo expand={mode === 'expand'} snippet={snippet} />}
renderNavigation={mode => (
<>
<NavLink
mode={mode}
name={t('sectionOrchestrate')}
iconMap={ORCHESTRATE_ICONS}
href={`/snippets/${snippetId}/orchestrate`}
active={section === 'orchestrate'}
/>
<NavLink
mode={mode}
name={t('sectionEvaluation')}
iconMap={EVALUATION_ICONS}
href={`/snippets/${snippetId}/evaluation`}
active={section === 'evaluation'}
/>
</>
)}
/>
<div className="relative min-h-0 min-w-0 grow overflow-hidden">
<div className="absolute inset-0 min-h-0 min-w-0 overflow-hidden">
{section === 'evaluation'
? (
<Evaluation resourceType="snippet" resourceId={snippetId} />
)
: (
<WorkflowWithInnerContext
nodes={nodes}
edges={edges}
viewport={viewport ?? graph.viewport}
>
<SnippetChildren
fields={fields}
uiMeta={uiMeta}
editingField={editingField}
isEditorOpen={isEditorOpen}
isInputPanelOpen={isInputPanelOpen}
isPublishMenuOpen={isPublishMenuOpen}
onToggleInputPanel={handleToggleInputPanel}
onTogglePublishMenu={togglePublishMenu}
onCloseInputPanel={handleCloseInputPanel}
onOpenEditor={openEditor}
onCloseEditor={closeEditor}
onSubmitField={handleSubmitField}
onRemoveField={handleRemoveField}
onPrimarySortChange={handlePrimarySortChange}
onSecondarySortChange={handleSecondarySortChange}
/>
</WorkflowWithInnerContext>
)}
</div>
</div>
</div>
)
}
export default SnippetMain

View File

@@ -0,0 +1,111 @@
'use client'
import type { PanelProps } from '@/app/components/workflow/panel'
import type { SnippetInputField } from '@/models/snippet'
import { memo, useMemo } from 'react'
import Panel from '@/app/components/workflow/panel'
import SnippetInputFieldEditor from './input-field-editor'
import SnippetInputFieldPanel from './panel'
type SnippetWorkflowPanelProps = {
fields: SnippetInputField[]
editingField: SnippetInputField | null
isEditorOpen: boolean
isInputPanelOpen: boolean
onCloseInputPanel: () => void
onOpenEditor: (field?: SnippetInputField | null) => void
onCloseEditor: () => void
onSubmitField: (field: SnippetInputField) => void
onRemoveField: (index: number) => void
onPrimarySortChange: (fields: SnippetInputField[]) => void
onSecondarySortChange: (fields: SnippetInputField[]) => void
}
const SnippetPanelOnLeft = ({
fields,
editingField,
isEditorOpen,
isInputPanelOpen,
onCloseInputPanel,
onOpenEditor,
onCloseEditor,
onSubmitField,
onRemoveField,
onPrimarySortChange,
onSecondarySortChange,
}: SnippetWorkflowPanelProps) => {
return (
<div className="hidden xl:flex">
{isEditorOpen && (
<SnippetInputFieldEditor
field={editingField}
onClose={onCloseEditor}
onSubmit={onSubmitField}
/>
)}
{isInputPanelOpen && (
<SnippetInputFieldPanel
fields={fields}
onClose={onCloseInputPanel}
onAdd={() => onOpenEditor()}
onEdit={onOpenEditor}
onRemove={onRemoveField}
onPrimarySortChange={onPrimarySortChange}
onSecondarySortChange={onSecondarySortChange}
/>
)}
</div>
)
}
const SnippetWorkflowPanel = ({
fields,
editingField,
isEditorOpen,
isInputPanelOpen,
onCloseInputPanel,
onOpenEditor,
onCloseEditor,
onSubmitField,
onRemoveField,
onPrimarySortChange,
onSecondarySortChange,
}: SnippetWorkflowPanelProps) => {
const panelProps: PanelProps = useMemo(() => {
return {
components: {
left: (
<SnippetPanelOnLeft
fields={fields}
editingField={editingField}
isEditorOpen={isEditorOpen}
isInputPanelOpen={isInputPanelOpen}
onCloseInputPanel={onCloseInputPanel}
onOpenEditor={onOpenEditor}
onCloseEditor={onCloseEditor}
onSubmitField={onSubmitField}
onRemoveField={onRemoveField}
onPrimarySortChange={onPrimarySortChange}
onSecondarySortChange={onSecondarySortChange}
/>
),
},
}
}, [
editingField,
fields,
isEditorOpen,
isInputPanelOpen,
onCloseEditor,
onCloseInputPanel,
onOpenEditor,
onPrimarySortChange,
onRemoveField,
onSecondarySortChange,
onSubmitField,
])
return <Panel {...panelProps} />
}
export default memo(SnippetWorkflowPanel)

View File

@@ -0,0 +1,5 @@
import { useSnippetDetail } from '@/service/use-snippets'
export const useSnippetInit = (snippetId: string) => {
return useSnippetDetail(snippetId)
}

View File

@@ -0,0 +1,86 @@
'use client'
import type { SnippetSection } from '@/models/snippet'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import WorkflowWithDefaultContext from '@/app/components/workflow'
import { WorkflowContextProvider } from '@/app/components/workflow/context'
import {
initialEdges,
initialNodes,
} from '@/app/components/workflow/utils'
import SnippetMain from './components/snippet-main'
import { useSnippetInit } from './hooks/use-snippet-init'
type SnippetPageProps = {
snippetId: string
section?: SnippetSection
}
const SnippetPage = ({
snippetId,
section = 'orchestrate',
}: SnippetPageProps) => {
const { t } = useTranslation('snippet')
const { data, isLoading } = useSnippetInit(snippetId)
const nodesData = useMemo(() => {
if (!data)
return []
return initialNodes(data.graph.nodes, data.graph.edges)
}, [data])
const edgesData = useMemo(() => {
if (!data)
return []
return initialEdges(data.graph.edges, data.graph.nodes)
}, [data])
if (isLoading) {
return (
<div className="flex h-full items-center justify-center bg-background-body">
<Loading />
</div>
)
}
if (!data) {
return (
<div className="flex h-full items-center justify-center bg-background-body px-6">
<div className="w-full max-w-md rounded-2xl border border-divider-subtle bg-components-card-bg p-8 text-center shadow-sm">
<div className="text-3xl font-semibold text-text-primary">404</div>
<div className="pt-3 text-text-primary system-md-semibold">{t('notFoundTitle')}</div>
<div className="pt-2 text-text-tertiary system-sm-regular">{t('notFoundDescription')}</div>
</div>
</div>
)
}
return (
<WorkflowWithDefaultContext
edges={edgesData}
nodes={nodesData}
>
<SnippetMain
key={snippetId}
snippetId={snippetId}
section={section}
payload={data}
nodes={nodesData}
edges={edgesData}
viewport={data.graph.viewport}
/>
</WorkflowWithDefaultContext>
)
}
const SnippetPageWrapper = (props: SnippetPageProps) => {
return (
<WorkflowContextProvider>
<SnippetPage {...props} />
</WorkflowContextProvider>
)
}
export default SnippetPageWrapper

View File

@@ -0,0 +1,44 @@
'use client'
import type { SnippetInputField, SnippetSection } from '@/models/snippet'
import { create } from 'zustand'
type SnippetDetailUIState = {
activeSection: SnippetSection
isInputPanelOpen: boolean
isPublishMenuOpen: boolean
isPreviewMode: boolean
isEditorOpen: boolean
editingField: SnippetInputField | null
setActiveSection: (section: SnippetSection) => void
setInputPanelOpen: (value: boolean) => void
toggleInputPanel: () => void
setPublishMenuOpen: (value: boolean) => void
togglePublishMenu: () => void
setPreviewMode: (value: boolean) => void
openEditor: (field?: SnippetInputField | null) => void
closeEditor: () => void
reset: () => void
}
const initialState = {
activeSection: 'orchestrate' as SnippetSection,
isInputPanelOpen: false,
isPublishMenuOpen: false,
isPreviewMode: false,
editingField: null,
isEditorOpen: false,
}
export const useSnippetDetailStore = create<SnippetDetailUIState>(set => ({
...initialState,
setActiveSection: activeSection => set({ activeSection }),
setInputPanelOpen: isInputPanelOpen => set({ isInputPanelOpen }),
toggleInputPanel: () => set(state => ({ isInputPanelOpen: !state.isInputPanelOpen, isPublishMenuOpen: false })),
setPublishMenuOpen: isPublishMenuOpen => set({ isPublishMenuOpen }),
togglePublishMenu: () => set(state => ({ isPublishMenuOpen: !state.isPublishMenuOpen })),
setPreviewMode: isPreviewMode => set({ isPreviewMode }),
openEditor: (editingField = null) => set({ editingField, isEditorOpen: true, isInputPanelOpen: true }),
closeEditor: () => set({ editingField: null, isEditorOpen: false }),
reset: () => set(initialState),
}))

View File

@@ -0,0 +1,40 @@
import type { Node } from '../types'
import { screen } from '@testing-library/react'
import CandidateNode from '../candidate-node'
import { BlockEnum } from '../types'
import { renderWorkflowComponent } from './workflow-test-env'
vi.mock('../candidate-node-main', () => ({
default: ({ candidateNode }: { candidateNode: Node }) => (
<div data-testid="candidate-node-main">{candidateNode.id}</div>
),
}))
const createCandidateNode = (): Node => ({
id: 'candidate-node-1',
type: 'custom',
position: { x: 0, y: 0 },
data: {
type: BlockEnum.Start,
title: 'Candidate node',
desc: 'candidate',
},
})
describe('CandidateNode', () => {
it('should not render when candidateNode is missing from the workflow store', () => {
renderWorkflowComponent(<CandidateNode />)
expect(screen.queryByTestId('candidate-node-main')).not.toBeInTheDocument()
})
it('should render CandidateNodeMain with the stored candidate node', () => {
renderWorkflowComponent(<CandidateNode />, {
initialStoreState: {
candidateNode: createCandidateNode(),
},
})
expect(screen.getByTestId('candidate-node-main')).toHaveTextContent('candidate-node-1')
})
})

View File

@@ -0,0 +1,81 @@
import type { ComponentProps } from 'react'
import { render } from '@testing-library/react'
import { getBezierPath, Position } from 'reactflow'
import CustomConnectionLine from '../custom-connection-line'
const createConnectionLineProps = (
overrides: Partial<ComponentProps<typeof CustomConnectionLine>> = {},
): ComponentProps<typeof CustomConnectionLine> => ({
fromX: 10,
fromY: 20,
toX: 70,
toY: 80,
fromPosition: Position.Right,
toPosition: Position.Left,
connectionLineType: undefined,
connectionStatus: null,
...overrides,
} as ComponentProps<typeof CustomConnectionLine>)
describe('CustomConnectionLine', () => {
it('should render the bezier path and target marker', () => {
const [expectedPath] = getBezierPath({
sourceX: 10,
sourceY: 20,
sourcePosition: Position.Right,
targetX: 70,
targetY: 80,
targetPosition: Position.Left,
curvature: 0.16,
})
const { container } = render(
<svg>
<CustomConnectionLine {...createConnectionLineProps()} />
</svg>,
)
const path = container.querySelector('path')
const marker = container.querySelector('rect')
expect(path).toHaveAttribute('fill', 'none')
expect(path).toHaveAttribute('stroke', '#D0D5DD')
expect(path).toHaveAttribute('stroke-width', '2')
expect(path).toHaveAttribute('d', expectedPath)
expect(marker).toHaveAttribute('x', '70')
expect(marker).toHaveAttribute('y', '76')
expect(marker).toHaveAttribute('width', '2')
expect(marker).toHaveAttribute('height', '8')
expect(marker).toHaveAttribute('fill', '#2970FF')
})
it('should update the path when the endpoints change', () => {
const [expectedPath] = getBezierPath({
sourceX: 30,
sourceY: 40,
sourcePosition: Position.Right,
targetX: 160,
targetY: 200,
targetPosition: Position.Left,
curvature: 0.16,
})
const { container } = render(
<svg>
<CustomConnectionLine
{...createConnectionLineProps({
fromX: 30,
fromY: 40,
toX: 160,
toY: 200,
})}
/>
</svg>,
)
expect(container.querySelector('path')).toHaveAttribute('d', expectedPath)
expect(container.querySelector('rect')).toHaveAttribute('x', '160')
expect(container.querySelector('rect')).toHaveAttribute('y', '196')
})
})

View File

@@ -0,0 +1,57 @@
import { render } from '@testing-library/react'
import CustomEdgeLinearGradientRender from '../custom-edge-linear-gradient-render'
describe('CustomEdgeLinearGradientRender', () => {
it('should render gradient definition with the provided id and positions', () => {
const { container } = render(
<svg>
<CustomEdgeLinearGradientRender
id="edge-gradient"
startColor="#123456"
stopColor="#abcdef"
position={{
x1: 10,
y1: 20,
x2: 30,
y2: 40,
}}
/>
</svg>,
)
const gradient = container.querySelector('linearGradient')
expect(gradient).toHaveAttribute('id', 'edge-gradient')
expect(gradient).toHaveAttribute('gradientUnits', 'userSpaceOnUse')
expect(gradient).toHaveAttribute('x1', '10')
expect(gradient).toHaveAttribute('y1', '20')
expect(gradient).toHaveAttribute('x2', '30')
expect(gradient).toHaveAttribute('y2', '40')
})
it('should render start and stop colors at both ends of the gradient', () => {
const { container } = render(
<svg>
<CustomEdgeLinearGradientRender
id="gradient-colors"
startColor="#111111"
stopColor="#222222"
position={{
x1: 0,
y1: 0,
x2: 100,
y2: 100,
}}
/>
</svg>,
)
const stops = container.querySelectorAll('stop')
expect(stops).toHaveLength(2)
expect(stops[0]).toHaveAttribute('offset', '0%')
expect(stops[0].getAttribute('style')).toContain('stop-color: rgb(17, 17, 17)')
expect(stops[0].getAttribute('style')).toContain('stop-opacity: 1')
expect(stops[1]).toHaveAttribute('offset', '100%')
expect(stops[1].getAttribute('style')).toContain('stop-color: rgb(34, 34, 34)')
expect(stops[1].getAttribute('style')).toContain('stop-opacity: 1')
})
})

View File

@@ -0,0 +1,127 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import DSLExportConfirmModal from '../dsl-export-confirm-modal'
const envList = [
{
id: 'env-1',
name: 'SECRET_TOKEN',
value: 'masked-value',
value_type: 'secret' as const,
description: 'secret token',
},
]
const multiEnvList = [
...envList,
{
id: 'env-2',
name: 'SERVICE_KEY',
value: 'another-secret',
value_type: 'secret' as const,
description: 'service key',
},
]
describe('DSLExportConfirmModal', () => {
it('should render environment rows and close when cancel is clicked', async () => {
const user = userEvent.setup()
const onConfirm = vi.fn()
const onClose = vi.fn()
render(
<DSLExportConfirmModal
envList={envList}
onConfirm={onConfirm}
onClose={onClose}
/>,
)
expect(screen.getByText('SECRET_TOKEN')).toBeInTheDocument()
expect(screen.getByText('masked-value')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(onClose).toHaveBeenCalledTimes(1)
expect(onConfirm).not.toHaveBeenCalled()
})
it('should confirm with exportSecrets=false by default', async () => {
const user = userEvent.setup()
const onConfirm = vi.fn()
const onClose = vi.fn()
render(
<DSLExportConfirmModal
envList={envList}
onConfirm={onConfirm}
onClose={onClose}
/>,
)
await user.click(screen.getByRole('button', { name: 'workflow.env.export.ignore' }))
expect(onConfirm).toHaveBeenCalledWith(false)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should confirm with exportSecrets=true after toggling the checkbox', async () => {
const user = userEvent.setup()
const onConfirm = vi.fn()
const onClose = vi.fn()
render(
<DSLExportConfirmModal
envList={envList}
onConfirm={onConfirm}
onClose={onClose}
/>,
)
await user.click(screen.getByRole('checkbox'))
await user.click(screen.getByRole('button', { name: 'workflow.env.export.export' }))
expect(onConfirm).toHaveBeenCalledWith(true)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should also toggle exportSecrets when the label text is clicked', async () => {
const user = userEvent.setup()
const onConfirm = vi.fn()
const onClose = vi.fn()
render(
<DSLExportConfirmModal
envList={envList}
onConfirm={onConfirm}
onClose={onClose}
/>,
)
await user.click(screen.getByText('workflow.env.export.checkbox'))
await user.click(screen.getByRole('button', { name: 'workflow.env.export.export' }))
expect(onConfirm).toHaveBeenCalledWith(true)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should render border separators for all rows except the last one', () => {
render(
<DSLExportConfirmModal
envList={multiEnvList}
onConfirm={vi.fn()}
onClose={vi.fn()}
/>,
)
const firstNameCell = screen.getByText('SECRET_TOKEN').closest('td')
const lastNameCell = screen.getByText('SERVICE_KEY').closest('td')
const firstValueCell = screen.getByText('masked-value').closest('td')
const lastValueCell = screen.getByText('another-secret').closest('td')
expect(firstNameCell).toHaveClass('border-b')
expect(firstValueCell).toHaveClass('border-b')
expect(lastNameCell).not.toHaveClass('border-b')
expect(lastValueCell).not.toHaveClass('border-b')
})
})

View File

@@ -0,0 +1,193 @@
import type { InputVar } from '../types'
import type { PromptVariable } from '@/models/debug'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ReactFlow, { ReactFlowProvider, useNodes } from 'reactflow'
import Features from '../features'
import { InputVarType } from '../types'
import { createStartNode } from './fixtures'
import { renderWorkflowComponent } from './workflow-test-env'
const mockHandleSyncWorkflowDraft = vi.fn()
const mockHandleAddVariable = vi.fn()
let mockIsChatMode = true
let mockNodesReadOnly = false
vi.mock('../hooks', async () => {
const actual = await vi.importActual<typeof import('../hooks')>('../hooks')
return {
...actual,
useIsChatMode: () => mockIsChatMode,
useNodesReadOnly: () => ({
nodesReadOnly: mockNodesReadOnly,
}),
useNodesSyncDraft: () => ({
handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft,
}),
}
})
vi.mock('../nodes/start/use-config', () => ({
default: () => ({
handleAddVariable: mockHandleAddVariable,
}),
}))
vi.mock('@/app/components/base/features/new-feature-panel', () => ({
default: ({
show,
isChatMode,
disabled,
onChange,
onClose,
onAutoAddPromptVariable,
workflowVariables,
}: {
show: boolean
isChatMode: boolean
disabled: boolean
onChange: () => void
onClose: () => void
onAutoAddPromptVariable: (variables: PromptVariable[]) => void
workflowVariables: InputVar[]
}) => {
if (!show)
return null
return (
<section aria-label="new feature panel">
<div>{isChatMode ? 'chat mode' : 'completion mode'}</div>
<div>{disabled ? 'panel disabled' : 'panel enabled'}</div>
<ul aria-label="workflow variables">
{workflowVariables.map(variable => (
<li key={variable.variable}>
{`${variable.label}:${variable.variable}`}
</li>
))}
</ul>
<button type="button" onClick={onChange}>open features</button>
<button type="button" onClick={onClose}>close features</button>
<button
type="button"
onClick={() => onAutoAddPromptVariable([{
key: 'opening_statement',
name: 'Opening Statement',
type: 'string',
max_length: 200,
required: true,
}])}
>
add required variable
</button>
<button
type="button"
onClick={() => onAutoAddPromptVariable([{
key: 'optional_statement',
name: 'Optional Statement',
type: 'string',
max_length: 120,
}])}
>
add optional variable
</button>
</section>
)
},
}))
const startNode = createStartNode({
id: 'start-node',
data: {
variables: [{ variable: 'existing_variable', label: 'Existing Variable' }],
},
})
const DelayedFeatures = () => {
const nodes = useNodes()
if (!nodes.length)
return null
return <Features />
}
const renderFeatures = (options?: Parameters<typeof renderWorkflowComponent>[1]) => {
return renderWorkflowComponent(
<div style={{ width: 800, height: 600 }}>
<ReactFlowProvider>
<ReactFlow nodes={[startNode]} edges={[]} fitView />
<DelayedFeatures />
</ReactFlowProvider>
</div>,
options,
)
}
describe('Features', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsChatMode = true
mockNodesReadOnly = false
})
describe('Rendering', () => {
it('should pass workflow context to the feature panel', () => {
renderFeatures()
expect(screen.getByText('chat mode')).toBeInTheDocument()
expect(screen.getByText('panel enabled')).toBeInTheDocument()
expect(screen.getByRole('list', { name: 'workflow variables' })).toHaveTextContent('Existing Variable:existing_variable')
})
})
describe('User Interactions', () => {
it('should sync the draft and open the workflow feature panel when users change features', async () => {
const user = userEvent.setup()
const { store } = renderFeatures()
await user.click(screen.getByRole('button', { name: 'open features' }))
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledTimes(1)
expect(store.getState().showFeaturesPanel).toBe(true)
})
it('should close the workflow feature panel and transform required prompt variables', async () => {
const user = userEvent.setup()
const { store } = renderFeatures({
initialStoreState: {
showFeaturesPanel: true,
},
})
await user.click(screen.getByRole('button', { name: 'close features' }))
expect(store.getState().showFeaturesPanel).toBe(false)
await user.click(screen.getByRole('button', { name: 'add required variable' }))
expect(mockHandleAddVariable).toHaveBeenCalledWith({
variable: 'opening_statement',
label: 'Opening Statement',
type: InputVarType.textInput,
max_length: 200,
required: true,
options: [],
})
})
it('should default prompt variables to optional when required is omitted', async () => {
const user = userEvent.setup()
renderFeatures()
await user.click(screen.getByRole('button', { name: 'add optional variable' }))
expect(mockHandleAddVariable).toHaveBeenCalledWith({
variable: 'optional_statement',
label: 'Optional Statement',
type: InputVarType.textInput,
max_length: 120,
required: false,
options: [],
})
})
})
})

View File

@@ -16,8 +16,8 @@ import * as React from 'react'
type MockNode = {
id: string
position: { x: number, y: number }
width?: number
height?: number
width?: number | null
height?: number | null
parentId?: string
data: Record<string, unknown>
}

View File

@@ -0,0 +1,22 @@
import SyncingDataModal from '../syncing-data-modal'
import { renderWorkflowComponent } from './workflow-test-env'
describe('SyncingDataModal', () => {
it('should not render when workflow draft syncing is disabled', () => {
const { container } = renderWorkflowComponent(<SyncingDataModal />)
expect(container).toBeEmptyDOMElement()
})
it('should render the fullscreen overlay when workflow draft syncing is enabled', () => {
const { container } = renderWorkflowComponent(<SyncingDataModal />, {
initialStoreState: {
isSyncingWorkflowDraft: true,
},
})
const overlay = container.firstElementChild
expect(overlay).toHaveClass('absolute', 'inset-0')
expect(overlay).toHaveClass('z-[9999]')
})
})

View File

@@ -0,0 +1,108 @@
import type * as React from 'react'
import { act, renderHook } from '@testing-library/react'
import useCheckVerticalScrollbar from '../use-check-vertical-scrollbar'
const resizeObserve = vi.fn()
const resizeDisconnect = vi.fn()
const mutationObserve = vi.fn()
const mutationDisconnect = vi.fn()
let resizeCallback: ResizeObserverCallback | null = null
let mutationCallback: MutationCallback | null = null
class MockResizeObserver implements ResizeObserver {
observe = resizeObserve
unobserve = vi.fn()
disconnect = resizeDisconnect
constructor(callback: ResizeObserverCallback) {
resizeCallback = callback
}
}
class MockMutationObserver implements MutationObserver {
observe = mutationObserve
disconnect = mutationDisconnect
takeRecords = vi.fn(() => [])
constructor(callback: MutationCallback) {
mutationCallback = callback
}
}
const setElementHeights = (element: HTMLElement, scrollHeight: number, clientHeight: number) => {
Object.defineProperty(element, 'scrollHeight', {
configurable: true,
value: scrollHeight,
})
Object.defineProperty(element, 'clientHeight', {
configurable: true,
value: clientHeight,
})
}
describe('useCheckVerticalScrollbar', () => {
beforeEach(() => {
vi.clearAllMocks()
resizeCallback = null
mutationCallback = null
vi.stubGlobal('ResizeObserver', MockResizeObserver)
vi.stubGlobal('MutationObserver', MockMutationObserver)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('should return false when the element ref is empty', () => {
const ref = { current: null } as React.RefObject<HTMLElement | null>
const { result } = renderHook(() => useCheckVerticalScrollbar(ref))
expect(result.current).toBe(false)
expect(resizeObserve).not.toHaveBeenCalled()
expect(mutationObserve).not.toHaveBeenCalled()
})
it('should detect the initial scrollbar state and react to observer updates', () => {
const element = document.createElement('div')
setElementHeights(element, 200, 100)
const ref = { current: element } as React.RefObject<HTMLElement | null>
const { result } = renderHook(() => useCheckVerticalScrollbar(ref))
expect(result.current).toBe(true)
expect(resizeObserve).toHaveBeenCalledWith(element)
expect(mutationObserve).toHaveBeenCalledWith(element, {
childList: true,
subtree: true,
characterData: true,
})
setElementHeights(element, 100, 100)
act(() => {
resizeCallback?.([] as ResizeObserverEntry[], new MockResizeObserver(() => {}))
})
expect(result.current).toBe(false)
setElementHeights(element, 180, 100)
act(() => {
mutationCallback?.([] as MutationRecord[], new MockMutationObserver(() => {}))
})
expect(result.current).toBe(true)
})
it('should disconnect observers on unmount', () => {
const element = document.createElement('div')
setElementHeights(element, 120, 100)
const ref = { current: element } as React.RefObject<HTMLElement | null>
const { unmount } = renderHook(() => useCheckVerticalScrollbar(ref))
unmount()
expect(resizeDisconnect).toHaveBeenCalledTimes(1)
expect(mutationDisconnect).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,103 @@
import type * as React from 'react'
import { act, renderHook } from '@testing-library/react'
import useStickyScroll, { ScrollPosition } from '../use-sticky-scroll'
const setRect = (element: HTMLElement, top: number, height: number) => {
element.getBoundingClientRect = vi.fn(() => new DOMRect(0, top, 100, height))
}
describe('useStickyScroll', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
const runScroll = (handleScroll: () => void) => {
act(() => {
handleScroll()
vi.advanceTimersByTime(120)
})
}
it('should keep the default state when refs are missing', () => {
const wrapElemRef = { current: null } as React.RefObject<HTMLElement | null>
const nextToStickyELemRef = { current: null } as React.RefObject<HTMLElement | null>
const { result } = renderHook(() =>
useStickyScroll({
wrapElemRef,
nextToStickyELemRef,
}),
)
runScroll(result.current.handleScroll)
expect(result.current.scrollPosition).toBe(ScrollPosition.belowTheWrap)
})
it('should mark the sticky element as below the wrapper when it is outside the visible area', () => {
const wrapElement = document.createElement('div')
const nextElement = document.createElement('div')
setRect(wrapElement, 100, 200)
setRect(nextElement, 320, 20)
const wrapElemRef = { current: wrapElement } as React.RefObject<HTMLElement | null>
const nextToStickyELemRef = { current: nextElement } as React.RefObject<HTMLElement | null>
const { result } = renderHook(() =>
useStickyScroll({
wrapElemRef,
nextToStickyELemRef,
}),
)
runScroll(result.current.handleScroll)
expect(result.current.scrollPosition).toBe(ScrollPosition.belowTheWrap)
})
it('should mark the sticky element as showing when it is within the wrapper', () => {
const wrapElement = document.createElement('div')
const nextElement = document.createElement('div')
setRect(wrapElement, 100, 200)
setRect(nextElement, 220, 20)
const wrapElemRef = { current: wrapElement } as React.RefObject<HTMLElement | null>
const nextToStickyELemRef = { current: nextElement } as React.RefObject<HTMLElement | null>
const { result } = renderHook(() =>
useStickyScroll({
wrapElemRef,
nextToStickyELemRef,
}),
)
runScroll(result.current.handleScroll)
expect(result.current.scrollPosition).toBe(ScrollPosition.showing)
})
it('should mark the sticky element as above the wrapper when it has scrolled past the top', () => {
const wrapElement = document.createElement('div')
const nextElement = document.createElement('div')
setRect(wrapElement, 100, 200)
setRect(nextElement, 90, 20)
const wrapElemRef = { current: wrapElement } as React.RefObject<HTMLElement | null>
const nextToStickyELemRef = { current: nextElement } as React.RefObject<HTMLElement | null>
const { result } = renderHook(() =>
useStickyScroll({
wrapElemRef,
nextToStickyELemRef,
}),
)
runScroll(result.current.handleScroll)
expect(result.current.scrollPosition).toBe(ScrollPosition.aboveTheWrap)
})
})

View File

@@ -0,0 +1,108 @@
import type { DataSourceItem } from '../types'
import { transformDataSourceToTool } from '../utils'
const createLocalizedText = (text: string) => ({
en_US: text,
zh_Hans: text,
})
const createDataSourceItem = (overrides: Partial<DataSourceItem> = {}): DataSourceItem => ({
plugin_id: 'plugin-1',
plugin_unique_identifier: 'plugin-1@provider',
provider: 'provider-a',
declaration: {
credentials_schema: [{ name: 'api_key' }],
provider_type: 'hosted',
identity: {
author: 'Dify',
description: createLocalizedText('Datasource provider'),
icon: 'provider-icon',
label: createLocalizedText('Provider A'),
name: 'provider-a',
tags: ['retrieval', 'storage'],
},
datasources: [
{
description: createLocalizedText('Search in documents'),
identity: {
author: 'Dify',
label: createLocalizedText('Document Search'),
name: 'document_search',
provider: 'provider-a',
},
parameters: [{ name: 'query', type: 'string' }],
output_schema: {
type: 'object',
properties: {
result: { type: 'string' },
},
},
},
],
},
is_authorized: true,
...overrides,
})
describe('transformDataSourceToTool', () => {
it('should map datasource provider fields to tool shape', () => {
const dataSourceItem = createDataSourceItem()
const result = transformDataSourceToTool(dataSourceItem)
expect(result).toMatchObject({
id: 'plugin-1',
provider: 'provider-a',
name: 'provider-a',
author: 'Dify',
description: createLocalizedText('Datasource provider'),
icon: 'provider-icon',
label: createLocalizedText('Provider A'),
type: 'hosted',
allow_delete: true,
is_authorized: true,
is_team_authorization: true,
labels: ['retrieval', 'storage'],
plugin_id: 'plugin-1',
plugin_unique_identifier: 'plugin-1@provider',
credentialsSchema: [{ name: 'api_key' }],
meta: { version: '' },
})
expect(result.team_credentials).toEqual({})
expect(result.tools).toEqual([
{
name: 'document_search',
author: 'Dify',
label: createLocalizedText('Document Search'),
description: createLocalizedText('Search in documents'),
parameters: [{ name: 'query', type: 'string' }],
labels: [],
output_schema: {
type: 'object',
properties: {
result: { type: 'string' },
},
},
},
])
})
it('should fallback to empty arrays when tags and credentials schema are missing', () => {
const baseDataSourceItem = createDataSourceItem()
const dataSourceItem = createDataSourceItem({
declaration: {
...baseDataSourceItem.declaration,
credentials_schema: undefined as unknown as DataSourceItem['declaration']['credentials_schema'],
identity: {
...baseDataSourceItem.declaration.identity,
tags: undefined as unknown as DataSourceItem['declaration']['identity']['tags'],
},
},
})
const result = transformDataSourceToTool(dataSourceItem)
expect(result.labels).toEqual([])
expect(result.credentialsSchema).toEqual([])
})
})

View File

@@ -0,0 +1,57 @@
import { fireEvent, render } from '@testing-library/react'
import ViewTypeSelect, { ViewType } from '../view-type-select'
const getViewOptions = (container: HTMLElement) => {
const options = container.firstElementChild?.children
if (!options || options.length !== 2)
throw new Error('Expected two view options')
return [options[0] as HTMLDivElement, options[1] as HTMLDivElement]
}
describe('ViewTypeSelect', () => {
it('should highlight the active view type', () => {
const onChange = vi.fn()
const { container } = render(
<ViewTypeSelect
viewType={ViewType.flat}
onChange={onChange}
/>,
)
const [flatOption, treeOption] = getViewOptions(container)
expect(flatOption).toHaveClass('bg-components-segmented-control-item-active-bg')
expect(treeOption).toHaveClass('cursor-pointer')
})
it('should call onChange when switching to a different view type', () => {
const onChange = vi.fn()
const { container } = render(
<ViewTypeSelect
viewType={ViewType.flat}
onChange={onChange}
/>,
)
const [, treeOption] = getViewOptions(container)
fireEvent.click(treeOption)
expect(onChange).toHaveBeenCalledWith(ViewType.tree)
expect(onChange).toHaveBeenCalledTimes(1)
})
it('should ignore clicks on the current view type', () => {
const onChange = vi.fn()
const { container } = render(
<ViewTypeSelect
viewType={ViewType.tree}
onChange={onChange}
/>,
)
const [, treeOption] = getViewOptions(container)
fireEvent.click(treeOption)
expect(onChange).not.toHaveBeenCalled()
})
})

View File

@@ -71,6 +71,10 @@ export const useTabs = ({
name: t('tabs.start', { ns: 'workflow' }),
show: shouldShowStartTab,
disabled: shouldDisableStartTab,
}, {
key: TabsEnum.Snippets,
name: t('tabs.snippets', { ns: 'workflow' }),
show: true,
}]
return tabConfigs.filter(tab => tab.show)
@@ -100,6 +104,7 @@ export const useTabs = ({
preferredOrder.push(TabsEnum.Sources)
if (!noStart)
preferredOrder.push(TabsEnum.Start)
preferredOrder.push(TabsEnum.Snippets)
for (const tabKey of preferredOrder) {
const validKey = getValidTabKey(tabKey)

View File

@@ -15,6 +15,7 @@ import type {
import {
memo,
useCallback,
useEffect,
useMemo,
useState,
} from 'react'
@@ -32,6 +33,7 @@ import SearchBox from '@/app/components/plugins/marketplace/search-box'
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
import { BlockEnum, isTriggerNode } from '../types'
import { useTabs } from './hooks'
import Snippets from './snippets'
import Tabs from './tabs'
import { TabsEnum } from './types'
@@ -88,6 +90,7 @@ const NodeSelector: FC<NodeSelectorProps> = ({
const { t } = useTranslation()
const nodes = useNodes()
const [searchText, setSearchText] = useState('')
const [snippetsLoading, setSnippetsLoading] = useState(() => Boolean(openFromProps) && defaultActiveTab === TabsEnum.Snippets)
const [tags, setTags] = useState<string[]>([])
const [localOpen, setLocalOpen] = useState(false)
// Exclude nodes explicitly ignored (such as the node currently being edited) when checking canvas state.
@@ -119,28 +122,6 @@ const NodeSelector: FC<NodeSelectorProps> = ({
// Default rule: user input option is only available when no Start node nor Trigger node exists on canvas.
const defaultAllowUserInputSelection = !hasUserInputNode && !hasTriggerNode
const canSelectUserInput = allowUserInputSelection ?? defaultAllowUserInputSelection
const open = openFromProps === undefined ? localOpen : openFromProps
const handleOpenChange = useCallback((newOpen: boolean) => {
setLocalOpen(newOpen)
if (!newOpen)
setSearchText('')
if (onOpenChange)
onOpenChange(newOpen)
}, [onOpenChange])
const handleTrigger = useCallback<MouseEventHandler<HTMLDivElement>>((e) => {
if (disabled)
return
e.stopPropagation()
handleOpenChange(!open)
}, [handleOpenChange, open, disabled])
const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => {
handleOpenChange(false)
onSelect(type, pluginDefaultValue)
}, [handleOpenChange, onSelect])
const {
activeTab,
setActiveTab,
@@ -154,10 +135,51 @@ const NodeSelector: FC<NodeSelectorProps> = ({
hasUserInputNode,
forceEnableStartTab,
})
const open = openFromProps === undefined ? localOpen : openFromProps
const handleOpenChange = useCallback((newOpen: boolean) => {
setLocalOpen(newOpen)
if (!newOpen) {
setSearchText('')
setSnippetsLoading(false)
}
else if (activeTab === TabsEnum.Snippets) {
setSnippetsLoading(true)
}
if (onOpenChange)
onOpenChange(newOpen)
}, [activeTab, onOpenChange])
const handleTrigger = useCallback<MouseEventHandler<HTMLDivElement>>((e) => {
if (disabled)
return
e.stopPropagation()
handleOpenChange(!open)
}, [handleOpenChange, open, disabled])
const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => {
handleOpenChange(false)
onSelect(type, pluginDefaultValue)
}, [handleOpenChange, onSelect])
const handleActiveTabChange = useCallback((newActiveTab: TabsEnum) => {
setActiveTab(newActiveTab)
}, [setActiveTab])
if (open && newActiveTab === TabsEnum.Snippets)
setSnippetsLoading(true)
}, [open, setActiveTab])
useEffect(() => {
if (!snippetsLoading)
return
const timer = window.setTimeout(() => {
setSnippetsLoading(false)
}, 200)
return () => {
window.clearTimeout(timer)
}
}, [snippetsLoading])
const searchPlaceholder = useMemo(() => {
if (activeTab === TabsEnum.Start)
@@ -171,6 +193,8 @@ const NodeSelector: FC<NodeSelectorProps> = ({
if (activeTab === TabsEnum.Sources)
return t('tabs.searchDataSource', { ns: 'workflow' })
if (activeTab === TabsEnum.Snippets)
return t('tabs.searchSnippets', { ns: 'workflow' })
return ''
}, [activeTab, t])
@@ -257,6 +281,17 @@ const NodeSelector: FC<NodeSelectorProps> = ({
inputClassName="grow"
/>
)}
{activeTab === TabsEnum.Snippets && (
<Input
showLeftIcon
showClearIcon
autoFocus
value={searchText}
placeholder={searchPlaceholder}
onChange={e => setSearchText(e.target.value)}
onClear={() => setSearchText('')}
/>
)}
</div>
)}
onSelect={handleSelect}
@@ -268,6 +303,7 @@ const NodeSelector: FC<NodeSelectorProps> = ({
noTools={noTools}
onTagsChange={setTags}
forceShowStartContent={forceShowStartContent}
snippetsElem={<Snippets loading={snippetsLoading} searchText={searchText} />}
/>
</div>
</PortalToFollowElemContent>

View File

@@ -0,0 +1,247 @@
import type { ReactNode } from 'react'
import {
memo,
useDeferredValue,
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import {
SearchMenu,
} from '@/app/components/base/icons/src/vender/line/others'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/app/components/base/ui/tooltip'
import { cn } from '@/utils/classnames'
import BlockIcon from '../block-icon'
import { BlockEnum } from '../types'
type SnippetsProps = {
loading?: boolean
searchText: string
}
type StaticSnippet = {
id: string
badge: string
badgeClassName: string
title: string
description: string
author?: string
relatedBlocks?: BlockEnum[]
}
const STATIC_SNIPPETS: StaticSnippet[] = [
{
id: 'customer-review',
badge: 'CR',
title: 'Customer Review',
description: 'Customer Review Description',
author: 'Evan',
relatedBlocks: [
BlockEnum.LLM,
BlockEnum.Code,
BlockEnum.KnowledgeRetrieval,
BlockEnum.QuestionClassifier,
BlockEnum.IfElse,
],
badgeClassName: 'bg-gradient-to-br from-orange-500 to-rose-500',
},
] as const
const LoadingSkeleton = () => {
return (
<div className="relative overflow-hidden">
<div className="p-1">
{['skeleton-1', 'skeleton-2', 'skeleton-3', 'skeleton-4'].map((key, index) => (
<div
key={key}
className={cn(
'flex items-center gap-1 px-3 py-1 opacity-20',
index === 3 && 'opacity-10',
)}
>
<div className="my-1 h-6 w-6 shrink-0 rounded-lg border-[0.5px] border-effects-icon-border bg-text-quaternary" />
<div className="min-w-0 flex-1 px-1 py-1">
<div className="h-2 w-[200px] rounded-[2px] bg-text-quaternary" />
</div>
</div>
))}
</div>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-components-panel-bg-transparent to-background-default-subtle" />
</div>
)
}
const SnippetBadge = ({
badge,
badgeClassName,
}: Pick<StaticSnippet, 'badge' | 'badgeClassName'>) => {
return (
<div
aria-hidden="true"
className={cn(
'flex h-6 w-6 shrink-0 items-center justify-center rounded-lg text-[9px] font-semibold uppercase text-white shadow-[0px_3px_10px_-2px_rgba(9,9,11,0.08),0px_2px_4px_-2px_rgba(9,9,11,0.06)]',
badgeClassName,
)}
>
{badge}
</div>
)
}
const SnippetDetailCard = ({
author,
description,
relatedBlocks = [],
title,
triggerBadge,
}: {
author?: string
description?: string
relatedBlocks?: BlockEnum[]
title: string
triggerBadge: ReactNode
}) => {
return (
<div className="w-[224px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur px-3 pb-4 pt-3 shadow-lg backdrop-blur-[5px]">
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-2">
{triggerBadge}
<div className="text-text-primary system-md-medium">{title}</div>
</div>
{!!description && (
<div className="w-[200px] text-text-secondary system-xs-regular">
{description}
</div>
)}
{!!relatedBlocks.length && (
<div className="flex items-center gap-0.5 pt-1">
{relatedBlocks.map(block => (
<BlockIcon
key={block}
type={block}
size="sm"
/>
))}
</div>
)}
</div>
{!!author && (
<div className="pt-3 text-text-tertiary system-xs-regular">
{author}
</div>
)}
</div>
)
}
const Snippets = ({
loading = false,
searchText,
}: SnippetsProps) => {
const { t } = useTranslation()
const deferredSearchText = useDeferredValue(searchText)
const [hoveredSnippetId, setHoveredSnippetId] = useState<string | null>(null)
const snippets = useMemo(() => {
return STATIC_SNIPPETS.map(item => ({
...item,
}))
}, [])
const filteredSnippets = useMemo(() => {
const normalizedSearch = deferredSearchText.trim().toLowerCase()
if (!normalizedSearch)
return snippets
return snippets.filter(item => item.title.toLowerCase().includes(normalizedSearch))
}, [deferredSearchText, snippets])
if (loading)
return <LoadingSkeleton />
if (!filteredSnippets.length) {
return (
<div className="flex min-h-[480px] flex-col items-center justify-center gap-2 px-4">
<SearchMenu className="h-8 w-8 text-text-tertiary" />
<div className="text-text-secondary system-sm-regular">
{t('tabs.noSnippetsFound', { ns: 'workflow' })}
</div>
<Button
variant="secondary-accent"
size="small"
onClick={(e) => {
e.preventDefault()
}}
>
{t('tabs.createSnippet', { ns: 'workflow' })}
</Button>
</div>
)
}
return (
<div className="max-h-[480px] max-w-[500px] overflow-y-auto p-1">
{filteredSnippets.map((item) => {
const badge = (
<SnippetBadge
badge={item.badge}
badgeClassName={item.badgeClassName}
/>
)
const row = (
<div
className={cn(
'flex h-8 items-center gap-2 rounded-lg px-3',
hoveredSnippetId === item.id && 'bg-background-default-hover',
)}
onMouseEnter={() => setHoveredSnippetId(item.id)}
onMouseLeave={() => setHoveredSnippetId(current => current === item.id ? null : current)}
>
{badge}
<div className="min-w-0 text-text-secondary system-sm-medium">
{item.title}
</div>
{hoveredSnippetId === item.id && item.author && (
<div className="ml-auto text-text-tertiary system-xs-regular">
{item.author}
</div>
)}
</div>
)
if (!item.description)
return <div key={item.id}>{row}</div>
return (
<Tooltip key={item.id}>
<TooltipTrigger
delay={0}
render={row}
/>
<TooltipContent
placement="left-start"
variant="plain"
popupClassName="!bg-transparent !p-0"
>
<SnippetDetailCard
author={item.author}
description={item.description}
relatedBlocks={item.relatedBlocks}
title={item.title}
triggerBadge={badge}
/>
</TooltipContent>
</Tooltip>
)
})}
</div>
)
}
export default memo(Snippets)

View File

@@ -40,6 +40,7 @@ export type TabsProps = {
noTools?: boolean
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).
snippetsElem?: React.ReactNode
}
const Tabs: FC<TabsProps> = ({
activeTab,
@@ -57,6 +58,7 @@ const Tabs: FC<TabsProps> = ({
noTools,
forceShowStartContent = false,
allowStartNodeSelection = false,
snippetsElem,
}) => {
const { t } = useTranslation()
const { data: buildInTools } = useAllBuiltInTools()
@@ -234,6 +236,13 @@ const Tabs: FC<TabsProps> = ({
/>
)
}
{
activeTab === TabsEnum.Snippets && snippetsElem && (
<div className="border-t border-divider-subtle">
{snippetsElem}
</div>
)
}
</div>
)
}

View File

@@ -7,6 +7,7 @@ export enum TabsEnum {
Blocks = 'blocks',
Tools = 'tools',
Sources = 'sources',
Snippets = 'snippets',
}
export enum ToolTypeEnum {

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
const useCheckVerticalScrollbar = (ref: React.RefObject<HTMLElement>) => {
const useCheckVerticalScrollbar = (ref: React.RefObject<HTMLElement | null>) => {
const [hasVerticalScrollbar, setHasVerticalScrollbar] = useState(false)
useEffect(() => {

View File

@@ -0,0 +1,178 @@
'use client'
import type { FC } from 'react'
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import { useKeyPress } from 'ahooks'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import AppIconPicker from '@/app/components/base/app-icon-picker'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import Toast from '@/app/components/base/toast'
import { Dialog, DialogCloseButton, DialogContent, DialogPortal, DialogTitle } from '@/app/components/base/ui/dialog'
import ShortcutsName from './shortcuts-name'
export type CreateSnippetDialogPayload = {
name: string
description: string
icon: AppIconSelection
selectedNodeIds: string[]
}
type CreateSnippetDialogProps = {
isOpen: boolean
selectedNodeIds: string[]
onClose: () => void
onConfirm: (payload: CreateSnippetDialogPayload) => void
}
const defaultIcon: AppIconSelection = {
type: 'emoji',
icon: '🤖',
background: '#FFEAD5',
}
const CreateSnippetDialog: FC<CreateSnippetDialogProps> = ({
isOpen,
selectedNodeIds,
onClose,
onConfirm,
}) => {
const { t } = useTranslation()
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [icon, setIcon] = useState<AppIconSelection>(defaultIcon)
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
const resetForm = useCallback(() => {
setName('')
setDescription('')
setIcon(defaultIcon)
setShowAppIconPicker(false)
}, [])
const handleClose = useCallback(() => {
resetForm()
onClose()
}, [onClose, resetForm])
const handleConfirm = useCallback(() => {
const trimmedName = name.trim()
const trimmedDescription = description.trim()
if (!trimmedName)
return
const payload = {
name: trimmedName,
description: trimmedDescription,
icon,
selectedNodeIds,
}
onConfirm(payload)
Toast.notify({
type: 'success',
message: t('snippet.createSuccess', { ns: 'workflow' }),
})
handleClose()
}, [description, handleClose, icon, name, onConfirm, selectedNodeIds, t])
useKeyPress(['meta.enter', 'ctrl.enter'], () => {
if (!isOpen)
return
handleConfirm()
})
return (
<>
<Dialog open={isOpen} onOpenChange={open => !open && handleClose()}>
<DialogContent className="w-[520px] max-w-[520px] p-0">
<DialogCloseButton />
<div className="px-6 pb-3 pt-6">
<DialogTitle className="text-text-primary title-2xl-semi-bold">
{t('snippet.createDialogTitle', { ns: 'workflow' })}
</DialogTitle>
</div>
<div className="space-y-4 px-6 py-2">
<div className="flex items-end gap-3">
<div className="flex-1 pb-0.5">
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-medium">
{t('snippet.nameLabel', { ns: 'workflow' })}
</div>
<Input
value={name}
onChange={e => setName(e.target.value)}
placeholder={t('snippet.namePlaceholder', { ns: 'workflow' }) || ''}
autoFocus
/>
</div>
<AppIcon
size="xxl"
className="shrink-0 cursor-pointer"
iconType={icon.type}
icon={icon.type === 'emoji' ? icon.icon : icon.fileId}
background={icon.type === 'emoji' ? icon.background : undefined}
imageUrl={icon.type === 'image' ? icon.url : undefined}
onClick={() => setShowAppIconPicker(true)}
/>
</div>
<div>
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-medium">
{t('snippet.descriptionLabel', { ns: 'workflow' })}
</div>
<Textarea
className="resize-none"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder={t('snippet.descriptionPlaceholder', { ns: 'workflow' }) || ''}
/>
</div>
</div>
<div className="flex items-center justify-end gap-2 px-6 pb-6 pt-5">
<Button onClick={handleClose}>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button
variant="primary"
disabled={!name.trim()}
onClick={handleConfirm}
>
{t('snippet.confirm', { ns: 'workflow' })}
<ShortcutsName className="ml-1" keys={['ctrl', 'enter']} bgColor="white" />
</Button>
</div>
</DialogContent>
<DialogPortal>
<div className="pointer-events-none fixed left-1/2 top-1/2 z-[1002] flex -translate-x-1/2 translate-y-[170px] items-center gap-1 text-text-quaternary body-xs-regular">
<span>{t('snippet.shortcuts.press', { ns: 'workflow' })}</span>
<ShortcutsName keys={['ctrl', 'enter']} textColor="secondary" />
<span>{t('snippet.shortcuts.toConfirm', { ns: 'workflow' })}</span>
</div>
</DialogPortal>
</Dialog>
{showAppIconPicker && (
<AppIconPicker
className="z-[1100]"
onSelect={(selection) => {
setIcon(selection)
setShowAppIconPicker(false)
}}
onClose={() => setShowAppIconPicker(false)}
/>
)}
</>
)
}
export default CreateSnippetDialog

View File

@@ -0,0 +1,59 @@
import { fireEvent, screen } from '@testing-library/react'
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import ChatVariableButton from '../chat-variable-button'
let mockTheme: 'light' | 'dark' = 'light'
vi.mock('@/hooks/use-theme', () => ({
default: () => ({
theme: mockTheme,
}),
}))
describe('ChatVariableButton', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTheme = 'light'
})
it('opens the chat variable panel and closes the other workflow panels', () => {
const { store } = renderWorkflowComponent(<ChatVariableButton disabled={false} />, {
initialStoreState: {
showEnvPanel: true,
showGlobalVariablePanel: true,
showDebugAndPreviewPanel: true,
},
})
fireEvent.click(screen.getByRole('button'))
expect(store.getState().showChatVariablePanel).toBe(true)
expect(store.getState().showEnvPanel).toBe(false)
expect(store.getState().showGlobalVariablePanel).toBe(false)
expect(store.getState().showDebugAndPreviewPanel).toBe(false)
})
it('applies the active dark theme styles when the chat variable panel is visible', () => {
mockTheme = 'dark'
renderWorkflowComponent(<ChatVariableButton disabled={false} />, {
initialStoreState: {
showChatVariablePanel: true,
},
})
expect(screen.getByRole('button')).toHaveClass('border-black/5', 'bg-white/10', 'backdrop-blur-sm')
})
it('stays disabled without mutating panel state', () => {
const { store } = renderWorkflowComponent(<ChatVariableButton disabled />, {
initialStoreState: {
showChatVariablePanel: false,
},
})
fireEvent.click(screen.getByRole('button'))
expect(screen.getByRole('button')).toBeDisabled()
expect(store.getState().showChatVariablePanel).toBe(false)
})
})

View File

@@ -0,0 +1,63 @@
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import EditingTitle from '../editing-title'
const mockFormatTime = vi.fn()
const mockFormatTimeFromNow = vi.fn()
vi.mock('@/hooks/use-timestamp', () => ({
default: () => ({
formatTime: mockFormatTime,
}),
}))
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: mockFormatTimeFromNow,
}),
}))
describe('EditingTitle', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFormatTime.mockReturnValue('08:00:00')
mockFormatTimeFromNow.mockReturnValue('2 hours ago')
})
it('should render autosave, published time, and syncing status when the draft has metadata', () => {
const { container } = renderWorkflowComponent(<EditingTitle />, {
initialStoreState: {
draftUpdatedAt: 1_710_000_000_000,
publishedAt: 1_710_003_600_000,
isSyncingWorkflowDraft: true,
maximizeCanvas: true,
},
})
expect(mockFormatTime).toHaveBeenCalledWith(1_710_000_000, 'HH:mm:ss')
expect(mockFormatTimeFromNow).toHaveBeenCalledWith(1_710_003_600_000)
expect(container.firstChild).toHaveClass('ml-2')
expect(container).toHaveTextContent('workflow.common.autoSaved')
expect(container).toHaveTextContent('08:00:00')
expect(container).toHaveTextContent('workflow.common.published')
expect(container).toHaveTextContent('2 hours ago')
expect(container).toHaveTextContent('workflow.common.syncingData')
})
it('should render unpublished status without autosave metadata when the workflow has not been published', () => {
const { container } = renderWorkflowComponent(<EditingTitle />, {
initialStoreState: {
draftUpdatedAt: 0,
publishedAt: 0,
isSyncingWorkflowDraft: false,
maximizeCanvas: false,
},
})
expect(mockFormatTime).not.toHaveBeenCalled()
expect(mockFormatTimeFromNow).not.toHaveBeenCalled()
expect(container.firstChild).not.toHaveClass('ml-2')
expect(container).toHaveTextContent('workflow.common.unpublished')
expect(container).not.toHaveTextContent('workflow.common.autoSaved')
expect(container).not.toHaveTextContent('workflow.common.syncingData')
})
})

View File

@@ -0,0 +1,68 @@
import { fireEvent, screen } from '@testing-library/react'
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import EnvButton from '../env-button'
const mockCloseAllInputFieldPanels = vi.fn()
let mockTheme: 'light' | 'dark' = 'light'
vi.mock('@/hooks/use-theme', () => ({
default: () => ({
theme: mockTheme,
}),
}))
vi.mock('@/app/components/rag-pipeline/hooks', () => ({
useInputFieldPanel: () => ({
closeAllInputFieldPanels: mockCloseAllInputFieldPanels,
}),
}))
describe('EnvButton', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTheme = 'light'
})
it('should open the environment panel and close the other panels when clicked', () => {
const { store } = renderWorkflowComponent(<EnvButton disabled={false} />, {
initialStoreState: {
showChatVariablePanel: true,
showGlobalVariablePanel: true,
showDebugAndPreviewPanel: true,
},
})
fireEvent.click(screen.getByRole('button'))
expect(store.getState().showEnvPanel).toBe(true)
expect(store.getState().showChatVariablePanel).toBe(false)
expect(store.getState().showGlobalVariablePanel).toBe(false)
expect(store.getState().showDebugAndPreviewPanel).toBe(false)
expect(mockCloseAllInputFieldPanels).toHaveBeenCalledTimes(1)
})
it('should apply the active dark theme styles when the environment panel is visible', () => {
mockTheme = 'dark'
renderWorkflowComponent(<EnvButton disabled={false} />, {
initialStoreState: {
showEnvPanel: true,
},
})
expect(screen.getByRole('button')).toHaveClass('border-black/5', 'bg-white/10', 'backdrop-blur-sm')
})
it('should keep the button disabled when the disabled prop is true', () => {
const { store } = renderWorkflowComponent(<EnvButton disabled />, {
initialStoreState: {
showEnvPanel: false,
},
})
fireEvent.click(screen.getByRole('button'))
expect(screen.getByRole('button')).toBeDisabled()
expect(store.getState().showEnvPanel).toBe(false)
expect(mockCloseAllInputFieldPanels).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,68 @@
import { fireEvent, screen } from '@testing-library/react'
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import GlobalVariableButton from '../global-variable-button'
const mockCloseAllInputFieldPanels = vi.fn()
let mockTheme: 'light' | 'dark' = 'light'
vi.mock('@/hooks/use-theme', () => ({
default: () => ({
theme: mockTheme,
}),
}))
vi.mock('@/app/components/rag-pipeline/hooks', () => ({
useInputFieldPanel: () => ({
closeAllInputFieldPanels: mockCloseAllInputFieldPanels,
}),
}))
describe('GlobalVariableButton', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTheme = 'light'
})
it('should open the global variable panel and close the other panels when clicked', () => {
const { store } = renderWorkflowComponent(<GlobalVariableButton disabled={false} />, {
initialStoreState: {
showEnvPanel: true,
showChatVariablePanel: true,
showDebugAndPreviewPanel: true,
},
})
fireEvent.click(screen.getByRole('button'))
expect(store.getState().showGlobalVariablePanel).toBe(true)
expect(store.getState().showEnvPanel).toBe(false)
expect(store.getState().showChatVariablePanel).toBe(false)
expect(store.getState().showDebugAndPreviewPanel).toBe(false)
expect(mockCloseAllInputFieldPanels).toHaveBeenCalledTimes(1)
})
it('should apply the active dark theme styles when the global variable panel is visible', () => {
mockTheme = 'dark'
renderWorkflowComponent(<GlobalVariableButton disabled={false} />, {
initialStoreState: {
showGlobalVariablePanel: true,
},
})
expect(screen.getByRole('button')).toHaveClass('border-black/5', 'bg-white/10', 'backdrop-blur-sm')
})
it('should keep the button disabled when the disabled prop is true', () => {
const { store } = renderWorkflowComponent(<GlobalVariableButton disabled />, {
initialStoreState: {
showGlobalVariablePanel: false,
},
})
fireEvent.click(screen.getByRole('button'))
expect(screen.getByRole('button')).toBeDisabled()
expect(store.getState().showGlobalVariablePanel).toBe(false)
expect(mockCloseAllInputFieldPanels).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,109 @@
import type { VersionHistory } from '@/types/workflow'
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import { WorkflowVersion } from '../../types'
import RestoringTitle from '../restoring-title'
const mockFormatTime = vi.fn()
const mockFormatTimeFromNow = vi.fn()
vi.mock('@/hooks/use-timestamp', () => ({
default: () => ({
formatTime: mockFormatTime,
}),
}))
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: mockFormatTimeFromNow,
}),
}))
const createVersion = (overrides: Partial<VersionHistory> = {}): VersionHistory => ({
id: 'version-1',
graph: {
nodes: [],
edges: [],
},
created_at: 1_700_000_000,
created_by: {
id: 'user-1',
name: 'Alice',
email: 'alice@example.com',
},
hash: 'hash-1',
updated_at: 1_700_000_100,
updated_by: {
id: 'user-2',
name: 'Bob',
email: 'bob@example.com',
},
tool_published: false,
version: 'v1',
marked_name: 'Release 1',
marked_comment: '',
...overrides,
})
describe('RestoringTitle', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFormatTime.mockReturnValue('09:30:00')
mockFormatTimeFromNow.mockReturnValue('3 hours ago')
})
it('should render draft metadata when the current version is a draft', () => {
const currentVersion = createVersion({
version: WorkflowVersion.Draft,
})
const { container } = renderWorkflowComponent(<RestoringTitle />, {
initialStoreState: {
currentVersion,
},
})
expect(mockFormatTimeFromNow).toHaveBeenCalledWith(currentVersion.updated_at * 1000)
expect(mockFormatTime).toHaveBeenCalledWith(currentVersion.created_at, 'HH:mm:ss')
expect(container).toHaveTextContent('workflow.versionHistory.currentDraft')
expect(container).toHaveTextContent('workflow.common.viewOnly')
expect(container).toHaveTextContent('workflow.common.unpublished')
expect(container).toHaveTextContent('3 hours ago 09:30:00')
expect(container).toHaveTextContent('Alice')
})
it('should render published metadata and fallback version name when the marked name is empty', () => {
const currentVersion = createVersion({
marked_name: '',
})
const { container } = renderWorkflowComponent(<RestoringTitle />, {
initialStoreState: {
currentVersion,
},
})
expect(mockFormatTimeFromNow).toHaveBeenCalledWith(currentVersion.created_at * 1000)
expect(container).toHaveTextContent('workflow.versionHistory.defaultName')
expect(container).toHaveTextContent('workflow.common.published')
expect(container).toHaveTextContent('Alice')
})
it('should render an empty creator name when the version creator name is missing', () => {
const currentVersion = createVersion({
created_by: {
id: 'user-1',
name: '',
email: 'alice@example.com',
},
})
const { container } = renderWorkflowComponent(<RestoringTitle />, {
initialStoreState: {
currentVersion,
},
})
expect(container).toHaveTextContent('workflow.common.published')
expect(container).not.toHaveTextContent('Alice')
})
})

View File

@@ -0,0 +1,61 @@
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import RunningTitle from '../running-title'
let mockIsChatMode = false
const mockFormatWorkflowRunIdentifier = vi.fn()
vi.mock('../../hooks', () => ({
useIsChatMode: () => mockIsChatMode,
}))
vi.mock('../../utils', () => ({
formatWorkflowRunIdentifier: (finishedAt?: number) => mockFormatWorkflowRunIdentifier(finishedAt),
}))
describe('RunningTitle', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsChatMode = false
mockFormatWorkflowRunIdentifier.mockReturnValue(' (14:30:25)')
})
it('should render the test run title in workflow mode', () => {
const { container } = renderWorkflowComponent(<RunningTitle />, {
initialStoreState: {
historyWorkflowData: {
id: 'history-1',
status: 'succeeded',
finished_at: 1_700_000_000,
},
},
})
expect(mockFormatWorkflowRunIdentifier).toHaveBeenCalledWith(1_700_000_000)
expect(container).toHaveTextContent('Test Run (14:30:25)')
expect(container).toHaveTextContent('workflow.common.viewOnly')
})
it('should render the test chat title in chat mode', () => {
mockIsChatMode = true
const { container } = renderWorkflowComponent(<RunningTitle />, {
initialStoreState: {
historyWorkflowData: {
id: 'history-2',
status: 'running',
finished_at: undefined,
},
},
})
expect(mockFormatWorkflowRunIdentifier).toHaveBeenCalledWith(undefined)
expect(container).toHaveTextContent('Test Chat (14:30:25)')
})
it('should handle missing workflow history data', () => {
const { container } = renderWorkflowComponent(<RunningTitle />)
expect(mockFormatWorkflowRunIdentifier).toHaveBeenCalledWith(undefined)
expect(container).toHaveTextContent('Test Run (14:30:25)')
})
})

View File

@@ -0,0 +1,53 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { createNode } from '../../__tests__/fixtures'
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
import ScrollToSelectedNodeButton from '../scroll-to-selected-node-button'
const mockScrollToWorkflowNode = vi.fn()
vi.mock('reactflow', async () =>
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
vi.mock('../../utils/node-navigation', () => ({
scrollToWorkflowNode: (nodeId: string) => mockScrollToWorkflowNode(nodeId),
}))
describe('ScrollToSelectedNodeButton', () => {
beforeEach(() => {
vi.clearAllMocks()
resetReactFlowMockState()
})
it('should render nothing when there is no selected node', () => {
rfState.nodes = [
createNode({
id: 'node-1',
data: { selected: false },
}),
]
const { container } = render(<ScrollToSelectedNodeButton />)
expect(container.firstChild).toBeNull()
})
it('should render the action and scroll to the selected node when clicked', () => {
rfState.nodes = [
createNode({
id: 'node-1',
data: { selected: false },
}),
createNode({
id: 'node-2',
data: { selected: true },
}),
]
render(<ScrollToSelectedNodeButton />)
fireEvent.click(screen.getByText('workflow.panel.scrollToSelectedNode'))
expect(mockScrollToWorkflowNode).toHaveBeenCalledWith('node-2')
expect(mockScrollToWorkflowNode).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,118 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import UndoRedo from '../undo-redo'
type TemporalSnapshot = {
pastStates: unknown[]
futureStates: unknown[]
}
const mockUnsubscribe = vi.fn()
const mockTemporalSubscribe = vi.fn()
const mockHandleUndo = vi.fn()
const mockHandleRedo = vi.fn()
let latestTemporalListener: ((state: TemporalSnapshot) => void) | undefined
let mockNodesReadOnly = false
vi.mock('@/app/components/workflow/header/view-workflow-history', () => ({
default: () => <div data-testid="view-workflow-history" />,
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesReadOnly: () => ({
nodesReadOnly: mockNodesReadOnly,
}),
}))
vi.mock('@/app/components/workflow/workflow-history-store', () => ({
useWorkflowHistoryStore: () => ({
store: {
temporal: {
subscribe: mockTemporalSubscribe,
},
},
shortcutsEnabled: true,
setShortcutsEnabled: vi.fn(),
}),
}))
vi.mock('@/app/components/base/divider', () => ({
default: () => <div data-testid="divider" />,
}))
vi.mock('@/app/components/workflow/operator/tip-popup', () => ({
default: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
}))
describe('UndoRedo', () => {
beforeEach(() => {
vi.clearAllMocks()
mockNodesReadOnly = false
latestTemporalListener = undefined
mockTemporalSubscribe.mockImplementation((listener: (state: TemporalSnapshot) => void) => {
latestTemporalListener = listener
return mockUnsubscribe
})
})
it('enables undo and redo when history exists and triggers the callbacks', () => {
render(<UndoRedo handleRedo={mockHandleRedo} handleUndo={mockHandleUndo} />)
act(() => {
latestTemporalListener?.({
pastStates: [{}],
futureStates: [{}],
})
})
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.undo' }))
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.redo' }))
expect(mockHandleUndo).toHaveBeenCalledTimes(1)
expect(mockHandleRedo).toHaveBeenCalledTimes(1)
})
it('keeps the buttons disabled before history is available', () => {
render(<UndoRedo handleRedo={mockHandleRedo} handleUndo={mockHandleUndo} />)
const undoButton = screen.getByRole('button', { name: 'workflow.common.undo' })
const redoButton = screen.getByRole('button', { name: 'workflow.common.redo' })
fireEvent.click(undoButton)
fireEvent.click(redoButton)
expect(undoButton).toBeDisabled()
expect(redoButton).toBeDisabled()
expect(mockHandleUndo).not.toHaveBeenCalled()
expect(mockHandleRedo).not.toHaveBeenCalled()
})
it('does not trigger callbacks when the canvas is read only', () => {
mockNodesReadOnly = true
render(<UndoRedo handleRedo={mockHandleRedo} handleUndo={mockHandleUndo} />)
const undoButton = screen.getByRole('button', { name: 'workflow.common.undo' })
const redoButton = screen.getByRole('button', { name: 'workflow.common.redo' })
act(() => {
latestTemporalListener?.({
pastStates: [{}],
futureStates: [{}],
})
})
fireEvent.click(undoButton)
fireEvent.click(redoButton)
expect(undoButton).toBeDisabled()
expect(redoButton).toBeDisabled()
expect(mockHandleUndo).not.toHaveBeenCalled()
expect(mockHandleRedo).not.toHaveBeenCalled()
})
it('unsubscribes from the temporal store on unmount', () => {
const { unmount } = render(<UndoRedo handleRedo={mockHandleRedo} handleUndo={mockHandleUndo} />)
unmount()
expect(mockUnsubscribe).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,68 @@
import { fireEvent, render, screen } from '@testing-library/react'
import VersionHistoryButton from '../version-history-button'
let mockTheme: 'light' | 'dark' = 'light'
vi.mock('@/hooks/use-theme', () => ({
default: () => ({
theme: mockTheme,
}),
}))
vi.mock('../../utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../utils')>()
return {
...actual,
getKeyboardKeyCodeBySystem: () => 'ctrl',
}
})
describe('VersionHistoryButton', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTheme = 'light'
})
it('should call onClick when the button is clicked', () => {
const onClick = vi.fn()
render(<VersionHistoryButton onClick={onClick} />)
fireEvent.click(screen.getByRole('button'))
expect(onClick).toHaveBeenCalledTimes(1)
})
it('should trigger onClick when the version history shortcut is pressed', () => {
const onClick = vi.fn()
render(<VersionHistoryButton onClick={onClick} />)
const keyboardEvent = new KeyboardEvent('keydown', {
key: 'H',
ctrlKey: true,
shiftKey: true,
bubbles: true,
cancelable: true,
})
Object.defineProperty(keyboardEvent, 'keyCode', { value: 72 })
Object.defineProperty(keyboardEvent, 'which', { value: 72 })
window.dispatchEvent(keyboardEvent)
expect(keyboardEvent.defaultPrevented).toBe(true)
expect(onClick).toHaveBeenCalledTimes(1)
})
it('should render the tooltip popup content on hover', async () => {
render(<VersionHistoryButton onClick={vi.fn()} />)
fireEvent.mouseEnter(screen.getByRole('button'))
expect(await screen.findByText('workflow.common.versionHistory')).toBeInTheDocument()
})
it('should apply dark theme styles when the theme is dark', () => {
mockTheme = 'dark'
render(<VersionHistoryButton onClick={vi.fn()} />)
expect(screen.getByRole('button')).toHaveClass('border-black/5', 'bg-white/10', 'backdrop-blur-sm')
})
})

View File

@@ -0,0 +1,276 @@
import type { WorkflowRunHistory, WorkflowRunHistoryResponse } from '@/types/workflow'
import { fireEvent, screen } from '@testing-library/react'
import * as React from 'react'
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import { ControlMode, WorkflowRunningStatus } from '../../types'
import ViewHistory from '../view-history'
const mockUseWorkflowRunHistory = vi.fn()
const mockFormatTimeFromNow = vi.fn((value: number) => `from-now:${value}`)
const mockCloseAllInputFieldPanels = vi.fn()
const mockHandleNodesCancelSelected = vi.fn()
const mockHandleCancelDebugAndPreviewPanel = vi.fn()
const mockFormatWorkflowRunIdentifier = vi.fn((finishedAt?: number, status?: string) => ` (${status || finishedAt || 'unknown'})`)
let mockIsChatMode = false
vi.mock('../../hooks', async () => {
const actual = await vi.importActual<typeof import('../../hooks')>('../../hooks')
return {
...actual,
useIsChatMode: () => mockIsChatMode,
useNodesInteractions: () => ({
handleNodesCancelSelected: mockHandleNodesCancelSelected,
}),
useWorkflowInteractions: () => ({
handleCancelDebugAndPreviewPanel: mockHandleCancelDebugAndPreviewPanel,
}),
}
})
vi.mock('@/service/use-workflow', () => ({
useWorkflowRunHistory: (url?: string, enabled?: boolean) => mockUseWorkflowRunHistory(url, enabled),
}))
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: mockFormatTimeFromNow,
}),
}))
vi.mock('@/app/components/rag-pipeline/hooks', () => ({
useInputFieldPanel: () => ({
closeAllInputFieldPanels: mockCloseAllInputFieldPanels,
}),
}))
vi.mock('@/app/components/base/loading', () => ({
default: () => <div data-testid="loading" />,
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
}))
vi.mock('@/app/components/base/portal-to-follow-elem', () => {
const PortalContext = React.createContext({ open: false })
return {
PortalToFollowElem: ({
children,
open,
}: {
children?: React.ReactNode
open: boolean
}) => <PortalContext.Provider value={{ open }}>{children}</PortalContext.Provider>,
PortalToFollowElemTrigger: ({
children,
onClick,
}: {
children?: React.ReactNode
onClick?: () => void
}) => <div data-testid="portal-trigger" onClick={onClick}>{children}</div>,
PortalToFollowElemContent: ({
children,
}: {
children?: React.ReactNode
}) => {
const { open } = React.useContext(PortalContext)
return open ? <div data-testid="portal-content">{children}</div> : null
},
}
})
vi.mock('../../utils', async () => {
const actual = await vi.importActual<typeof import('../../utils')>('../../utils')
return {
...actual,
formatWorkflowRunIdentifier: (finishedAt?: number, status?: string) => mockFormatWorkflowRunIdentifier(finishedAt, status),
}
})
const createHistoryItem = (overrides: Partial<WorkflowRunHistory> = {}): WorkflowRunHistory => ({
id: 'run-1',
version: 'v1',
graph: {
nodes: [],
edges: [],
},
inputs: {},
status: WorkflowRunningStatus.Succeeded,
outputs: {},
elapsed_time: 1,
total_tokens: 2,
total_steps: 3,
created_at: 100,
finished_at: 120,
created_by_account: {
id: 'user-1',
name: 'Alice',
email: 'alice@example.com',
},
...overrides,
})
describe('ViewHistory', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsChatMode = false
mockUseWorkflowRunHistory.mockReturnValue({
data: { data: [] } satisfies WorkflowRunHistoryResponse,
isLoading: false,
})
})
it('defers fetching until the history popup is opened and renders the empty state', () => {
renderWorkflowComponent(<ViewHistory historyUrl="/history" withText />, {
hooksStoreProps: {
handleBackupDraft: vi.fn(),
},
})
expect(mockUseWorkflowRunHistory).toHaveBeenCalledWith('/history', false)
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.showRunHistory' }))
expect(mockUseWorkflowRunHistory).toHaveBeenLastCalledWith('/history', true)
expect(screen.getByText('workflow.common.notRunning')).toBeInTheDocument()
expect(screen.getByText('workflow.common.showRunHistory')).toBeInTheDocument()
})
it('renders the icon trigger variant and loading state, and clears log modals on trigger click', () => {
const onClearLogAndMessageModal = vi.fn()
mockUseWorkflowRunHistory.mockReturnValue({
data: { data: [] } satisfies WorkflowRunHistoryResponse,
isLoading: true,
})
renderWorkflowComponent(
<ViewHistory
historyUrl="/history"
onClearLogAndMessageModal={onClearLogAndMessageModal}
/>,
{
hooksStoreProps: {
handleBackupDraft: vi.fn(),
},
},
)
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.viewRunHistory' }))
expect(onClearLogAndMessageModal).toHaveBeenCalledTimes(1)
expect(screen.getByTestId('loading')).toBeInTheDocument()
})
it('renders workflow run history items and updates the workflow store when one is selected', () => {
const handleBackupDraft = vi.fn()
const pausedRun = createHistoryItem({
id: 'run-paused',
status: WorkflowRunningStatus.Paused,
created_at: 101,
finished_at: 0,
})
const failedRun = createHistoryItem({
id: 'run-failed',
status: WorkflowRunningStatus.Failed,
created_at: 102,
finished_at: 130,
})
const succeededRun = createHistoryItem({
id: 'run-succeeded',
status: WorkflowRunningStatus.Succeeded,
created_at: 103,
finished_at: 140,
})
mockUseWorkflowRunHistory.mockReturnValue({
data: {
data: [pausedRun, failedRun, succeededRun],
} satisfies WorkflowRunHistoryResponse,
isLoading: false,
})
const { store } = renderWorkflowComponent(<ViewHistory historyUrl="/history" withText />, {
initialStoreState: {
historyWorkflowData: failedRun,
showInputsPanel: true,
showEnvPanel: true,
controlMode: ControlMode.Pointer,
},
hooksStoreProps: {
handleBackupDraft,
},
})
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.showRunHistory' }))
expect(screen.getByText('Test Run (paused)')).toBeInTheDocument()
expect(screen.getByText('Test Run (failed)')).toBeInTheDocument()
expect(screen.getByText('Test Run (succeeded)')).toBeInTheDocument()
fireEvent.click(screen.getByText('Test Run (succeeded)'))
expect(store.getState().historyWorkflowData).toEqual(succeededRun)
expect(store.getState().showInputsPanel).toBe(false)
expect(store.getState().showEnvPanel).toBe(false)
expect(store.getState().controlMode).toBe(ControlMode.Hand)
expect(mockCloseAllInputFieldPanels).toHaveBeenCalledTimes(1)
expect(handleBackupDraft).toHaveBeenCalledTimes(1)
expect(mockHandleNodesCancelSelected).toHaveBeenCalledTimes(1)
expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalledTimes(1)
})
it('renders chat history labels without workflow status icons in chat mode', () => {
mockIsChatMode = true
const chatRun = createHistoryItem({
id: 'chat-run',
status: WorkflowRunningStatus.Failed,
})
mockUseWorkflowRunHistory.mockReturnValue({
data: {
data: [chatRun],
} satisfies WorkflowRunHistoryResponse,
isLoading: false,
})
renderWorkflowComponent(<ViewHistory historyUrl="/history" withText />, {
hooksStoreProps: {
handleBackupDraft: vi.fn(),
},
})
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.showRunHistory' }))
expect(screen.getByText('Test Chat (failed)')).toBeInTheDocument()
})
it('closes the popup from the close button and clears log modals', () => {
const onClearLogAndMessageModal = vi.fn()
mockUseWorkflowRunHistory.mockReturnValue({
data: { data: [] } satisfies WorkflowRunHistoryResponse,
isLoading: false,
})
renderWorkflowComponent(
<ViewHistory
historyUrl="/history"
withText
onClearLogAndMessageModal={onClearLogAndMessageModal}
/>,
{
hooksStoreProps: {
handleBackupDraft: vi.fn(),
},
},
)
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.showRunHistory' }))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' }))
expect(onClearLogAndMessageModal).toHaveBeenCalledTimes(1)
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
})
})

View File

@@ -1,6 +1,5 @@
import type { FC } from 'react'
import type { CommonNodeType } from '../types'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useNodes } from 'reactflow'
import { cn } from '@/utils/classnames'
@@ -11,21 +10,15 @@ const ScrollToSelectedNodeButton: FC = () => {
const nodes = useNodes<CommonNodeType>()
const selectedNode = nodes.find(node => node.data.selected)
const handleScrollToSelectedNode = useCallback(() => {
if (!selectedNode)
return
scrollToWorkflowNode(selectedNode.id)
}, [selectedNode])
if (!selectedNode)
return null
return (
<div
className={cn(
'system-xs-medium flex h-6 cursor-pointer items-center justify-center whitespace-nowrap rounded-md border-[0.5px] border-effects-highlight bg-components-actionbar-bg px-3 text-text-tertiary shadow-lg backdrop-blur-sm transition-colors duration-200 hover:text-text-accent',
'flex h-6 cursor-pointer items-center justify-center whitespace-nowrap rounded-md border-[0.5px] border-effects-highlight bg-components-actionbar-bg px-3 text-text-tertiary shadow-lg backdrop-blur-sm transition-colors duration-200 system-xs-medium hover:text-text-accent',
)}
onClick={handleScrollToSelectedNode}
onClick={() => scrollToWorkflowNode(selectedNode.id)}
>
{t('panel.scrollToSelectedNode', { ns: 'workflow' })}
</div>

View File

@@ -1,8 +1,4 @@
import type { FC } from 'react'
import {
RiArrowGoBackLine,
RiArrowGoForwardFill,
} from '@remixicon/react'
import { memo, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ViewWorkflowHistory from '@/app/components/workflow/header/view-workflow-history'
@@ -33,28 +29,34 @@ const UndoRedo: FC<UndoRedoProps> = ({ handleUndo, handleRedo }) => {
return (
<div className="flex items-center space-x-0.5 rounded-lg border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-lg backdrop-blur-[5px]">
<TipPopup title={t('common.undo', { ns: 'workflow' })!} shortcuts={['ctrl', 'z']}>
<div
<button
type="button"
aria-label={t('common.undo', { ns: 'workflow' })!}
data-tooltip-id="workflow.undo"
disabled={nodesReadOnly || buttonsDisabled.undo}
className={
cn('system-sm-medium flex h-8 w-8 cursor-pointer select-none items-center rounded-md px-1.5 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary', (nodesReadOnly || buttonsDisabled.undo)
&& 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled')
}
onClick={() => !nodesReadOnly && !buttonsDisabled.undo && handleUndo()}
onClick={handleUndo}
>
<RiArrowGoBackLine className="h-4 w-4" />
</div>
<span className="i-ri-arrow-go-back-line h-4 w-4" />
</button>
</TipPopup>
<TipPopup title={t('common.redo', { ns: 'workflow' })!} shortcuts={['ctrl', 'y']}>
<div
<button
type="button"
aria-label={t('common.redo', { ns: 'workflow' })!}
data-tooltip-id="workflow.redo"
disabled={nodesReadOnly || buttonsDisabled.redo}
className={
cn('system-sm-medium flex h-8 w-8 cursor-pointer select-none items-center rounded-md px-1.5 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary', (nodesReadOnly || buttonsDisabled.redo)
&& 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled')
}
onClick={() => !nodesReadOnly && !buttonsDisabled.redo && handleRedo()}
onClick={handleRedo}
>
<RiArrowGoForwardFill className="h-4 w-4" />
</div>
<span className="i-ri-arrow-go-forward-fill h-4 w-4" />
</button>
</TipPopup>
<Divider type="vertical" className="mx-0.5 h-3.5" />
<ViewWorkflowHistory />

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