Merge remote-tracking branch 'origin/main' into feat/css-first

This commit is contained in:
yyh 2026-05-04 21:35:53 +08:00
commit d2097736fd
No known key found for this signature in database
109 changed files with 2039 additions and 1736 deletions

View File

@ -116,10 +116,6 @@ jobs:
if: github.event_name != 'merge_group'
uses: ./.github/actions/setup-web
- name: Generate API contracts
if: github.event_name != 'merge_group' && steps.api-changes.outputs.any_changed == 'true'
run: pnpm --filter @dify/contracts gen-api-contract
- name: ESLint autofix
if: github.event_name != 'merge_group' && steps.web-changes.outputs.any_changed == 'true'
run: |

View File

@ -158,7 +158,7 @@ jobs:
- name: Run Claude Code for Translation Sync
if: steps.context.outputs.CHANGED_FILES != ''
uses: anthropics/claude-code-action@ef50f123a3a9be95b60040d042717517407c7256 # v1.0.110
uses: anthropics/claude-code-action@fefa07e9c665b7320f08c3b525980457f22f58aa # v1.0.111
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }}

View File

@ -41,7 +41,8 @@ def guess_file_info_from_response(response: httpx.Response):
# Try to extract filename from URL
parsed_url = urllib.parse.urlparse(url)
url_path = parsed_url.path
filename = os.path.basename(url_path)
# Decode percent-encoded characters in the path segment
filename = urllib.parse.unquote(os.path.basename(url_path))
# If filename couldn't be extracted, use Content-Disposition header
if not filename:

View File

@ -8,6 +8,7 @@ from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator, model_validator
from sqlalchemy import select
from werkzeug.exceptions import NotFound
from configs import dify_config
from constants.languages import supported_language
@ -45,6 +46,8 @@ from libs.helper import EmailStr, extract_remote_ip, timezone
from libs.login import current_account_with_tenant, login_required
from models import AccountIntegrate, InvitationCode
from models.account import AccountStatus, InvitationCodeStatus
from models.enums import CreatorUserRole
from models.model import UploadFile
from services.account_service import AccountService
from services.billing_service import BillingService
from services.errors.account import CurrentPasswordIncorrectError as ServiceCurrentPasswordIncorrectError
@ -322,9 +325,24 @@ class AccountAvatarApi(Resource):
@login_required
@account_initialization_required
def get(self):
current_user, current_tenant_id = current_account_with_tenant()
args = AccountAvatarQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
avatar = args.avatar
avatar_url = file_helpers.get_signed_file_url(args.avatar)
if avatar.startswith(("http://", "https://")):
return {"avatar_url": avatar}
upload_file = db.session.scalar(select(UploadFile).where(UploadFile.id == avatar).limit(1))
if upload_file is None:
raise NotFound("Avatar file not found")
if upload_file.tenant_id != current_tenant_id:
raise NotFound("Avatar file not found")
if upload_file.created_by_role != CreatorUserRole.ACCOUNT or upload_file.created_by != current_user.id:
raise NotFound("Avatar file not found")
avatar_url = file_helpers.get_signed_file_url(upload_file_id=upload_file.id)
return {"avatar_url": avatar_url}
@console_ns.expect(console_ns.models[AccountAvatarPayload.__name__])

View File

@ -144,8 +144,20 @@ class Vector:
def get_vector_factory(vector_type: str) -> type[AbstractVectorFactory]:
return get_vector_factory_class(vector_type)
@staticmethod
def _filter_empty_text_documents(documents: list[Document]) -> list[Document]:
filtered_documents = [document for document in documents if document.page_content.strip()]
skipped_count = len(documents) - len(filtered_documents)
if skipped_count:
logger.warning("skip %d empty documents before vector embedding", skipped_count)
return filtered_documents
def create(self, texts: list | None = None, **kwargs):
if texts:
texts = self._filter_empty_text_documents(texts)
if not texts:
return
start = time.time()
logger.info("start embedding %s texts %s", len(texts), start)
batch_size = 1000
@ -203,8 +215,14 @@ class Vector:
logger.info("Embedding %s files took %s s", len(file_documents), time.time() - start)
def add_texts(self, documents: list[Document], **kwargs):
documents = self._filter_empty_text_documents(documents)
if not documents:
return
if kwargs.get("duplicate_check", False):
documents = self._filter_duplicate_texts(documents)
if not documents:
return
embeddings = self._embeddings.embed_documents([document.page_content for document in documents])
self._vector_processor.create(texts=documents, embeddings=embeddings, **kwargs)

View File

@ -19,8 +19,13 @@ from werkzeug.http import parse_options_header
from core.helper import ssrf_proxy
def extract_filename(url_path: str, content_disposition: str | None) -> str | None:
"""Extract a safe filename from Content-Disposition or the request URL path."""
def extract_filename(url_or_path: str, content_disposition: str | None) -> str | None:
"""Extract a safe filename from Content-Disposition or the request URL path.
Handles full URLs, paths with query strings, hash fragments, and percent-encoded segments.
Query strings and hash fragments are stripped from the URL before extracting the basename.
Percent-encoded characters in the path are decoded safely.
"""
filename: str | None = None
if content_disposition:
filename_star_match = re.search(r"filename\*=([^;]+)", content_disposition)
@ -47,8 +52,13 @@ def extract_filename(url_path: str, content_disposition: str | None) -> str | No
filename = urllib.parse.unquote(raw)
if not filename:
candidate = os.path.basename(url_path)
filename = urllib.parse.unquote(candidate) if candidate else None
# Parse the URL to extract just the path, stripping query strings and fragments
# This handles both full URLs and bare paths
parsed = urllib.parse.urlparse(url_or_path)
path = parsed.path
candidate = os.path.basename(path)
# Decode percent-encoded characters, with safe fallback for malformed input
filename = urllib.parse.unquote(candidate, errors="replace") if candidate else None
if filename:
filename = os.path.basename(filename)

View File

@ -14,7 +14,7 @@ dependencies = [
"gevent>=26.4.0",
"gevent-websocket>=0.10.1",
"gmpy2>=2.3.0",
"google-api-python-client>=2.194.0",
"google-api-python-client>=2.195.0",
"gunicorn>=25.3.0",
"psycogreen>=1.0.2",
"psycopg2-binary>=2.9.12",
@ -31,7 +31,7 @@ dependencies = [
"flask-migrate>=4.1.0,<5.0.0",
"flask-orjson>=2.0.0,<3.0.0",
"flask-restx>=1.3.2,<2.0.0",
"google-cloud-aiplatform>=1.148.1,<2.0.0",
"google-cloud-aiplatform>=1.149.0,<2.0.0",
"httpx[socks]>=0.28.1,<1.0.0",
"opentelemetry-distro>=0.62b1,<1.0.0",
"opentelemetry-instrumentation-celery>=0.62b0,<1.0.0",

View File

@ -3,6 +3,7 @@ from typing import Any, Literal
from pydantic import BaseModel, field_validator
from core.rag.entities import Rule
from core.rag.entities.metadata_entities import MetadataFilteringCondition
from core.rag.index_processor.constant.index_type import IndexStructureType
from core.rag.retrieval.retrieval_methods import RetrievalMethod
@ -83,6 +84,7 @@ class RetrievalModel(BaseModel):
score_threshold_enabled: bool
score_threshold: float | None = None
weights: WeightModel | None = None
metadata_filtering_conditions: MetadataFilteringCondition | None = None
class MetaDataConfig(BaseModel):

View File

@ -1,4 +1,5 @@
import unittest
from __future__ import annotations
from datetime import UTC, datetime
from unittest.mock import patch
from uuid import uuid4
@ -16,7 +17,7 @@ from models.enums import CreatorUserRole
@pytest.mark.usefixtures("flask_req_ctx_with_containers")
class TestStorageKeyLoader(unittest.TestCase):
class TestStorageKeyLoader:
"""
Integration tests for StorageKeyLoader class.
@ -24,110 +25,82 @@ class TestStorageKeyLoader(unittest.TestCase):
with different transfer methods: LOCAL_FILE, REMOTE_URL, and TOOL_FILE.
"""
def setUp(self):
"""Set up test data before each test method."""
self.session = db.session()
self.tenant_id = str(uuid4())
self.user_id = str(uuid4())
self.conversation_id = str(uuid4())
# Create test data that will be cleaned up after each test
self.test_upload_files = []
self.test_tool_files = []
# Create StorageKeyLoader instance
self.loader = StorageKeyLoader(
self.session,
self.tenant_id,
access_controller=DatabaseFileAccessController(),
)
def tearDown(self):
"""Clean up test data after each test method."""
self.session.rollback()
# ------------------------------------------------------------------
# Per-test helpers (use db_session_with_containers as parameter)
# ------------------------------------------------------------------
@staticmethod
def _create_upload_file(
self, file_id: str | None = None, storage_key: str | None = None, tenant_id: str | None = None
session: Session,
tenant_id: str,
user_id: str,
*,
file_id: str | None = None,
storage_key: str | None = None,
override_tenant_id: str | None = None,
) -> UploadFile:
"""Helper method to create an UploadFile record for testing."""
if file_id is None:
file_id = str(uuid4())
if storage_key is None:
storage_key = f"test_storage_key_{uuid4()}"
if tenant_id is None:
tenant_id = self.tenant_id
"""Create and flush an UploadFile record for testing."""
upload_file = UploadFile(
tenant_id=tenant_id,
tenant_id=override_tenant_id if override_tenant_id is not None else tenant_id,
storage_type=StorageType.LOCAL,
key=storage_key,
key=storage_key or f"test_storage_key_{uuid4()}",
name="test_file.txt",
size=1024,
extension=".txt",
mime_type="text/plain",
created_by_role=CreatorUserRole.ACCOUNT,
created_by=self.user_id,
created_by=user_id,
created_at=datetime.now(UTC),
used=False,
)
upload_file.id = file_id
self.session.add(upload_file)
self.session.flush()
self.test_upload_files.append(upload_file)
upload_file.id = file_id or str(uuid4())
session.add(upload_file)
session.flush()
return upload_file
@staticmethod
def _create_tool_file(
self, file_id: str | None = None, file_key: str | None = None, tenant_id: str | None = None
session: Session,
tenant_id: str,
user_id: str,
conversation_id: str,
*,
file_id: str | None = None,
file_key: str | None = None,
override_tenant_id: str | None = None,
) -> ToolFile:
"""Helper method to create a ToolFile record for testing."""
if file_id is None:
file_id = str(uuid4())
if file_key is None:
file_key = f"test_file_key_{uuid4()}"
if tenant_id is None:
tenant_id = self.tenant_id
"""Create and flush a ToolFile record for testing."""
tool_file = ToolFile(
user_id=self.user_id,
tenant_id=tenant_id,
conversation_id=self.conversation_id,
file_key=file_key,
user_id=user_id,
tenant_id=override_tenant_id if override_tenant_id is not None else tenant_id,
conversation_id=conversation_id,
file_key=file_key or f"test_file_key_{uuid4()}",
mimetype="text/plain",
original_url="http://example.com/file.txt",
name="test_tool_file.txt",
size=2048,
)
tool_file.id = file_id
self.session.add(tool_file)
self.session.flush()
self.test_tool_files.append(tool_file)
tool_file.id = file_id or str(uuid4())
session.add(tool_file)
session.flush()
return tool_file
def _create_file(self, related_id: str, transfer_method: FileTransferMethod, tenant_id: str | None = None) -> File:
"""Helper method to create a File object for testing."""
if tenant_id is None:
tenant_id = self.tenant_id
# Set related_id for LOCAL_FILE and TOOL_FILE transfer methods
file_related_id = None
remote_url = None
if transfer_method in (FileTransferMethod.LOCAL_FILE, FileTransferMethod.TOOL_FILE):
file_related_id = related_id
elif transfer_method == FileTransferMethod.REMOTE_URL:
remote_url = "https://example.com/test_file.txt"
file_related_id = related_id
@staticmethod
def _create_file(
tenant_id: str,
related_id: str,
transfer_method: FileTransferMethod,
*,
override_tenant_id: str | None = None,
) -> File:
"""Build a File value-object for testing."""
remote_url = "https://example.com/test_file.txt" if transfer_method == FileTransferMethod.REMOTE_URL else None
return File(
file_id=str(uuid4()), # Generate new UUID for File.id
tenant_id=tenant_id,
file_id=str(uuid4()),
tenant_id=override_tenant_id if override_tenant_id is not None else tenant_id,
file_type=FileType.DOCUMENT,
transfer_method=transfer_method,
related_id=file_related_id,
related_id=related_id,
remote_url=remote_url,
filename="test_file.txt",
extension=".txt",
@ -136,240 +109,280 @@ class TestStorageKeyLoader(unittest.TestCase):
storage_key="initial_key",
)
def test_load_storage_keys_local_file(self):
# ------------------------------------------------------------------
# Tests
# ------------------------------------------------------------------
def test_load_storage_keys_local_file(self, db_session_with_containers: Session):
"""Test loading storage keys for LOCAL_FILE transfer method."""
# Create test data
upload_file = self._create_upload_file()
file = self._create_file(related_id=upload_file.id, transfer_method=FileTransferMethod.LOCAL_FILE)
tenant_id = str(uuid4())
user_id = str(uuid4())
# Load storage keys
self.loader.load_storage_keys([file])
upload_file = self._create_upload_file(db_session_with_containers, tenant_id, user_id)
file = self._create_file(tenant_id, related_id=upload_file.id, transfer_method=FileTransferMethod.LOCAL_FILE)
loader = StorageKeyLoader(
db_session_with_containers, tenant_id, access_controller=DatabaseFileAccessController()
)
loader.load_storage_keys([file])
# Verify storage key was loaded correctly
assert file._storage_key == upload_file.key
def test_load_storage_keys_remote_url(self):
def test_load_storage_keys_remote_url(self, db_session_with_containers: Session):
"""Test loading storage keys for REMOTE_URL transfer method."""
# Create test data
upload_file = self._create_upload_file()
file = self._create_file(related_id=upload_file.id, transfer_method=FileTransferMethod.REMOTE_URL)
tenant_id = str(uuid4())
user_id = str(uuid4())
# Load storage keys
self.loader.load_storage_keys([file])
upload_file = self._create_upload_file(db_session_with_containers, tenant_id, user_id)
file = self._create_file(tenant_id, related_id=upload_file.id, transfer_method=FileTransferMethod.REMOTE_URL)
loader = StorageKeyLoader(
db_session_with_containers, tenant_id, access_controller=DatabaseFileAccessController()
)
loader.load_storage_keys([file])
# Verify storage key was loaded correctly
assert file._storage_key == upload_file.key
def test_load_storage_keys_tool_file(self):
def test_load_storage_keys_tool_file(self, db_session_with_containers: Session):
"""Test loading storage keys for TOOL_FILE transfer method."""
# Create test data
tool_file = self._create_tool_file()
file = self._create_file(related_id=tool_file.id, transfer_method=FileTransferMethod.TOOL_FILE)
tenant_id = str(uuid4())
user_id = str(uuid4())
conversation_id = str(uuid4())
# Load storage keys
self.loader.load_storage_keys([file])
tool_file = self._create_tool_file(db_session_with_containers, tenant_id, user_id, conversation_id)
file = self._create_file(tenant_id, related_id=tool_file.id, transfer_method=FileTransferMethod.TOOL_FILE)
loader = StorageKeyLoader(
db_session_with_containers, tenant_id, access_controller=DatabaseFileAccessController()
)
loader.load_storage_keys([file])
# Verify storage key was loaded correctly
assert file._storage_key == tool_file.file_key
def test_load_storage_keys_mixed_methods(self):
def test_load_storage_keys_mixed_methods(self, db_session_with_containers: Session):
"""Test batch loading with mixed transfer methods."""
# Create test data for different transfer methods
upload_file1 = self._create_upload_file()
upload_file2 = self._create_upload_file()
tool_file = self._create_tool_file()
tenant_id = str(uuid4())
user_id = str(uuid4())
conversation_id = str(uuid4())
file1 = self._create_file(related_id=upload_file1.id, transfer_method=FileTransferMethod.LOCAL_FILE)
file2 = self._create_file(related_id=upload_file2.id, transfer_method=FileTransferMethod.REMOTE_URL)
file3 = self._create_file(related_id=tool_file.id, transfer_method=FileTransferMethod.TOOL_FILE)
upload_file1 = self._create_upload_file(db_session_with_containers, tenant_id, user_id)
upload_file2 = self._create_upload_file(db_session_with_containers, tenant_id, user_id)
tool_file = self._create_tool_file(db_session_with_containers, tenant_id, user_id, conversation_id)
files = [file1, file2, file3]
file1 = self._create_file(tenant_id, related_id=upload_file1.id, transfer_method=FileTransferMethod.LOCAL_FILE)
file2 = self._create_file(tenant_id, related_id=upload_file2.id, transfer_method=FileTransferMethod.REMOTE_URL)
file3 = self._create_file(tenant_id, related_id=tool_file.id, transfer_method=FileTransferMethod.TOOL_FILE)
# Load storage keys
self.loader.load_storage_keys(files)
loader = StorageKeyLoader(
db_session_with_containers, tenant_id, access_controller=DatabaseFileAccessController()
)
loader.load_storage_keys([file1, file2, file3])
# Verify all storage keys were loaded correctly
assert file1._storage_key == upload_file1.key
assert file2._storage_key == upload_file2.key
assert file3._storage_key == tool_file.file_key
def test_load_storage_keys_empty_list(self):
"""Test with empty file list."""
# Should not raise any exceptions
self.loader.load_storage_keys([])
def test_load_storage_keys_empty_list(self, db_session_with_containers: Session):
"""Test with empty file list — should not raise."""
tenant_id = str(uuid4())
loader = StorageKeyLoader(
db_session_with_containers, tenant_id, access_controller=DatabaseFileAccessController()
)
loader.load_storage_keys([])
def test_load_storage_keys_ignores_legacy_file_tenant_id(self):
def test_load_storage_keys_ignores_legacy_file_tenant_id(self, db_session_with_containers: Session):
"""Legacy file tenant_id should not override the loader tenant scope."""
upload_file = self._create_upload_file()
tenant_id = str(uuid4())
user_id = str(uuid4())
upload_file = self._create_upload_file(db_session_with_containers, tenant_id, user_id)
file = self._create_file(
related_id=upload_file.id, transfer_method=FileTransferMethod.LOCAL_FILE, tenant_id=str(uuid4())
tenant_id,
related_id=upload_file.id,
transfer_method=FileTransferMethod.LOCAL_FILE,
override_tenant_id=str(uuid4()),
)
self.loader.load_storage_keys([file])
loader = StorageKeyLoader(
db_session_with_containers, tenant_id, access_controller=DatabaseFileAccessController()
)
loader.load_storage_keys([file])
assert file._storage_key == upload_file.key
def test_load_storage_keys_missing_file_id(self):
"""Test with None file.related_id."""
# Create a file with valid parameters first, then manually set related_id to None
file = self._create_file(related_id=str(uuid4()), transfer_method=FileTransferMethod.LOCAL_FILE)
def test_load_storage_keys_missing_file_id(self, db_session_with_containers: Session):
"""Test with None file.related_id — should raise ValueError."""
tenant_id = str(uuid4())
user_id = str(uuid4())
upload_file = self._create_upload_file(db_session_with_containers, tenant_id, user_id)
file = self._create_file(tenant_id, related_id=upload_file.id, transfer_method=FileTransferMethod.LOCAL_FILE)
file.related_id = None
# Should raise ValueError for None file related_id
with pytest.raises(ValueError) as context:
self.loader.load_storage_keys([file])
loader = StorageKeyLoader(
db_session_with_containers, tenant_id, access_controller=DatabaseFileAccessController()
)
with pytest.raises(ValueError, match="file id should not be None."):
loader.load_storage_keys([file])
assert str(context.value) == "file id should not be None."
def test_load_storage_keys_nonexistent_upload_file_records(self, db_session_with_containers: Session):
"""Test with missing UploadFile database records — should raise ValueError."""
tenant_id = str(uuid4())
file = self._create_file(tenant_id, related_id=str(uuid4()), transfer_method=FileTransferMethod.LOCAL_FILE)
def test_load_storage_keys_nonexistent_upload_file_records(self):
"""Test with missing UploadFile database records."""
# Create file with non-existent upload file id
non_existent_id = str(uuid4())
file = self._create_file(related_id=non_existent_id, transfer_method=FileTransferMethod.LOCAL_FILE)
# Should raise ValueError for missing record
loader = StorageKeyLoader(
db_session_with_containers, tenant_id, access_controller=DatabaseFileAccessController()
)
with pytest.raises(ValueError):
self.loader.load_storage_keys([file])
loader.load_storage_keys([file])
def test_load_storage_keys_nonexistent_tool_file_records(self):
"""Test with missing ToolFile database records."""
# Create file with non-existent tool file id
non_existent_id = str(uuid4())
file = self._create_file(related_id=non_existent_id, transfer_method=FileTransferMethod.TOOL_FILE)
def test_load_storage_keys_nonexistent_tool_file_records(self, db_session_with_containers: Session):
"""Test with missing ToolFile database records — should raise ValueError."""
tenant_id = str(uuid4())
file = self._create_file(tenant_id, related_id=str(uuid4()), transfer_method=FileTransferMethod.TOOL_FILE)
# Should raise ValueError for missing record
loader = StorageKeyLoader(
db_session_with_containers, tenant_id, access_controller=DatabaseFileAccessController()
)
with pytest.raises(ValueError):
self.loader.load_storage_keys([file])
loader.load_storage_keys([file])
def test_load_storage_keys_invalid_uuid(self):
"""Test with invalid UUID format."""
# Create a file with valid parameters first, then manually set invalid related_id
file = self._create_file(related_id=str(uuid4()), transfer_method=FileTransferMethod.LOCAL_FILE)
def test_load_storage_keys_invalid_uuid(self, db_session_with_containers: Session):
"""Test with invalid UUID format — should raise ValueError."""
tenant_id = str(uuid4())
user_id = str(uuid4())
upload_file = self._create_upload_file(db_session_with_containers, tenant_id, user_id)
file = self._create_file(tenant_id, related_id=upload_file.id, transfer_method=FileTransferMethod.LOCAL_FILE)
file.related_id = "invalid-uuid-format"
# Should raise ValueError for invalid UUID
loader = StorageKeyLoader(
db_session_with_containers, tenant_id, access_controller=DatabaseFileAccessController()
)
with pytest.raises(ValueError):
self.loader.load_storage_keys([file])
loader.load_storage_keys([file])
def test_load_storage_keys_batch_efficiency(self):
"""Test batched operations use efficient queries."""
# Create multiple files of different types
upload_files = [self._create_upload_file() for _ in range(3)]
tool_files = [self._create_tool_file() for _ in range(2)]
def test_load_storage_keys_batch_efficiency(self, db_session_with_containers: Session):
"""Batched operations should issue exactly 2 queries for mixed file types."""
tenant_id = str(uuid4())
user_id = str(uuid4())
conversation_id = str(uuid4())
files = []
files.extend(
[self._create_file(related_id=uf.id, transfer_method=FileTransferMethod.LOCAL_FILE) for uf in upload_files]
upload_files = [self._create_upload_file(db_session_with_containers, tenant_id, user_id) for _ in range(3)]
tool_files = [
self._create_tool_file(db_session_with_containers, tenant_id, user_id, conversation_id) for _ in range(2)
]
files = [
self._create_file(tenant_id, related_id=uf.id, transfer_method=FileTransferMethod.LOCAL_FILE)
for uf in upload_files
] + [
self._create_file(tenant_id, related_id=tf.id, transfer_method=FileTransferMethod.TOOL_FILE)
for tf in tool_files
]
loader = StorageKeyLoader(
db_session_with_containers, tenant_id, access_controller=DatabaseFileAccessController()
)
files.extend(
[self._create_file(related_id=tf.id, transfer_method=FileTransferMethod.TOOL_FILE) for tf in tool_files]
)
# Mock the session to count queries
with patch.object(self.session, "scalars", wraps=self.session.scalars) as mock_scalars:
self.loader.load_storage_keys(files)
# Should make exactly 2 queries (one for upload_files, one for tool_files)
with patch.object(
db_session_with_containers, "scalars", wraps=db_session_with_containers.scalars
) as mock_scalars:
loader.load_storage_keys(files)
# Exactly 2 DB round-trips: one for UploadFile, one for ToolFile
assert mock_scalars.call_count == 2
# Verify all storage keys were loaded correctly
for i, file in enumerate(files[:3]):
assert file._storage_key == upload_files[i].key
for i, file in enumerate(files[3:]):
assert file._storage_key == tool_files[i].file_key
def test_load_storage_keys_tenant_isolation(self):
"""Test that tenant isolation works correctly."""
# Create files for different tenants
def test_load_storage_keys_tenant_isolation(self, db_session_with_containers: Session):
"""Loader should not surface records belonging to a different tenant."""
tenant_id = str(uuid4())
other_tenant_id = str(uuid4())
user_id = str(uuid4())
# Create upload file for current tenant
upload_file_current = self._create_upload_file()
upload_file_current = self._create_upload_file(db_session_with_containers, tenant_id, user_id)
file_current = self._create_file(
related_id=upload_file_current.id, transfer_method=FileTransferMethod.LOCAL_FILE
tenant_id, related_id=upload_file_current.id, transfer_method=FileTransferMethod.LOCAL_FILE
)
# Create upload file for other tenant (but don't add to cleanup list)
upload_file_other = UploadFile(
tenant_id=other_tenant_id,
storage_type=StorageType.LOCAL,
key="other_tenant_key",
name="other_file.txt",
size=1024,
extension=".txt",
mime_type="text/plain",
created_by_role=CreatorUserRole.ACCOUNT,
created_by=self.user_id,
created_at=datetime.now(UTC),
used=False,
upload_file_other = self._create_upload_file(
db_session_with_containers,
tenant_id,
user_id,
override_tenant_id=other_tenant_id,
)
upload_file_other.id = str(uuid4())
self.session.add(upload_file_other)
self.session.flush()
# Create file for other tenant but try to load with current tenant's loader
file_other = self._create_file(
related_id=upload_file_other.id, transfer_method=FileTransferMethod.LOCAL_FILE, tenant_id=other_tenant_id
tenant_id,
related_id=upload_file_other.id,
transfer_method=FileTransferMethod.LOCAL_FILE,
override_tenant_id=other_tenant_id,
)
# Should raise ValueError due to tenant mismatch
with pytest.raises(ValueError) as context:
self.loader.load_storage_keys([file_other])
loader = StorageKeyLoader(
db_session_with_containers, tenant_id, access_controller=DatabaseFileAccessController()
)
assert "Upload file not found for id:" in str(context.value)
with pytest.raises(ValueError, match="Upload file not found for id:"):
loader.load_storage_keys([file_other])
# Current tenant's file should still work
self.loader.load_storage_keys([file_current])
# Current-tenant file still resolves correctly
loader.load_storage_keys([file_current])
assert file_current._storage_key == upload_file_current.key
def test_load_storage_keys_mixed_tenant_batch(self):
"""Test batch with mixed tenant files (should fail on first mismatch)."""
# Create files for current tenant
upload_file_current = self._create_upload_file()
def test_load_storage_keys_mixed_tenant_batch(self, db_session_with_containers: Session):
"""A batch containing a foreign-tenant file should fail on the mismatch."""
tenant_id = str(uuid4())
user_id = str(uuid4())
upload_file_current = self._create_upload_file(db_session_with_containers, tenant_id, user_id)
file_current = self._create_file(
related_id=upload_file_current.id, transfer_method=FileTransferMethod.LOCAL_FILE
tenant_id, related_id=upload_file_current.id, transfer_method=FileTransferMethod.LOCAL_FILE
)
# Create file for different tenant
other_tenant_id = str(uuid4())
file_other = self._create_file(
related_id=str(uuid4()), transfer_method=FileTransferMethod.LOCAL_FILE, tenant_id=other_tenant_id
tenant_id,
related_id=str(uuid4()),
transfer_method=FileTransferMethod.LOCAL_FILE,
override_tenant_id=str(uuid4()),
)
# Should raise ValueError on tenant mismatch
with pytest.raises(ValueError) as context:
self.loader.load_storage_keys([file_current, file_other])
loader = StorageKeyLoader(
db_session_with_containers, tenant_id, access_controller=DatabaseFileAccessController()
)
with pytest.raises(ValueError, match="Upload file not found for id:"):
loader.load_storage_keys([file_current, file_other])
assert "Upload file not found for id:" in str(context.value)
def test_load_storage_keys_duplicate_file_ids(self, db_session_with_containers: Session):
"""Duplicate file IDs in the batch should be handled gracefully."""
tenant_id = str(uuid4())
user_id = str(uuid4())
def test_load_storage_keys_duplicate_file_ids(self):
"""Test handling of duplicate file IDs in the batch."""
# Create upload file
upload_file = self._create_upload_file()
upload_file = self._create_upload_file(db_session_with_containers, tenant_id, user_id)
file1 = self._create_file(tenant_id, related_id=upload_file.id, transfer_method=FileTransferMethod.LOCAL_FILE)
file2 = self._create_file(tenant_id, related_id=upload_file.id, transfer_method=FileTransferMethod.LOCAL_FILE)
# Create two File objects with same related_id
file1 = self._create_file(related_id=upload_file.id, transfer_method=FileTransferMethod.LOCAL_FILE)
file2 = self._create_file(related_id=upload_file.id, transfer_method=FileTransferMethod.LOCAL_FILE)
loader = StorageKeyLoader(
db_session_with_containers, tenant_id, access_controller=DatabaseFileAccessController()
)
loader.load_storage_keys([file1, file2])
# Should handle duplicates gracefully
self.loader.load_storage_keys([file1, file2])
# Both files should have the same storage key
assert file1._storage_key == upload_file.key
assert file2._storage_key == upload_file.key
def test_load_storage_keys_session_isolation(self):
"""Test that the loader uses the provided session correctly."""
# Create test data
upload_file = self._create_upload_file()
file = self._create_file(related_id=upload_file.id, transfer_method=FileTransferMethod.LOCAL_FILE)
def test_load_storage_keys_session_isolation(self, db_session_with_containers: Session):
"""A loader backed by an uncommitted session should not see data from another session."""
tenant_id = str(uuid4())
user_id = str(uuid4())
# Create loader with different session (same underlying connection)
upload_file = self._create_upload_file(db_session_with_containers, tenant_id, user_id)
file = self._create_file(tenant_id, related_id=upload_file.id, transfer_method=FileTransferMethod.LOCAL_FILE)
# A loader with a fresh, separate session cannot see uncommitted rows from db_session_with_containers
with Session(bind=db.engine) as other_session:
other_loader = StorageKeyLoader(
other_session,
self.tenant_id,
tenant_id,
access_controller=DatabaseFileAccessController(),
)
with pytest.raises(ValueError):

View File

@ -1,6 +1,7 @@
from unittest.mock import MagicMock, PropertyMock, patch
import pytest
from werkzeug.exceptions import NotFound
from controllers.console import console_ns
from controllers.console.auth.error import (
@ -29,6 +30,7 @@ from controllers.console.workspace.error import (
CurrentPasswordIncorrectError,
InvalidAccountDeletionCodeError,
)
from models.enums import CreatorUserRole
from services.errors.account import CurrentPasswordIncorrectError as ServicePwdError
@ -135,6 +137,131 @@ class TestAccountUpdateApis:
assert result["id"] == "u1"
class TestAccountAvatarApiGet:
"""GET /account/avatar must not sign arbitrary upload_file IDs (IDOR)."""
def test_get_avatar_signed_url_when_upload_owned_by_current_account(self, app):
api = AccountAvatarApi()
method = unwrap(api.get)
user = MagicMock()
user.id = "acc-owner"
tenant_id = "tenant-1"
file_id = "550e8400-e29b-41d4-a716-446655440000"
upload_file = MagicMock()
upload_file.id = file_id
upload_file.tenant_id = tenant_id
upload_file.created_by = user.id
upload_file.created_by_role = CreatorUserRole.ACCOUNT
with (
app.test_request_context(f"/account/avatar?avatar={file_id}"),
patch(
"controllers.console.workspace.account.current_account_with_tenant",
return_value=(user, tenant_id),
),
patch("controllers.console.workspace.account.db.session.scalar", return_value=upload_file),
patch(
"controllers.console.workspace.account.file_helpers.get_signed_file_url",
return_value="https://signed/example",
) as sign_mock,
):
result = method(api)
assert result == {"avatar_url": "https://signed/example"}
sign_mock.assert_called_once_with(upload_file_id=file_id)
def test_get_avatar_not_found_when_upload_created_by_other_account_same_tenant(self, app):
api = AccountAvatarApi()
method = unwrap(api.get)
user = MagicMock()
user.id = "acc-a"
tenant_id = "tenant-1"
file_id = "550e8400-e29b-41d4-a716-446655440001"
upload_file = MagicMock()
upload_file.id = file_id
upload_file.tenant_id = tenant_id
upload_file.created_by = "acc-b"
upload_file.created_by_role = CreatorUserRole.ACCOUNT
with (
app.test_request_context(f"/account/avatar?avatar={file_id}"),
patch(
"controllers.console.workspace.account.current_account_with_tenant",
return_value=(user, tenant_id),
),
patch("controllers.console.workspace.account.db.session.scalar", return_value=upload_file),
patch(
"controllers.console.workspace.account.file_helpers.get_signed_file_url",
return_value="https://signed/leak",
) as sign_mock,
):
with pytest.raises(NotFound):
method(api)
sign_mock.assert_not_called()
def test_get_avatar_not_found_when_upload_belongs_to_other_tenant(self, app):
api = AccountAvatarApi()
method = unwrap(api.get)
user = MagicMock()
user.id = "acc-owner"
tenant_id = "tenant-1"
file_id = "550e8400-e29b-41d4-a716-446655440002"
upload_file = MagicMock()
upload_file.id = file_id
upload_file.tenant_id = "tenant-other"
upload_file.created_by = user.id
upload_file.created_by_role = CreatorUserRole.ACCOUNT
with (
app.test_request_context(f"/account/avatar?avatar={file_id}"),
patch(
"controllers.console.workspace.account.current_account_with_tenant",
return_value=(user, tenant_id),
),
patch("controllers.console.workspace.account.db.session.scalar", return_value=upload_file),
patch(
"controllers.console.workspace.account.file_helpers.get_signed_file_url",
return_value="https://signed/leak",
) as sign_mock,
):
with pytest.raises(NotFound):
method(api)
sign_mock.assert_not_called()
def test_get_avatar_https_pass_through_without_signing(self, app):
api = AccountAvatarApi()
method = unwrap(api.get)
user = MagicMock()
user.id = "acc-owner"
tenant_id = "tenant-1"
external = "https://cdn.example/avatar.png"
with (
app.test_request_context(f"/account/avatar?avatar={external}"),
patch(
"controllers.console.workspace.account.current_account_with_tenant",
return_value=(user, tenant_id),
),
patch(
"controllers.console.workspace.account.file_helpers.get_signed_file_url",
return_value="https://signed/should-not-use",
) as sign_mock,
):
result = method(api)
assert result == {"avatar_url": external}
sign_mock.assert_not_called()
class TestAccountPasswordApi:
def test_password_success(self, app):
api = AccountPasswordApi()

View File

@ -171,6 +171,62 @@ class TestHitTestingApiPost:
assert passed_retrieval_model["search_method"] == "semantic_search"
assert passed_retrieval_model["top_k"] == 10
@patch("controllers.service_api.dataset.hit_testing.service_api_ns")
@patch("controllers.console.datasets.hit_testing_base.marshal")
@patch("controllers.console.datasets.hit_testing_base.HitTestingService")
@patch("controllers.console.datasets.hit_testing_base.DatasetService")
@patch("controllers.console.datasets.hit_testing_base.current_user", new_callable=lambda: Mock(spec=Account))
def test_post_preserves_retrieval_model_metadata_filtering_conditions(
self,
mock_current_user,
mock_dataset_svc,
mock_hit_svc,
mock_marshal,
mock_ns,
app,
):
"""Service API retrieval payload should not drop metadata filters."""
dataset_id = str(uuid.uuid4())
tenant_id = str(uuid.uuid4())
mock_dataset = Mock()
mock_dataset.id = dataset_id
mock_dataset_svc.get_dataset.return_value = mock_dataset
mock_dataset_svc.check_dataset_permission.return_value = None
mock_hit_svc.retrieve.return_value = {"query": "filtered query", "records": []}
mock_hit_svc.hit_testing_args_check.return_value = None
mock_marshal.return_value = []
metadata_filtering_conditions = {
"logical_operator": "and",
"conditions": [
{
"name": "category",
"comparison_operator": "is",
"value": "finance",
}
],
}
mock_ns.payload = {
"query": "filtered query",
"retrieval_model": {
"search_method": "semantic_search",
"reranking_enable": False,
"score_threshold_enabled": False,
"top_k": 4,
"metadata_filtering_conditions": metadata_filtering_conditions,
},
}
with app.test_request_context():
api = HitTestingApi()
HitTestingApi.post.__wrapped__(api, tenant_id, dataset_id)
passed_retrieval_model = mock_hit_svc.retrieve.call_args.kwargs.get("retrieval_model")
assert passed_retrieval_model is not None
assert passed_retrieval_model["metadata_filtering_conditions"] == metadata_filtering_conditions
@patch("controllers.service_api.dataset.hit_testing.service_api_ns")
@patch("controllers.console.datasets.hit_testing_base.marshal")
@patch("controllers.console.datasets.hit_testing_base.HitTestingService")

View File

@ -316,6 +316,33 @@ def test_create_batches_texts_and_skips_empty_input(vector_factory_module):
vector._vector_processor.create.assert_not_called()
def test_create_skips_empty_text_documents_before_embedding(vector_factory_module):
vector = vector_factory_module.Vector.__new__(vector_factory_module.Vector)
vector._embeddings = MagicMock()
vector._embeddings.embed_documents.return_value = [[0.1], [0.2]]
vector._vector_processor = MagicMock()
docs = [
Document(page_content="foo", metadata={"doc_id": "id-1"}),
Document(page_content="", metadata={"doc_id": "id-empty"}),
Document(page_content=" \n", metadata={"doc_id": "id-blank"}),
Document(page_content="bar", metadata={"doc_id": "id-2"}),
]
vector.create(texts=docs, request_id="r-1")
vector._embeddings.embed_documents.assert_called_once_with(["foo", "bar"])
vector._vector_processor.create.assert_called_once_with(
texts=[docs[0], docs[3]], embeddings=[[0.1], [0.2]], request_id="r-1"
)
vector._embeddings.embed_documents.reset_mock()
vector._vector_processor.create.reset_mock()
vector.create(texts=[docs[1], docs[2]])
vector._embeddings.embed_documents.assert_not_called()
vector._vector_processor.create.assert_not_called()
def test_create_multimodal_filters_missing_uploads(vector_factory_module, monkeypatch):
class _Field:
def in_(self, value):
@ -396,6 +423,48 @@ def test_add_texts_with_optional_duplicate_check(vector_factory_module):
vector._vector_processor.create.assert_called_once()
def test_add_texts_skips_empty_text_documents(vector_factory_module):
vector = vector_factory_module.Vector.__new__(vector_factory_module.Vector)
vector._embeddings = MagicMock()
vector._embeddings.embed_documents.return_value = [[0.1]]
vector._vector_processor = MagicMock()
docs = [
Document(page_content="keep", metadata={"doc_id": "id-1"}),
Document(page_content="", metadata={"doc_id": "id-empty"}),
]
vector.add_texts(docs, source="api")
vector._embeddings.embed_documents.assert_called_once_with(["keep"])
vector._vector_processor.create.assert_called_once_with(texts=[docs[0]], embeddings=[[0.1]], source="api")
vector._embeddings.embed_documents.reset_mock()
vector._vector_processor.create.reset_mock()
vector.add_texts([docs[1]])
vector._embeddings.embed_documents.assert_not_called()
vector._vector_processor.create.assert_not_called()
def test_add_texts_filters_empty_documents_before_duplicate_check(vector_factory_module):
vector = vector_factory_module.Vector.__new__(vector_factory_module.Vector)
vector._embeddings = MagicMock()
vector._embeddings.embed_documents.return_value = [[0.1]]
vector._vector_processor = MagicMock()
vector._filter_duplicate_texts = MagicMock(return_value=[])
docs = [
Document(page_content="keep", metadata={"doc_id": "id-1"}),
Document(page_content=" ", metadata={"doc_id": "id-empty"}),
]
vector.add_texts(docs, duplicate_check=True)
vector._filter_duplicate_texts.assert_called_once_with([docs[0]])
vector._embeddings.embed_documents.assert_not_called()
vector._vector_processor.create.assert_not_called()
def test_vector_delegation_methods(vector_factory_module):
vector = vector_factory_module.Vector.__new__(vector_factory_module.Vector)
vector._embeddings = MagicMock()

View File

@ -230,3 +230,64 @@ class TestExtractFilename:
"http://example.com/", 'attachment; filename="file%20with%20quotes%20%26%20encoding.txt"'
)
assert result == "file with quotes & encoding.txt"
def test_url_with_query_string(self):
"""Test that query strings are stripped from URL basename."""
result = extract_filename("http://example.com/path/file.txt?signature=abc123&expires=12345", None)
assert result == "file.txt"
def test_url_with_hash_fragment(self):
"""Test that hash fragments are stripped from URL basename."""
result = extract_filename("http://example.com/path/file.txt#section", None)
assert result == "file.txt"
def test_url_with_query_and_fragment(self):
"""Test that both query strings and hash fragments are stripped."""
result = extract_filename("http://example.com/path/file.txt?token=xyz#section", None)
assert result == "file.txt"
def test_signed_url_preserves_filename(self):
"""Test that signed URL parameters don't affect filename extraction."""
result = extract_filename(
"http://storage.example.com/bucket/documents/report.pdf?AWSAccessKeyId=xxx&Signature=yyy&Expires=12345",
None,
)
assert result == "report.pdf"
def test_percent_encoded_filename_with_query_string(self):
"""Test percent-encoded filename with query string is decoded correctly."""
result = extract_filename("http://example.com/path/my%20file.txt?download=true", None)
assert result == "my file.txt"
def test_percent_encoded_filename_with_fragment(self):
"""Test percent-encoded filename with fragment is decoded correctly."""
result = extract_filename("http://example.com/path/my%20file.txt#page=1", None)
assert result == "my file.txt"
def test_complex_percent_encoding_with_query(self):
"""Test complex percent-encoded filename with query parameters."""
result = extract_filename("http://example.com/docs/%E4%B8%AD%E6%96%87%E6%96%87%E4%BB%B6.pdf?v=1", None)
assert result == "中文文件.pdf"
def test_url_with_special_chars_in_query(self):
"""Test that special characters in query string don't affect filename."""
result = extract_filename("http://example.com/file.bin?name=test&path=/some/path", None)
assert result == "file.bin"
def test_malformed_percent_encoding_safe_fallback(self):
"""Test that malformed percent-encoding is handled safely."""
result = extract_filename("http://example.com/path/file%20name%GG.txt?x=1", None)
# %GG is invalid, should be replaced with replacement character
assert "file" in result
assert ".txt" in result
def test_empty_path_with_query_returns_none(self):
"""Test that empty path with query string returns None."""
result = extract_filename("http://example.com/?query=value", None)
assert result is None
def test_path_only_with_query_string(self):
"""Test bare path (not full URL) with query string."""
result = extract_filename("/path/to/file.txt?extra=params", None)
assert result == "file.txt"

16
api/uv.lock generated
View File

@ -1592,8 +1592,8 @@ requires-dist = [
{ name = "gevent", specifier = ">=26.4.0" },
{ name = "gevent-websocket", specifier = ">=0.10.1" },
{ name = "gmpy2", specifier = ">=2.3.0" },
{ name = "google-api-python-client", specifier = ">=2.194.0" },
{ name = "google-cloud-aiplatform", specifier = ">=1.148.1,<2.0.0" },
{ name = "google-api-python-client", specifier = ">=2.195.0" },
{ name = "google-cloud-aiplatform", specifier = ">=1.149.0,<2.0.0" },
{ name = "graphon", specifier = "~=0.2.2" },
{ name = "gunicorn", specifier = ">=25.3.0" },
{ name = "httpx", extras = ["socks"], specifier = ">=0.28.1,<1.0.0" },
@ -2719,7 +2719,7 @@ grpc = [
[[package]]
name = "google-api-python-client"
version = "2.194.0"
version = "2.195.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "google-api-core" },
@ -2728,9 +2728,9 @@ dependencies = [
{ name = "httplib2" },
{ name = "uritemplate" },
]
sdist = { url = "https://files.pythonhosted.org/packages/60/ab/e83af0eb043e4ccc49571ca7a6a49984e9d00f4e9e6e6f1238d60bc84dce/google_api_python_client-2.194.0.tar.gz", hash = "sha256:db92647bd1a90f40b79c9618461553c2b20b6a43ce7395fa6de07132dc14f023", size = 14443469, upload-time = "2026-04-08T23:07:35.757Z" }
sdist = { url = "https://files.pythonhosted.org/packages/69/07/08d759b9cb10f48af14b25262dd0d6685ca8cda6c1f9e8a8109f57457205/google_api_python_client-2.195.0.tar.gz", hash = "sha256:c72cf2661c3addf01c880ce60541e83e1df354644b874f7f9d8d5ed2070446ae", size = 14584819, upload-time = "2026-04-30T21:51:50.638Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/34/5a624e49f179aa5b0cb87b2ce8093960299030ff40423bfbde09360eb908/google_api_python_client-2.194.0-py3-none-any.whl", hash = "sha256:61eaaac3b8fc8fdf11c08af87abc3d1342d1b37319cc1b57405f86ef7697e717", size = 15016514, upload-time = "2026-04-08T23:07:33.093Z" },
{ url = "https://files.pythonhosted.org/packages/21/b9/2c71095e31fff57668fec7c07ac897df065f15521d070e63229e13689590/google_api_python_client-2.195.0-py3-none-any.whl", hash = "sha256:753e62057f23049a89534bea0162b60fe391b85fb86d80bcdf884d05ec91c5bf", size = 15162418, upload-time = "2026-04-30T21:51:47.444Z" },
]
[[package]]
@ -2766,7 +2766,7 @@ wheels = [
[[package]]
name = "google-cloud-aiplatform"
version = "1.148.1"
version = "1.149.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "docstring-parser" },
@ -2782,9 +2782,9 @@ dependencies = [
{ name = "pydantic" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/f3/b2a9417014c93858a2e3266134f931eefd972c2d410b25d7b8782fc6f143/google_cloud_aiplatform-1.148.1.tar.gz", hash = "sha256:75d605fba34e68714bd08e1e482755d0a6e3ae972805f809d088e686c30879e7", size = 10278758, upload-time = "2026-04-17T23:45:26.738Z" }
sdist = { url = "https://files.pythonhosted.org/packages/42/2c/fba4adc56f74c0ee0fbd91a39d414ca2c3588dd8b71f9be8a507015ca886/google_cloud_aiplatform-1.149.0.tar.gz", hash = "sha256:a4d73485bf1d727a9e1bbbd13d08d7031490686bbf7d125eb905c1a6c1559a35", size = 10451466, upload-time = "2026-04-27T23:11:54.513Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/56/5b/e3515d7bbba602c2b0f6a0da5431785e897252443682e4735d0e6873dc8f/google_cloud_aiplatform-1.148.1-py2.py3-none-any.whl", hash = "sha256:035101e2d8e65c6a706cc3930b2452de7ddcbde50dd130320fcea0d8b03b0c5a", size = 8434481, upload-time = "2026-04-17T23:45:22.919Z" },
{ url = "https://files.pythonhosted.org/packages/bf/a0/27719ba23967ef62e52a1d54e013e0fc174bdab8dd84fb300bab9bf0d4a3/google_cloud_aiplatform-1.149.0-py2.py3-none-any.whl", hash = "sha256:e6b5299fa5d303e971cb29a19f03fdbb7b1e3b9d2faa3a788ca933341fba2f2e", size = 8570410, upload-time = "2026-04-27T23:11:50.495Z" },
]
[[package]]

View File

@ -197,21 +197,11 @@
"count": 4
}
},
"web/app/components/app-sidebar/basic.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/app-sidebar/index.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/app-sidebar/toggle-button.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/app/annotation/add-annotation-modal/edit-item/index.tsx": {
"erasable-syntax-only/enums": {
"count": 1
@ -351,16 +341,6 @@
"count": 1
}
},
"web/app/components/app/configuration/config-vision/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/app/configuration/config-vision/param-config-content.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/app/configuration/config/agent/agent-setting/index.tsx": {
"react/set-state-in-effect": {
"count": 1
@ -445,21 +425,6 @@
"count": 2
}
},
"web/app/components/app/configuration/config/config-audio.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/app/configuration/config/config-document.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/app/configuration/dataset-config/context-var/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/app/configuration/dataset-config/index.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -470,11 +435,6 @@
"count": 1
}
},
"web/app/components/app/configuration/dataset-config/params-config/config-content.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/app/configuration/dataset-config/params-config/index.tsx": {
"no-restricted-imports": {
"count": 1
@ -1726,11 +1686,6 @@
"count": 1
}
},
"web/app/components/base/param-item/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/base/prompt-editor/index.stories.tsx": {
"no-console": {
"count": 1
@ -2047,11 +2002,6 @@
"count": 1
}
},
"web/app/components/billing/priority-label/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/billing/type.ts": {
"erasable-syntax-only/enums": {
"count": 4
@ -2077,11 +2027,6 @@
"count": 3
}
},
"web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/image-input.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/common/image-uploader/store.tsx": {
"react-refresh/only-export-components": {
"count": 3
@ -2092,11 +2037,6 @@
"count": 1
}
},
"web/app/components/datasets/common/retrieval-param-config/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/dsl-confirm-modal.tsx": {
"no-restricted-imports": {
"count": 1
@ -2115,11 +2055,6 @@
"count": 1
}
},
"web/app/components/datasets/create-from-pipeline/list/template-card/details/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/create-from-pipeline/list/template-card/details/types.ts": {
"erasable-syntax-only/enums": {
"count": 1
@ -2130,11 +2065,6 @@
"count": 1
}
},
"web/app/components/datasets/create/embedding-process/indexing-progress-item.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/create/empty-dataset-creation-modal/index.tsx": {
"no-restricted-imports": {
"count": 1
@ -2165,16 +2095,6 @@
"count": 5
}
},
"web/app/components/datasets/create/step-two/components/indexing-mode-section.tsx": {
"no-restricted-imports": {
"count": 2
}
},
"web/app/components/datasets/create/step-two/components/inputs.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/create/step-two/hooks/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 6
@ -2209,16 +2129,6 @@
"count": 1
}
},
"web/app/components/datasets/create/website/base/checkbox-with-label.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/create/website/base/field.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/create/website/firecrawl/index.tsx": {
"no-console": {
"count": 1
@ -2327,11 +2237,6 @@
"count": 4
}
},
"web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/checkbox-with-label.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/index.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -2477,11 +2382,6 @@
"count": 3
}
},
"web/app/components/datasets/documents/status-item/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/external-knowledge-base/create/ExternalApiSelect.tsx": {
"react/set-state-in-effect": {
"count": 1
@ -2565,11 +2465,6 @@
"count": 1
}
},
"web/app/components/datasets/metadata/metadata-document/info-group.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/metadata/types.ts": {
"erasable-syntax-only/enums": {
"count": 2
@ -2590,11 +2485,6 @@
"count": 1
}
},
"web/app/components/datasets/settings/summary-index-setting.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/develop/code.tsx": {
"ts/no-empty-object-type": {
"count": 1
@ -2778,9 +2668,6 @@
}
},
"web/app/components/header/account-setting/key-validator/declarations.ts": {
"erasable-syntax-only/enums": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
@ -3336,11 +3223,6 @@
"count": 1
}
},
"web/app/components/rag-pipeline/components/panel/input-field/label-right-content/global-inputs.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/index.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -3927,11 +3809,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/_base/components/field.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/_base/components/input-support-select-var.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -3975,11 +3852,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/_base/components/option-card.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/_base/components/prompt/editor.tsx": {
"ts/no-explicit-any": {
"count": 4
@ -4041,9 +3913,6 @@
}
},
"web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx": {
"no-restricted-imports": {
"count": 1
},
"react/set-state-in-effect": {
"count": 3
},
@ -4534,14 +4403,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/llm/components/config-prompt-item.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 3
}
},
"web/app/components/workflow/nodes/llm/components/config-prompt.tsx": {
"react/unsupported-syntax": {
"count": 1

View File

@ -7,6 +7,7 @@ from typing import NotRequired, TypedDict
class AdminConfig(TypedDict):
"""Configuration for admin section."""
username: str
password: str
base_url: str
@ -14,6 +15,7 @@ class AdminConfig(TypedDict):
class AuthConfig(TypedDict):
"""Configuration for authentication section."""
access_token: str
refresh_token: NotRequired[str]
expires_at: NotRequired[int]
@ -21,6 +23,7 @@ class AuthConfig(TypedDict):
class AppConfig(TypedDict):
"""Configuration for app section."""
app_id: str
app_name: NotRequired[str]
description: NotRequired[str]
@ -28,6 +31,7 @@ class AppConfig(TypedDict):
class ApiKeyConfig(TypedDict):
"""Configuration for API key section."""
token: str
key_name: NotRequired[str]
expires_at: NotRequired[int]
@ -35,6 +39,7 @@ class ApiKeyConfig(TypedDict):
class StressTestState(TypedDict):
"""Complete stress test state structure."""
admin: NotRequired[AdminConfig]
auth: NotRequired[AuthConfig]
app: NotRequired[AppConfig]

View File

@ -7,12 +7,6 @@ vi.mock('@/app/components/base/icons/src/vender/workflow', () => ({
WindowCursor: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="webapp-icon" {...props} />,
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ popupContent }: { popupContent: React.ReactNode }) => (
<div data-testid="tooltip">{popupContent}</div>
),
}))
vi.mock('../../base/app-icon', () => ({
default: ({ icon, background, innerIcon, className }: {
icon?: string
@ -75,13 +69,12 @@ describe('AppBasic', () => {
it('should show hover tip when provided', () => {
render(<AppBasic name="My App" type="Chatbot" hoverTip="Some tip" />)
expect(screen.getByTestId('tooltip')).toBeInTheDocument()
expect(screen.getByText('Some tip')).toBeInTheDocument()
expect(screen.getByLabelText('Some tip')).toBeInTheDocument()
})
it('should not show hover tip when not provided', () => {
render(<AppBasic name="My App" type="Chatbot" />)
expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument()
expect(screen.queryByLabelText('Some tip')).not.toBeInTheDocument()
})
})

View File

@ -4,7 +4,7 @@ import {
ApiAggregate,
WindowCursor,
} from '@/app/components/base/icons/src/vender/workflow'
import Tooltip from '@/app/components/base/tooltip'
import { Infotip } from '@/app/components/base/infotip'
import AppIcon from '../base/app-icon'
type IAppBasicProps = {
@ -82,16 +82,9 @@ export default function AppBasic({ icon, icon_background, name, isExternal, type
</div>
{hoverTip
&& (
<Tooltip
popupContent={(
<div className="w-[240px]">
{hoverTip}
</div>
)}
popupClassName="ml-1"
triggerClassName="w-4 h-4 ml-1"
position="top"
/>
<Infotip aria-label={hoverTip} className="ml-1" popupClassName="w-[240px]">
{hoverTip}
</Infotip>
)}
</div>
{!hideType && isExtraInLine && (

View File

@ -1,20 +1,20 @@
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '../base/tooltip'
import ShortcutsName from '../workflow/shortcuts-name'
type TooltipContentProps = {
type ToggleTooltipContentProps = {
expand: boolean
}
const TOGGLE_SHORTCUT = ['ctrl', 'B']
const TooltipContent = ({
const ToggleTooltipContent = ({
expand,
}: TooltipContentProps) => {
}: ToggleTooltipContentProps) => {
const { t } = useTranslation()
return (
@ -37,22 +37,21 @@ const ToggleButton = ({
className,
}: ToggleButtonProps) => {
return (
<Tooltip
popupContent={<TooltipContent expand={expand} />}
popupClassName="p-1.5 rounded-lg"
position="right"
>
<Button
size="small"
onClick={handleToggle}
className={cn('rounded-full px-1', className)}
<Tooltip>
<TooltipTrigger
render={(
<Button
size="small"
onClick={handleToggle}
className={cn('rounded-full px-1', className)}
/>
)}
>
{
expand
? <RiArrowLeftSLine className="size-4" />
: <RiArrowRightSLine className="size-4" />
}
</Button>
{expand ? <RiArrowLeftSLine className="size-4" /> : <RiArrowRightSLine className="size-4" />}
</TooltipTrigger>
<TooltipContent placement="right" className="rounded-lg p-1.5">
<ToggleTooltipContent expand={expand} />
</TooltipContent>
</Tooltip>
)
}

View File

@ -11,7 +11,7 @@ import { useContext } from 'use-context-selector'
// import { Resolution } from '@/types/app'
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
import { Vision } from '@/app/components/base/icons/src/vender/features'
import Tooltip from '@/app/components/base/tooltip'
import { Infotip } from '@/app/components/base/infotip'
import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
// import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card'
@ -70,13 +70,12 @@ const ConfigVision: FC = () => {
</div>
<div className="flex grow items-center">
<div className="mr-1 system-sm-semibold text-text-secondary">{t('vision.name', { ns: 'appDebug' })}</div>
<Tooltip
popupContent={(
<div className="w-[180px]">
{t('vision.description', { ns: 'appDebug' })}
</div>
)}
/>
<Infotip
aria-label={t('vision.description', { ns: 'appDebug' })}
popupClassName="w-[180px]"
>
{t('vision.description', { ns: 'appDebug' })}
</Infotip>
</div>
<div className="flex shrink-0 items-center">
{readonly
@ -84,15 +83,14 @@ const ConfigVision: FC = () => {
<>
<div className="mr-2 flex items-center gap-0.5">
<div className="system-xs-medium-uppercase text-text-tertiary">{t('vision.visionSettings.resolution', { ns: 'appDebug' })}</div>
<Tooltip
popupContent={(
<div className="w-[180px]">
{t('vision.visionSettings.resolutionTooltip', { ns: 'appDebug' }).split('\n').map(item => (
<div key={item}>{item}</div>
))}
</div>
)}
/>
<Infotip
aria-label={t('vision.visionSettings.resolutionTooltip', { ns: 'appDebug' })}
popupClassName="w-[180px]"
>
{t('vision.visionSettings.resolutionTooltip', { ns: 'appDebug' }).split('\n').map(item => (
<div key={item}>{item}</div>
))}
</Infotip>
</div>
<div className="flex items-center gap-1">
<OptionCard

View File

@ -6,8 +6,8 @@ import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
import { Infotip } from '@/app/components/base/infotip'
import ParamItem from '@/app/components/base/param-item'
import Tooltip from '@/app/components/base/tooltip'
import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card'
import { Resolution, TransferMethod } from '@/types/app'
@ -47,15 +47,14 @@ const ParamConfigContent: FC = () => {
<div>
<div className="mb-2 flex items-center space-x-1">
<div className="text-[13px] leading-[18px] font-semibold text-text-secondary">{t('vision.visionSettings.resolution', { ns: 'appDebug' })}</div>
<Tooltip
popupContent={(
<div className="w-[180px]">
{t('vision.visionSettings.resolutionTooltip', { ns: 'appDebug' }).split('\n').map(item => (
<div key={item}>{item}</div>
))}
</div>
)}
/>
<Infotip
aria-label={t('vision.visionSettings.resolutionTooltip', { ns: 'appDebug' })}
popupClassName="w-[180px]"
>
{t('vision.visionSettings.resolutionTooltip', { ns: 'appDebug' }).split('\n').map(item => (
<div key={item}>{item}</div>
))}
</Infotip>
</div>
<div className="flex items-center gap-1">
<OptionCard

View File

@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
import { Microphone01 } from '@/app/components/base/icons/src/vender/features'
import Tooltip from '@/app/components/base/tooltip'
import { Infotip } from '@/app/components/base/infotip'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import ConfigContext from '@/context/debug-configuration'
@ -57,13 +57,12 @@ const ConfigAudio: FC = () => {
</div>
<div className="flex grow items-center">
<div className="mr-1 system-sm-semibold text-text-secondary">{t('feature.audioUpload.title', { ns: 'appDebug' })}</div>
<Tooltip
popupContent={(
<div className="w-[180px]">
{t('feature.audioUpload.description', { ns: 'appDebug' })}
</div>
)}
/>
<Infotip
aria-label={t('feature.audioUpload.description', { ns: 'appDebug' })}
popupClassName="w-[180px]"
>
{t('feature.audioUpload.description', { ns: 'appDebug' })}
</Infotip>
</div>
{!readonly && (
<div className="flex shrink-0 items-center">

View File

@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
import { Document } from '@/app/components/base/icons/src/vender/features'
import Tooltip from '@/app/components/base/tooltip'
import { Infotip } from '@/app/components/base/infotip'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import ConfigContext from '@/context/debug-configuration'
@ -57,13 +57,12 @@ const ConfigDocument: FC = () => {
</div>
<div className="flex grow items-center">
<div className="mr-1 system-sm-semibold text-text-secondary">{t('feature.documentUpload.title', { ns: 'appDebug' })}</div>
<Tooltip
popupContent={(
<div className="w-[180px]">
{t('feature.documentUpload.description', { ns: 'appDebug' })}
</div>
)}
/>
<Infotip
aria-label={t('feature.documentUpload.description', { ns: 'appDebug' })}
popupClassName="w-[180px]"
>
{t('feature.documentUpload.description', { ns: 'appDebug' })}
</Infotip>
</div>
{!readonly && (
<div className="flex shrink-0 items-center">

View File

@ -275,7 +275,7 @@ describe('ContextVar', () => {
// Act
render(<ContextVar {...props} />)
const varPickerTrigger = screen.getByTestId('popover-trigger')
const varPickerTrigger = screen.getAllByTestId('popover-trigger').at(-1)!
await user.click(varPickerTrigger!)
expect(screen.getByTestId('popover-content'))!.toBeInTheDocument()
@ -296,7 +296,7 @@ describe('ContextVar', () => {
// Act
render(<ContextVar {...props} />)
const varPickerTrigger = screen.getByTestId('popover-trigger')
const varPickerTrigger = screen.getAllByTestId('popover-trigger').at(-1)!
// Open dropdown
await user.click(varPickerTrigger!)

View File

@ -5,7 +5,7 @@ import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { BracketsX } from '@/app/components/base/icons/src/vender/line/development'
import Tooltip from '@/app/components/base/tooltip'
import { Infotip } from '@/app/components/base/infotip'
import VarPicker from './var-picker'
const ContextVar: FC<Props> = (props) => {
@ -20,13 +20,12 @@ const ContextVar: FC<Props> = (props) => {
<BracketsX className="h-4 w-4 text-text-accent" />
</div>
<div className="mr-1 text-sm font-medium text-text-secondary">{t('feature.dataSet.queryVariable.title', { ns: 'appDebug' })}</div>
<Tooltip
popupContent={(
<div className="w-[180px]">
{t('feature.dataSet.queryVariable.tip', { ns: 'appDebug' })}
</div>
)}
/>
<Infotip
aria-label={t('feature.dataSet.queryVariable.tip', { ns: 'appDebug' })}
popupClassName="w-[180px]"
>
{t('feature.dataSet.queryVariable.tip', { ns: 'appDebug' })}
</Infotip>
</div>
<VarPicker {...props} />

View File

@ -15,9 +15,9 @@ import { toast } from '@langgenius/dify-ui/toast'
import { memo, useCallback, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import { Infotip } from '@/app/components/base/infotip'
import ScoreThresholdItem from '@/app/components/base/param-item/score-threshold-item'
import TopKItem from '@/app/components/base/param-item/top-k-item'
import Tooltip from '@/app/components/base/tooltip'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useCurrentProviderAndModel, useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
@ -244,15 +244,14 @@ const ConfigContent: FC<Props> = ({
onClick={() => handleRerankModeChange(option.value)}
>
<div className="truncate">{option.label}</div>
<Tooltip
popupContent={(
<div className="w-[200px]">
{option.tips}
</div>
)}
popupClassName="ml-0.5"
triggerClassName="ml-0.5 w-3.5 h-3.5"
/>
<Infotip
aria-label={option.tips}
className="ml-0.5 h-3.5 w-3.5"
iconClassName="h-3.5 w-3.5"
popupClassName="w-[200px]"
>
{option.tips}
</Infotip>
</div>
))
}
@ -273,15 +272,13 @@ const ConfigContent: FC<Props> = ({
)
}
<div className="ml-1 system-sm-semibold leading-[32px] text-text-secondary">{t('modelProvider.rerankModel.key', { ns: 'common' })}</div>
<Tooltip
popupContent={(
<div className="w-[200px]">
{t('modelProvider.rerankModel.tip', { ns: 'common' })}
</div>
)}
popupClassName="ml-1"
triggerClassName="ml-1 w-4 h-4"
/>
<Infotip
aria-label={t('modelProvider.rerankModel.tip', { ns: 'common' })}
className="ml-1"
popupClassName="w-[200px]"
>
{t('modelProvider.rerankModel.tip', { ns: 'common' })}
</Infotip>
</div>
{
showRerankModel && (
@ -363,9 +360,9 @@ const ConfigContent: FC<Props> = ({
<div className="mt-4">
<div className="flex items-center space-x-0.5">
<div className="text-[13px] leading-[32px] font-medium text-text-primary">{t('modelProvider.systemReasoningModel.key', { ns: 'common' })}</div>
<Tooltip
popupContent={t('modelProvider.systemReasoningModel.tip', { ns: 'common' })}
/>
<Infotip aria-label={t('modelProvider.systemReasoningModel.tip', { ns: 'common' })}>
{t('modelProvider.systemReasoningModel.tip', { ns: 'common' })}
</Infotip>
</div>
<ModelParameterModal
isInWorkflow={isInWorkflow}

View File

@ -48,24 +48,21 @@ describe('ThemeSelector', () => {
it('should call setTheme with light when light option is clicked', () => {
render(<ThemeSelector />)
fireEvent.click(screen.getByRole('button'))
const lightButton = screen.getByText(/light/i).closest('button')!
fireEvent.click(lightButton)
fireEvent.click(screen.getByText(/light/i))
expect(mockSetTheme).toHaveBeenCalledWith('light')
})
it('should call setTheme with dark when dark option is clicked', () => {
render(<ThemeSelector />)
fireEvent.click(screen.getByRole('button'))
const darkButton = screen.getByText(/dark/i).closest('button')!
fireEvent.click(darkButton)
fireEvent.click(screen.getByText(/dark/i))
expect(mockSetTheme).toHaveBeenCalledWith('dark')
})
it('should call setTheme with system when system option is clicked', () => {
render(<ThemeSelector />)
fireEvent.click(screen.getByRole('button'))
const systemButton = screen.getByText(/auto/i).closest('button')!
fireEvent.click(systemButton)
fireEvent.click(screen.getByText(/auto/i))
expect(mockSetTheme).toHaveBeenCalledWith('system')
})
})

View File

@ -8,6 +8,8 @@ type CheckboxProps = {
className?: string
disabled?: boolean
indeterminate?: boolean
ariaLabel?: string
ariaLabelledBy?: string
}
const Checkbox = ({
@ -17,6 +19,8 @@ const Checkbox = ({
className,
disabled,
indeterminate,
ariaLabel,
ariaLabelledBy,
}: CheckboxProps) => {
const checkClassName = (checked || indeterminate)
? 'bg-components-checkbox-bg text-components-checkbox-icon hover:bg-components-checkbox-bg-hover'
@ -52,6 +56,8 @@ const Checkbox = ({
role="checkbox"
aria-checked={indeterminate ? 'mixed' : !!checked}
aria-disabled={!!disabled}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
tabIndex={disabled ? -1 : 0}
>
{!checked && indeterminate && <IndeterminateIcon />}

View File

@ -27,17 +27,15 @@ describe('ParamItem', () => {
})
it('should render a tooltip trigger by default', () => {
const { container } = render(<ParamItem {...defaultProps} tip="Some tip text" />)
render(<ParamItem {...defaultProps} tip="Some tip text" />)
// Tooltip trigger icon should be rendered (the data-state div)
expect(container.querySelector('[data-state]')).toBeInTheDocument()
expect(screen.getByLabelText('Some tip text')).toBeInTheDocument()
})
it('should not render tooltip trigger when noTooltip is true', () => {
const { container } = render(<ParamItem {...defaultProps} noTooltip tip="Hidden tip" />)
render(<ParamItem {...defaultProps} noTooltip tip="Hidden tip" />)
// No tooltip trigger icon should be rendered
expect(container.querySelector('[data-state]')).not.toBeInTheDocument()
expect(screen.queryByLabelText('Hidden tip')).not.toBeInTheDocument()
})
it('should render a switch when hasSwitch is true', () => {

View File

@ -24,10 +24,9 @@ describe('ScoreThresholdItem', () => {
})
it('should render tooltip trigger', () => {
const { container } = render(<ScoreThresholdItem {...defaultProps} />)
render(<ScoreThresholdItem {...defaultProps} />)
// Tooltip trigger icon should be rendered
expect(container.querySelector('[data-state]')).toBeInTheDocument()
expect(screen.getByLabelText('appDebug.datasetConfig.score_thresholdTip')).toBeInTheDocument()
})
it('should render InputNumber and Slider', () => {

View File

@ -29,10 +29,9 @@ describe('TopKItem', () => {
})
it('should render tooltip trigger', () => {
const { container } = render(<TopKItem {...defaultProps} />)
render(<TopKItem {...defaultProps} />)
// Tooltip trigger icon should be rendered
expect(container.querySelector('[data-state]')).toBeInTheDocument()
expect(screen.getByLabelText('appDebug.datasetConfig.top_kTip')).toBeInTheDocument()
})
it('should render InputNumber and Slider', () => {

View File

@ -10,7 +10,7 @@ import {
} from '@langgenius/dify-ui/number-field'
import { Slider } from '@langgenius/dify-ui/slider'
import { Switch } from '@langgenius/dify-ui/switch'
import Tooltip from '@/app/components/base/tooltip'
import { Infotip } from '@/app/components/base/infotip'
type Props = {
className?: string
@ -44,11 +44,10 @@ const ParamItem: FC<Props> = ({ className, id, name, noTooltip, tip, step = 0.1,
/>
)}
<span className="mr-1 system-sm-semibold text-text-secondary">{name}</span>
{!noTooltip && (
<Tooltip
triggerClassName="w-4 h-4 shrink-0"
popupContent={<div className="w-[200px]">{tip}</div>}
/>
{!noTooltip && tip && (
<Infotip aria-label={tip} popupClassName="w-[200px]">
{tip}
</Infotip>
)}
</div>
</div>

View File

@ -1,25 +1,25 @@
'use client'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuRadioItemIndicator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useTheme } from 'next-themes'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
export type Theme = 'light' | 'dark' | 'system'
export default function ThemeSelector() {
const { t } = useTranslation()
const { theme, setTheme } = useTheme()
const [open, setOpen] = useState(false)
const handleThemeChange = (newTheme: Theme) => {
setTheme(newTheme)
setOpen(false)
}
const getCurrentIcon = () => {
@ -31,70 +31,36 @@ export default function ThemeSelector() {
}
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-end"
offset={{ mainAxis: 6 }}
>
<PortalToFollowElemTrigger
onClick={() => setOpen(!open)}
<DropdownMenu>
<DropdownMenuTrigger
render={(
<ActionButton
aria-label={t('theme.theme', { ns: 'common' })}
className="h-8 w-8 p-[6px] data-popup-open:bg-state-base-hover"
/>
)}
>
<ActionButton
className={`h-8 w-8 p-[6px] ${open && 'bg-state-base-hover'}`}
>
{getCurrentIcon()}
</ActionButton>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1000">
<div className="flex w-[144px] flex-col items-start rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg">
<button
type="button"
className="flex w-full items-center gap-1 rounded-lg px-2 py-1.5 text-text-secondary hover:bg-state-base-hover"
onClick={() => handleThemeChange('light')}
>
{getCurrentIcon()}
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-end" sideOffset={6} popupClassName="w-[144px]">
<DropdownMenuRadioGroup value={theme || 'system'} onValueChange={value => handleThemeChange(value as Theme)}>
<DropdownMenuRadioItem value="light" closeOnClick>
<span className="i-ri-sun-line h-4 w-4 text-text-tertiary" />
<div className="flex grow items-center justify-start px-1">
<span className="system-md-regular">{t('theme.light', { ns: 'common' })}</span>
</div>
{theme === 'light' && (
<div className="flex h-4 w-4 shrink-0 items-center justify-center">
<span className="i-ri-check-line h-4 w-4 text-text-accent" data-testid="light-icon" />
</div>
)}
</button>
<button
type="button"
className="flex w-full items-center gap-1 rounded-lg px-2 py-1.5 text-text-secondary hover:bg-state-base-hover"
onClick={() => handleThemeChange('dark')}
>
<span className="grow px-1 system-md-regular">{t('theme.light', { ns: 'common' })}</span>
<DropdownMenuRadioItemIndicator data-testid="light-icon" />
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="dark" closeOnClick>
<span className="i-ri-moon-line h-4 w-4 text-text-tertiary" />
<div className="flex grow items-center justify-start px-1">
<span className="system-md-regular">{t('theme.dark', { ns: 'common' })}</span>
</div>
{theme === 'dark' && (
<div className="flex h-4 w-4 shrink-0 items-center justify-center">
<span className="i-ri-check-line h-4 w-4 text-text-accent" data-testid="dark-icon" />
</div>
)}
</button>
<button
type="button"
className="flex w-full items-center gap-1 rounded-lg px-2 py-1.5 text-text-secondary hover:bg-state-base-hover"
onClick={() => handleThemeChange('system')}
>
<span className="grow px-1 system-md-regular">{t('theme.dark', { ns: 'common' })}</span>
<DropdownMenuRadioItemIndicator data-testid="dark-icon" />
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="system" closeOnClick>
<span className="i-ri-computer-line h-4 w-4 text-text-tertiary" />
<div className="flex grow items-center justify-start px-1">
<span className="system-md-regular">{t('theme.auto', { ns: 'common' })}</span>
</div>
{theme === 'system' && (
<div className="flex h-4 w-4 shrink-0 items-center justify-center">
<span className="i-ri-check-line h-4 w-4 text-text-accent" data-testid="system-icon" />
</div>
)}
</button>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
<span className="grow px-1 system-md-regular">{t('theme.auto', { ns: 'common' })}</span>
<DropdownMenuRadioItemIndicator data-testid="system-icon" />
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -1,5 +1,6 @@
import type { Mock } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import { TooltipProvider } from '@langgenius/dify-ui/tooltip'
import { render, screen } from '@testing-library/react'
import { createMockPlan } from '@/__mocks__/provider-context'
import { useProviderContext } from '@/context/provider-context'
import { Plan } from '../../type'
@ -15,6 +16,14 @@ const setupPlan = (planType: Plan) => {
useProviderContextMock.mockReturnValue(createMockPlan(planType))
}
const renderPriorityLabel = (className?: string) => {
return render(
<TooltipProvider delay={0} closeDelay={0}>
<PriorityLabel className={className} />
</TooltipProvider>,
)
}
describe('PriorityLabel', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -24,7 +33,7 @@ describe('PriorityLabel', () => {
it('should render the standard priority label when plan is sandbox', () => {
setupPlan(Plan.sandbox)
render(<PriorityLabel />)
renderPriorityLabel()
expect(screen.getByText('billing.plansCommon.priority.standard')).toBeInTheDocument()
})
@ -35,7 +44,7 @@ describe('PriorityLabel', () => {
it('should apply custom className to the label container', () => {
setupPlan(Plan.sandbox)
render(<PriorityLabel className="custom-class" />)
renderPriorityLabel('custom-class')
const label = screen.getByText('billing.plansCommon.priority.standard').closest('div')
expect(label).toHaveClass('custom-class')
@ -47,7 +56,7 @@ describe('PriorityLabel', () => {
it('should render priority label and icon when plan is professional', () => {
setupPlan(Plan.professional)
const { container } = render(<PriorityLabel />)
const { container } = renderPriorityLabel()
expect(screen.getByText('billing.plansCommon.priority.priority')).toBeInTheDocument()
expect(container.querySelector('svg')).toBeInTheDocument()
@ -56,7 +65,7 @@ describe('PriorityLabel', () => {
it('should render top priority label and icon when plan is team', () => {
setupPlan(Plan.team)
const { container } = render(<PriorityLabel />)
const { container } = renderPriorityLabel()
expect(screen.getByText('billing.plansCommon.priority.top-priority')).toBeInTheDocument()
expect(container.querySelector('svg')).toBeInTheDocument()
@ -65,7 +74,7 @@ describe('PriorityLabel', () => {
it('should render standard label without icon when plan is sandbox', () => {
setupPlan(Plan.sandbox)
const { container } = render(<PriorityLabel />)
const { container } = renderPriorityLabel()
expect(screen.getByText('billing.plansCommon.priority.standard')).toBeInTheDocument()
expect(container.querySelector('svg')).not.toBeInTheDocument()
@ -77,7 +86,7 @@ describe('PriorityLabel', () => {
it('should render top-priority label with icon for enterprise plan', () => {
setupPlan(Plan.enterprise)
const { container } = render(<PriorityLabel />)
const { container } = renderPriorityLabel()
expect(screen.getByText('billing.plansCommon.priority.top-priority')).toBeInTheDocument()
expect(container.querySelector('svg')).toBeInTheDocument()
@ -85,29 +94,21 @@ describe('PriorityLabel', () => {
})
describe('Edge Cases', () => {
it('should show the tip text when priority is not top priority', async () => {
it('should render a non-top priority trigger without mounting tooltip content by default', () => {
setupPlan(Plan.sandbox)
render(<PriorityLabel />)
const label = screen.getByText('billing.plansCommon.priority.standard').closest('div')
fireEvent.mouseEnter(label as HTMLElement)
renderPriorityLabel()
expect(await screen.findByText(
'billing.plansCommon.documentProcessingPriority: billing.plansCommon.priority.standard',
)).toBeInTheDocument()
expect(screen.getByText('billing.plansCommon.documentProcessingPriorityTip')).toBeInTheDocument()
expect(screen.getByText('billing.plansCommon.priority.standard')).toBeInTheDocument()
expect(screen.queryByText('billing.plansCommon.documentProcessingPriority')).not.toBeInTheDocument()
})
it('should hide the tip text when priority is top priority', async () => {
it('should render a top priority trigger without mounting upgrade tip by default', () => {
setupPlan(Plan.enterprise)
render(<PriorityLabel />)
const label = screen.getByText('billing.plansCommon.priority.top-priority').closest('div')
fireEvent.mouseEnter(label as HTMLElement)
renderPriorityLabel()
expect(await screen.findByText(
'billing.plansCommon.documentProcessingPriority: billing.plansCommon.priority.top-priority',
)).toBeInTheDocument()
expect(screen.getByText('billing.plansCommon.priority.top-priority')).toBeInTheDocument()
expect(screen.queryByText('billing.plansCommon.documentProcessingPriorityTip')).not.toBeInTheDocument()
})
})

View File

@ -1,8 +1,8 @@
import { cn } from '@langgenius/dify-ui/cn'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { RiAedFill } from '@remixicon/react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
import { useProviderContext } from '@/context/provider-context'
import {
DocumentProcessingPriority,
@ -31,8 +31,25 @@ const PriorityLabel = ({ className }: PriorityLabelProps) => {
}, [plan])
return (
<Tooltip popupContent={(
<div>
<Tooltip>
<TooltipTrigger
render={(
<div
className={cn(
'ml-1 inline-flex h-[18px] shrink-0 items-center rounded-[5px] border border-text-accent-secondary bg-components-badge-bg-dimm px-[5px] system-2xs-medium text-text-accent-secondary',
className,
)}
/>
)}
>
{
(plan.type === Plan.professional || plan.type === Plan.team || plan.type === Plan.enterprise) && (
<RiAedFill className="mr-0.5 size-3" />
)
}
<span>{t(`plansCommon.priority.${priority}`, { ns: 'billing' })}</span>
</TooltipTrigger>
<TooltipContent>
<div className="mb-1 text-xs font-semibold text-text-primary">
{t('plansCommon.documentProcessingPriority', { ns: 'billing' })}
:
@ -44,22 +61,7 @@ const PriorityLabel = ({ className }: PriorityLabelProps) => {
<div className="text-xs text-text-secondary">{t('plansCommon.documentProcessingPriorityTip', { ns: 'billing' })}</div>
)
}
</div>
)}
>
<div
className={cn(
'ml-1 inline-flex h-[18px] shrink-0 items-center rounded-[5px] border border-text-accent-secondary bg-components-badge-bg-dimm px-[5px] system-2xs-medium text-text-accent-secondary',
className,
)}
>
{
(plan.type === Plan.professional || plan.type === Plan.team || plan.type === Plan.enterprise) && (
<RiAedFill className="mr-0.5 size-3" />
)
}
<span>{t(`plansCommon.priority.${priority}`, { ns: 'billing' })}</span>
</div>
</TooltipContent>
</Tooltip>
)
}

View File

@ -1,7 +1,7 @@
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { RiImageAddLine } from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
import { ACCEPT_TYPES } from '../constants'
import { useUpload } from '../hooks/use-upload'
import { useFileStoreWithSelector } from '../store'
@ -29,20 +29,14 @@ const ImageUploader = () => {
onChange={fileChangeHandle}
/>
<div className="flex flex-wrap gap-1">
<Tooltip
popupContent={t('imageUploader.tooltip', {
ns: 'datasetHitTesting',
size: fileUploadConfig.imageFileSizeLimit,
batchCount: fileUploadConfig.imageFileBatchLimit,
})}
popupClassName="system-xs-medium p-1.5 rounded-lg text-text-secondary"
position="top"
offset={4}
disabled={files.length === 0}
>
<div
className="group flex cursor-pointer items-center gap-x-2"
onClick={selectHandle}
<Tooltip disabled={files.length === 0}>
<TooltipTrigger
render={(
<div
className="group flex cursor-pointer items-center gap-x-2"
onClick={selectHandle}
/>
)}
>
<div className="flex size-8 items-center justify-center rounded-lg border border-dashed border-components-dropzone-border bg-components-button-tertiary-bg group-hover:bg-components-button-tertiary-bg-hover">
<RiImageAddLine className="size-4 text-text-tertiary" />
@ -56,7 +50,14 @@ const ImageUploader = () => {
})}
</span>
)}
</div>
</TooltipTrigger>
<TooltipContent sideOffset={4} className="rounded-lg p-1.5 system-xs-medium text-text-secondary">
{t('imageUploader.tooltip', {
ns: 'datasetHitTesting',
size: fileUploadConfig.imageFileSizeLimit,
batchCount: fileUploadConfig.imageFileBatchLimit,
})}
</TooltipContent>
</Tooltip>
</div>
</div>

View File

@ -134,12 +134,6 @@ vi.mock('@langgenius/dify-ui/switch', () => ({
),
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ popupContent }: { popupContent: React.ReactNode }) => (
<div data-testid="tooltip">{popupContent}</div>
),
}))
describe('RetrievalParamConfig', () => {
const createDefaultConfig = (overrides?: Partial<RetrievalConfig>): RetrievalConfig => ({
search_method: RETRIEVE_METHOD.semantic,
@ -799,7 +793,7 @@ describe('RetrievalParamConfig', () => {
/>,
)
expect(screen.getByTestId('tooltip'))!.toBeInTheDocument()
expect(screen.getByLabelText('common.modelProvider.rerankModel.tip'))!.toBeInTheDocument()
})
})

View File

@ -10,10 +10,10 @@ import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import WeightedScore from '@/app/components/app/configuration/dataset-config/params-config/weighted-score'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
import { Infotip } from '@/app/components/base/infotip'
import ScoreThresholdItem from '@/app/components/base/param-item/score-threshold-item'
import TopKItem from '@/app/components/base/param-item/top-k-item'
import RadioCard from '@/app/components/base/radio-card'
import Tooltip from '@/app/components/base/tooltip'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useCurrentProviderAndModel, useModelListAndDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
@ -127,11 +127,12 @@ const RetrievalParamConfig: FC<Props> = ({
)}
<div className="flex items-center">
<span className="mr-0.5 system-sm-semibold text-text-secondary">{t('modelProvider.rerankModel.key', { ns: 'common' })}</span>
<Tooltip
popupContent={
<div className="w-[200px]">{t('modelProvider.rerankModel.tip', { ns: 'common' })}</div>
}
/>
<Infotip
aria-label={t('modelProvider.rerankModel.tip', { ns: 'common' })}
popupClassName="w-[200px]"
>
{t('modelProvider.rerankModel.tip', { ns: 'common' })}
</Infotip>
</div>
</div>
{

View File

@ -5,8 +5,8 @@ import * as React from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import { Infotip } from '@/app/components/base/infotip'
import Loading from '@/app/components/base/loading'
import Tooltip from '@/app/components/base/tooltip'
import WorkflowPreview from '@/app/components/workflow/workflow-preview'
import { usePipelineTemplateById } from '@/service/use-pipeline'
import ChunkStructureCard from './chunk-structure-card'
@ -111,10 +111,12 @@ const Details = ({
<span className="system-sm-semibold-uppercase text-text-secondary">
{t('details.structure', { ns: 'datasetPipeline' })}
</span>
<Tooltip
<Infotip
aria-label={t('details.structureTooltip', { ns: 'datasetPipeline' })}
popupClassName="max-w-[240px]"
popupContent={t('details.structureTooltip', { ns: 'datasetPipeline' })}
/>
>
{t('details.structureTooltip', { ns: 'datasetPipeline' })}
</Infotip>
</div>
<ChunkStructureCard {...chunkStructureConfig[pipelineTemplateInfo.chunk_structure]} />
</div>

View File

@ -1,4 +1,3 @@
import type { ReactNode } from 'react'
import type { IndexingStatusResponse } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@ -14,12 +13,6 @@ vi.mock('../../../common/document-file-icon', () => ({
vi.mock('@/app/components/base/notion-icon', () => ({
default: ({ src }: { src?: string }) => <span data-testid="notion-icon">{src}</span>,
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children, popupContent }: { children?: ReactNode, popupContent?: ReactNode }) => (
<div data-testid="tooltip" data-content={popupContent}>{children}</div>
),
}))
describe('IndexingProgressItem', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -100,7 +93,7 @@ describe('IndexingProgressItem', () => {
/>,
)
expect(screen.getByTestId('tooltip')).toHaveAttribute('data-content', 'Parse failed')
expect(screen.getByLabelText('Parse failed')).toBeInTheDocument()
})
it('should show priority label when billing is enabled', () => {

View File

@ -1,12 +1,12 @@
import type { FC } from 'react'
import type { IndexingStatusResponse } from '@/models/datasets'
import { cn } from '@langgenius/dify-ui/cn'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import {
RiCheckboxCircleFill,
RiErrorWarningFill,
} from '@remixicon/react'
import NotionIcon from '@/app/components/base/notion-icon'
import Tooltip from '@/app/components/base/tooltip'
import PriorityLabel from '@/app/components/billing/priority-label'
import { DataSourceType } from '@/models/datasets'
import DocumentFileIcon from '../../common/document-file-icon'
@ -27,14 +27,16 @@ const StatusIcon: FC<{ status: string, error?: string }> = ({ status, error }) =
if (status === 'error') {
return (
<Tooltip
popupClassName="px-4 py-[14px] max-w-60 body-xs-regular text-text-secondary border-[0.5px] border-components-panel-border rounded-xl"
offset={4}
popupContent={error}
>
<span>
<Tooltip>
<TooltipTrigger render={<span aria-label={error || 'Error'} />}>
<RiErrorWarningFill className="size-4 shrink-0 text-text-destructive" />
</span>
</TooltipTrigger>
<TooltipContent
sideOffset={4}
className="max-w-60 rounded-xl border-[0.5px] border-components-panel-border px-4 py-[14px] body-xs-regular text-text-secondary"
>
{error}
</TooltipContent>
</Tooltip>
)
}

View File

@ -165,6 +165,13 @@ describe('IndexingModeSection', () => {
const economicalText = screen.getByText(`${ns}.stepTwo.economical`)
const card = economicalText.closest('[class*="rounded-xl"]')
expect(card)!.toHaveClass('pointer-events-none')
expect(screen.getByText(`${ns}.stepTwo.notAvailableForQA`))!.toBeInTheDocument()
})
it('should show parent-child disabled reason inline on economical option', () => {
render(<IndexingModeSection {...defaultProps} docForm={ChunkingMode.parentChild} />)
expect(screen.getByText(`${ns}.stepTwo.notAvailableForParentChild`))!.toBeInTheDocument()
})
})

View File

@ -30,9 +30,7 @@ describe('DelimiterInput', () => {
it('should render tooltip content', () => {
render(<DelimiterInput />)
// Tooltip triggers render; component mounts without error
// Tooltip triggers render; component mounts without error
expect(screen.getByText(`${ns}.stepTwo.separator`))!.toBeInTheDocument()
expect(screen.getByLabelText(`${ns}.stepTwo.separatorTip`))!.toBeInTheDocument()
})
it('should suppress onChange during IME composition', () => {
@ -103,6 +101,7 @@ describe('OverlapInput', () => {
it('should render overlap label', () => {
render(<OverlapInput onChange={vi.fn()} />)
expect(screen.getAllByText(new RegExp(`${ns}.stepTwo.overlap`)).length).toBeGreaterThan(0)
expect(screen.getByLabelText(`${ns}.stepTwo.overlapTip`))!.toBeInTheDocument()
})
it('should render number input', () => {

View File

@ -3,14 +3,20 @@
import type { FC } from 'react'
import type { DefaultModel, Model } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { RetrievalConfig } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
import CustomDialog from '@/app/components/base/dialog'
import Divider from '@/app/components/base/divider'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
import Tooltip from '@/app/components/base/tooltip'
import EconomicalRetrievalMethodConfig from '@/app/components/datasets/common/economical-retrieval-method-config'
import RetrievalMethodConfig from '@/app/components/datasets/common/retrieval-method-config'
import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
@ -65,6 +71,13 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
const docLink = useDocLink()
const getIndexingTechnique = () => indexType
const economicalDisabledReason = (() => {
if (docForm === ChunkingMode.qa)
return t('stepTwo.notAvailableForQA', { ns: 'datasetCreation' })
if (docForm !== ChunkingMode.text)
return t('stepTwo.notAvailableForParentChild', { ns: 'datasetCreation' })
})()
return (
<>
@ -72,7 +85,33 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
<div className="mb-1 system-md-semibold text-text-secondary">
{t('stepTwo.indexMode', { ns: 'datasetCreation' })}
</div>
<div className="flex items-center gap-2">
<AlertDialog
open={isQAConfirmDialogOpen}
onOpenChange={(open) => {
if (!open)
onQAConfirmDialogClose()
}}
>
<AlertDialogContent className="w-[432px]">
<div className="flex flex-col gap-2 p-6 pb-4">
<AlertDialogTitle className="text-lg leading-7 font-semibold text-text-primary">
{t('stepTwo.qaSwitchHighQualityTipTitle', { ns: 'datasetCreation' })}
</AlertDialogTitle>
<AlertDialogDescription className="text-sm leading-5 text-text-secondary">
{t('stepTwo.qaSwitchHighQualityTipContent', { ns: 'datasetCreation' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton variant="secondary">
{t('stepTwo.cancel', { ns: 'datasetCreation' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton tone="default" onClick={onQAConfirmDialogConfirm}>
{t('stepTwo.switch', { ns: 'datasetCreation' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
<div className="flex items-stretch gap-2">
{/* Qualified option */}
{(!hasSetIndexType || (hasSetIndexType && indexType === IndexingType.QUALIFIED)) && (
<OptionCard
@ -106,49 +145,15 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
{/* Economical option */}
{(!hasSetIndexType || (hasSetIndexType && indexType === IndexingType.ECONOMICAL)) && (
<>
<CustomDialog show={isQAConfirmDialogOpen} onClose={onQAConfirmDialogClose} className="w-[432px]">
<header className="mb-4 pt-6">
<h2 className="text-lg font-semibold text-text-primary">
{t('stepTwo.qaSwitchHighQualityTipTitle', { ns: 'datasetCreation' })}
</h2>
<p className="mt-2 text-sm font-normal text-text-secondary">
{t('stepTwo.qaSwitchHighQualityTipContent', { ns: 'datasetCreation' })}
</p>
</header>
<div className="flex gap-2 pb-6">
<Button className="ml-auto" onClick={onQAConfirmDialogClose}>
{t('stepTwo.cancel', { ns: 'datasetCreation' })}
</Button>
<Button variant="primary" onClick={onQAConfirmDialogConfirm}>
{t('stepTwo.switch', { ns: 'datasetCreation' })}
</Button>
</div>
</CustomDialog>
<Tooltip
popupContent={(
<div className="rounded-lg border-components-panel-border bg-components-tooltip-bg p-3 text-xs font-medium text-text-secondary shadow-lg">
{docForm === ChunkingMode.qa
? t('stepTwo.notAvailableForQA', { ns: 'datasetCreation' })
: t('stepTwo.notAvailableForParentChild', { ns: 'datasetCreation' })}
</div>
)}
noDecoration
position="top"
asChild={false}
triggerClassName="flex-1 self-stretch"
>
<OptionCard
className="h-full"
title={t('stepTwo.economical', { ns: 'datasetCreation' })}
description={t('stepTwo.economicalTip', { ns: 'datasetCreation' })}
icon={<img src={indexMethodIcon.economical} alt="" />}
isActive={!hasSetIndexType && indexType === IndexingType.ECONOMICAL}
disabled={hasSetIndexType || docForm !== ChunkingMode.text}
onSwitched={() => onIndexTypeChange(IndexingType.ECONOMICAL)}
/>
</Tooltip>
</>
<OptionCard
className="flex-1 self-stretch"
title={t('stepTwo.economical', { ns: 'datasetCreation' })}
description={economicalDisabledReason || t('stepTwo.economicalTip', { ns: 'datasetCreation' })}
icon={<img src={indexMethodIcon.economical} alt="" />}
isActive={!hasSetIndexType && indexType === IndexingType.ECONOMICAL}
disabled={hasSetIndexType || !!economicalDisabledReason}
onSwitched={() => onIndexTypeChange(IndexingType.ECONOMICAL)}
/>
)}
</div>

View File

@ -12,8 +12,8 @@ import {
} from '@langgenius/dify-ui/number-field'
import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Infotip } from '@/app/components/base/infotip'
import Input from '@/app/components/base/input'
import Tooltip from '@/app/components/base/tooltip'
import { env } from '@/env'
const TextLabel: FC<PropsWithChildren> = (props) => {
@ -38,13 +38,9 @@ export const DelimiterInput: FC<InputProps & { tooltip?: string }> = ({ tooltip,
<FormField label={(
<div className="mb-1 flex items-center">
<span className="mr-0.5 system-sm-semibold">{t('stepTwo.separator', { ns: 'datasetCreation' })}</span>
<Tooltip
popupContent={(
<div className="max-w-[200px]">
{tooltip || t('stepTwo.separatorTip', { ns: 'datasetCreation' })}
</div>
)}
/>
<Infotip aria-label={tooltip || t('stepTwo.separatorTip', { ns: 'datasetCreation' })} popupClassName="max-w-[200px]">
{tooltip || t('stepTwo.separatorTip', { ns: 'datasetCreation' })}
</Infotip>
</div>
)}
>
@ -154,13 +150,9 @@ export const OverlapInput: FC<CompoundNumberInputProps> = (props) => {
<FormField label={(
<div className="mb-1 flex items-center">
<span className="system-sm-semibold">{t('stepTwo.overlap', { ns: 'datasetCreation' })}</span>
<Tooltip
popupContent={(
<div className="max-w-[200px]">
{t('stepTwo.overlapTip', { ns: 'datasetCreation' })}
</div>
)}
/>
<Infotip aria-label={t('stepTwo.overlapTip', { ns: 'datasetCreation' })} popupClassName="max-w-[200px]">
{t('stepTwo.overlapTip', { ns: 'datasetCreation' })}
</Infotip>
</div>
)}
>

View File

@ -20,7 +20,7 @@ type OptionCardHeaderProps = {
export const OptionCardHeader: FC<OptionCardHeaderProps> = (props) => {
const { icon, title, description, isActive, activeClassName, effectImg, disabled } = props
return (
<div className={cn('relative flex h-full overflow-hidden rounded-t-xl', isActive && activeClassName, !disabled && 'cursor-pointer')}>
<div className={cn('relative flex flex-1 overflow-hidden rounded-t-xl', isActive && activeClassName, !disabled && 'cursor-pointer')}>
<div className="relative flex size-14 items-center justify-center overflow-hidden">
{isActive && effectImg && <img src={effectImg} className="absolute top-0 left-0 h-full w-full" alt="" width={56} height={56} />}
<div className="p-1">
@ -63,7 +63,7 @@ export const OptionCard: FC<OptionCardProps> = (
const { icon, className, title, description, isActive, children, actions, activeHeaderClassName, style, effectImg, onSwitched, noHighlight, disabled, ...rest } = props
return (
<div
className={cn('rounded-xl bg-components-option-card-option-bg shadow-xs', (isActive && !noHighlight)
className={cn('flex flex-col rounded-xl bg-components-option-card-option-bg shadow-xs', (isActive && !noHighlight)
? 'border-[1.5px] border-components-option-card-option-selected-border'
: 'border border-components-option-card-option-border', disabled && 'pointer-events-none opacity-50', className)}
style={{

View File

@ -2,10 +2,6 @@ import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import CheckboxWithLabel from '../checkbox-with-label'
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ popupContent }: { popupContent?: React.ReactNode }) => <div data-testid="tooltip">{popupContent}</div>,
}))
describe('CheckboxWithLabel', () => {
const onChange = vi.fn()
@ -27,12 +23,12 @@ describe('CheckboxWithLabel', () => {
tooltip="Help text"
/>,
)
expect(screen.getByTestId('tooltip')).toBeInTheDocument()
expect(screen.getByLabelText('Help text')).toBeInTheDocument()
})
it('should not render tooltip when not provided', () => {
render(<CheckboxWithLabel isChecked={false} onChange={onChange} label="Option" />)
expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument()
expect(screen.queryByLabelText('Help text')).not.toBeInTheDocument()
})
it('should toggle checked state on checkbox click', () => {

View File

@ -2,10 +2,6 @@ import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Field from '../field'
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ popupContent }: { popupContent?: React.ReactNode }) => <div data-testid="tooltip">{popupContent}</div>,
}))
describe('WebsiteField', () => {
const onChange = vi.fn()
@ -30,7 +26,7 @@ describe('WebsiteField', () => {
it('should render tooltip when provided', () => {
render(<Field label="URL" value="" onChange={onChange} tooltip="Enter full URL" />)
expect(screen.getByTestId('tooltip')).toBeInTheDocument()
expect(screen.getByLabelText('Enter full URL')).toBeInTheDocument()
})
it('should pass value and onChange to Input', () => {

View File

@ -2,8 +2,9 @@
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import { useId } from 'react'
import Checkbox from '@/app/components/base/checkbox'
import Tooltip from '@/app/components/base/tooltip'
import { Infotip } from '@/app/components/base/infotip'
type Props = {
className?: string
@ -24,19 +25,28 @@ const CheckboxWithLabel: FC<Props> = ({
tooltip,
testId,
}) => {
const labelId = useId()
const handleToggle = () => onChange(!isChecked)
return (
<label className={cn(className, 'flex h-7 items-center space-x-2')}>
<Checkbox checked={isChecked} onCheck={() => onChange(!isChecked)} id={testId} />
<div className={cn('text-sm font-normal text-text-secondary', labelClassName)}>{label}</div>
{tooltip && (
<Tooltip
popupContent={
<div className="w-[200px]">{tooltip}</div>
}
triggerClassName="ml-0.5 w-4 h-4"
/>
)}
</label>
<div className={cn(className, 'flex h-7 items-center')}>
<Checkbox checked={isChecked} onCheck={handleToggle} id={testId} ariaLabelledBy={labelId} />
<div className="ml-2 flex min-w-0 items-center gap-1">
<button
type="button"
id={labelId}
className={cn('min-w-0 cursor-pointer border-0 bg-transparent p-0 text-left text-sm font-normal text-text-secondary', labelClassName)}
onClick={handleToggle}
>
{label}
</button>
{tooltip && (
<Infotip aria-label={tooltip} popupClassName="w-[200px]">
{tooltip}
</Infotip>
)}
</div>
</div>
)
}
export default React.memo(CheckboxWithLabel)

View File

@ -2,7 +2,7 @@
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import Tooltip from '@/app/components/base/tooltip'
import { Infotip } from '@/app/components/base/infotip'
import Input from './input'
type Props = {
@ -37,12 +37,9 @@ const Field: FC<Props> = ({
</div>
{isRequired && <span className="ml-0.5 text-xs font-semibold text-text-destructive">*</span>}
{tooltip && (
<Tooltip
popupContent={
<div className="w-[200px]">{tooltip}</div>
}
triggerClassName="ml-0.5 w-4 h-4"
/>
<Infotip aria-label={tooltip} className="ml-0.5" popupClassName="w-[200px]">
{tooltip}
</Infotip>
)}
</div>
<Input

View File

@ -8,10 +8,6 @@ vi.mock('@/app/components/base/checkbox', () => ({
),
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ popupContent }: { popupContent: string }) => <div data-testid="tooltip">{popupContent}</div>,
}))
describe('CheckboxWithLabel', () => {
const defaultProps = {
isChecked: false,
@ -35,12 +31,12 @@ describe('CheckboxWithLabel', () => {
it('should render tooltip when provided', () => {
render(<CheckboxWithLabel {...defaultProps} tooltip="Help text" />)
expect(screen.getByTestId('tooltip')).toBeInTheDocument()
expect(screen.getByLabelText('Help text')).toBeInTheDocument()
})
it('should not render tooltip when not provided', () => {
render(<CheckboxWithLabel {...defaultProps} />)
expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument()
expect(screen.queryByLabelText('Help text')).not.toBeInTheDocument()
})
it('should apply custom className', () => {

View File

@ -62,16 +62,13 @@ describe('CheckboxWithLabel', () => {
it('should render tooltip when provided', () => {
render(<CheckboxWithLabel {...defaultProps} tooltip="Helpful tooltip text" />)
// Assert - Tooltip trigger should be present
const tooltipTrigger = document.querySelector('[class*="ml-0.5"]')
expect(tooltipTrigger)!.toBeInTheDocument()
expect(screen.getByLabelText('Helpful tooltip text'))!.toBeInTheDocument()
})
it('should not render tooltip when not provided', () => {
render(<CheckboxWithLabel {...defaultProps} />)
const tooltipTrigger = document.querySelector('[class*="ml-0.5"]')
expect(tooltipTrigger).not.toBeInTheDocument()
expect(screen.queryByLabelText('Helpful tooltip text')).not.toBeInTheDocument()
})
})
@ -81,8 +78,7 @@ describe('CheckboxWithLabel', () => {
<CheckboxWithLabel {...defaultProps} className="custom-class" />,
)
const label = container.querySelector('label')
expect(label)!.toHaveClass('custom-class')
expect(container.firstChild)!.toHaveClass('custom-class')
})
it('should apply custom labelClassName', () => {
@ -114,16 +110,14 @@ describe('CheckboxWithLabel', () => {
expect(mockOnChange).toHaveBeenCalledWith(false)
})
it('should not trigger onChange when clicking label text due to custom checkbox', () => {
it('should trigger onChange when clicking label text', () => {
const mockOnChange = vi.fn()
render(<CheckboxWithLabel {...defaultProps} onChange={mockOnChange} />)
// Act - Click on the label text element
const labelText = screen.getByText('Test Label')
fireEvent.click(labelText)
// Assert - Custom checkbox does not support native label-input click forwarding
expect(mockOnChange).not.toHaveBeenCalled()
expect(mockOnChange).toHaveBeenCalledWith(true)
})
})
})
@ -386,15 +380,14 @@ describe('CrawledResult', () => {
it('should pass showPreview to items', () => {
render(<CrawledResult {...defaultProps} showPreview={true} />)
// Assert - Preview buttons should be visible
const buttons = screen.getAllByRole('button')
const buttons = screen.getAllByRole('button', { name: 'datasetCreation.stepOne.website.preview' })
expect(buttons.length).toBe(3)
})
it('should not show preview buttons when showPreview is false', () => {
render(<CrawledResult {...defaultProps} showPreview={false} />)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'datasetCreation.stepOne.website.preview' })).not.toBeInTheDocument()
})
})
@ -507,7 +500,7 @@ describe('CrawledResult', () => {
/>,
)
const buttons = screen.getAllByRole('button')
const buttons = screen.getAllByRole('button', { name: 'datasetCreation.stepOne.website.preview' })
fireEvent.click(buttons[1]!) // Second item's preview button
expect(mockOnPreview).toHaveBeenCalledWith(list[1], 1)
@ -796,7 +789,7 @@ describe('Base Components Integration', () => {
expect(mockOnSelectedChange).toHaveBeenCalledWith([list[0]])
// Act - Preview second item
const previewButtons = screen.getAllByRole('button')
const previewButtons = screen.getAllByRole('button', { name: 'datasetCreation.stepOne.website.preview' })
fireEvent.click(previewButtons[1]!)
expect(mockOnPreview).toHaveBeenCalledWith(list[1], 1)

View File

@ -1,8 +1,9 @@
'use client'
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import { useId } from 'react'
import Checkbox from '@/app/components/base/checkbox'
import Tooltip from '@/app/components/base/tooltip'
import { Infotip } from '@/app/components/base/infotip'
type CheckboxWithLabelProps = {
className?: string
@ -21,19 +22,28 @@ const CheckboxWithLabel = ({
labelClassName,
tooltip,
}: CheckboxWithLabelProps) => {
const labelId = useId()
const handleToggle = () => onChange(!isChecked)
return (
<label className={cn('flex items-center space-x-2', className)}>
<Checkbox checked={isChecked} onCheck={() => onChange(!isChecked)} />
<div className={cn('system-sm-medium text-text-secondary', labelClassName)}>{label}</div>
{tooltip && (
<Tooltip
popupContent={
<div className="w-[200px]">{tooltip}</div>
}
triggerClassName="ml-0.5 w-4 h-4"
/>
)}
</label>
<div className={cn('flex items-center', className)}>
<Checkbox checked={isChecked} onCheck={handleToggle} ariaLabelledBy={labelId} />
<div className="ml-2 flex min-w-0 items-center gap-1">
<button
type="button"
id={labelId}
className={cn('min-w-0 cursor-pointer border-0 bg-transparent p-0 text-left system-sm-medium text-text-secondary', labelClassName)}
onClick={handleToggle}
>
{label}
</button>
{tooltip && (
<Infotip aria-label={tooltip} popupClassName="w-[200px]">
{tooltip}
</Infotip>
)}
</div>
</div>
)
}
export default React.memo(CheckboxWithLabel)

View File

@ -140,12 +140,12 @@ describe('StatusItem', () => {
describe('error message tooltip', () => {
it('should show tooltip trigger when error message is provided', () => {
render(<StatusItem status="error" errorMessage="Test error message" />)
expect(screen.getByTestId('error-tooltip-trigger')).toBeInTheDocument()
expect(screen.getByLabelText('Test error message')).toBeInTheDocument()
})
it('should not show tooltip trigger when no error message', () => {
render(<StatusItem status="error" />)
expect(screen.queryByTestId('error-tooltip-trigger')).not.toBeInTheDocument()
expect(screen.queryByLabelText('Test error message')).not.toBeInTheDocument()
})
})

View File

@ -5,11 +5,12 @@ import type { DocumentDisplayStatus } from '@/models/datasets'
import { cn } from '@langgenius/dify-ui/cn'
import { Switch } from '@langgenius/dify-ui/switch'
import { toast } from '@langgenius/dify-ui/toast'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useDebounceFn } from 'ahooks'
import * as React from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
import { Infotip } from '@/app/components/base/infotip'
import Indicator from '@/app/components/header/indicator'
import { useDocumentDelete, useDocumentDisable, useDocumentEnable } from '@/service/knowledge/use-document'
import { asyncRunSafe } from '@/utils'
@ -81,11 +82,33 @@ const StatusItem = ({ status, reverse = false, scene = 'list', textCls = '', err
<span className={cn(`${STATUS_TEXT_COLOR_MAP[DOC_INDEX_STATUS_MAP[localStatus].color as keyof typeof STATUS_TEXT_COLOR_MAP]} text-sm`, textCls)}>
{DOC_INDEX_STATUS_MAP[localStatus]?.text}
</span>
{errorMessage && (<Tooltip popupContent={<div className="max-w-[260px] break-all">{errorMessage}</div>} triggerClassName="ml-1 w-4 h-4" triggerTestId="error-tooltip-trigger" />)}
{errorMessage && (
<Infotip
aria-label={errorMessage}
className="ml-1"
popupClassName="max-w-[260px] break-all"
>
{errorMessage}
</Infotip>
)}
{scene === 'detail' && (
<div className="ml-1.5 flex items-center justify-between">
<Tooltip popupContent={t('list.action.enableWarning', { ns: 'datasetDocuments' })} popupClassName="text-text-secondary system-xs-medium" disabled={!archived}>
<Switch checked={archived ? false : enabled} onCheckedChange={v => !archived && handleSwitch(v ? 'enable' : 'disable')} disabled={embedding || archived} size="md" />
<Tooltip disabled={!archived}>
<TooltipTrigger
render={(
<span className="flex">
<Switch
checked={archived ? false : enabled}
onCheckedChange={v => !archived && handleSwitch(v ? 'enable' : 'disable')}
disabled={embedding || archived}
size="md"
/>
</span>
)}
/>
<TooltipContent className="system-xs-medium text-text-secondary">
{t('list.action.enableWarning', { ns: 'datasetDocuments' })}
</TooltipContent>
</Tooltip>
</div>
)}

View File

@ -124,9 +124,7 @@ describe('InfoGroup', () => {
titleTooltip="This is a tooltip"
/>,
)
// Tooltip icon should be present
const tooltipIcon = screen.getByText('Test').closest('.flex')?.querySelector('svg')
expect(tooltipIcon)!.toBeInTheDocument()
expect(screen.getByLabelText('This is a tooltip'))!.toBeInTheDocument()
})
it('should render headerRight content', () => {

View File

@ -2,11 +2,11 @@
import type { FC } from 'react'
import type { MetadataItemWithValue } from '../types'
import { cn } from '@langgenius/dify-ui/cn'
import { RiDeleteBinLine, RiQuestionLine } from '@remixicon/react'
import { RiDeleteBinLine } from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import Tooltip from '@/app/components/base/tooltip'
import { Infotip } from '@/app/components/base/infotip'
import useTimestamp from '@/hooks/use-timestamp'
import { useRouter } from '@/next/navigation'
import AddMetadataButton from '../add-metadata-button'
@ -64,9 +64,9 @@ const InfoGroup: FC<Props> = ({
<div className="flex items-center space-x-1">
<div className={cn('text-text-secondary', uppercaseTitle ? 'system-xs-semibold-uppercase' : 'system-md-semibold')}>{title}</div>
{titleTooltip && (
<Tooltip popupContent={<div className="max-w-[240px]">{titleTooltip}</div>}>
<div><RiQuestionLine className="size-3.5 text-text-tertiary" /></div>
</Tooltip>
<Infotip aria-label={titleTooltip} popupClassName="max-w-[240px]">
{titleTooltip}
</Infotip>
)}
</div>
{headerRight}

View File

@ -8,8 +8,8 @@ import {
useMemo,
} from 'react'
import { useTranslation } from 'react-i18next'
import { Infotip } from '@/app/components/base/infotip'
import Textarea from '@/app/components/base/textarea'
import Tooltip from '@/app/components/base/tooltip'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
@ -65,11 +65,12 @@ const SummaryIndexSetting = ({
<div className="flex h-6 items-center justify-between">
<div className="flex items-center system-sm-semibold-uppercase text-text-secondary">
{t('form.summaryAutoGen', { ns: 'datasetSettings' })}
<Tooltip
triggerClassName="ml-1 h-4 w-4 shrink-0"
popupContent={t('form.summaryAutoGenTip', { ns: 'datasetSettings' })}
<Infotip
aria-label={t('form.summaryAutoGenTip', { ns: 'datasetSettings' })}
className="ml-1"
>
</Tooltip>
{t('form.summaryAutoGenTip', { ns: 'datasetSettings' })}
</Infotip>
</div>
<Switch
checked={summaryIndexSetting?.enable ?? false}

View File

@ -1,10 +1,12 @@
import type { Dispatch, SetStateAction } from 'react'
export enum ValidatedStatus {
Success = 'success',
Error = 'error',
Exceed = 'exceed',
}
export const ValidatedStatus = {
Success: 'success',
Error: 'error',
Exceed: 'exceed',
} as const
export type ValidatedStatus = typeof ValidatedStatus[keyof typeof ValidatedStatus]
export type ValidatedStatusState = {
status?: ValidatedStatus

View File

@ -1,14 +1,6 @@
import { render, screen } from '@testing-library/react'
import GlobalInputs from '../global-inputs'
vi.mock('@/app/components/base/tooltip', () => ({
default: ({
popupContent,
}: {
popupContent: React.ReactNode
}) => <div data-testid="tooltip">{popupContent}</div>,
}))
describe('GlobalInputs', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -18,6 +10,6 @@ describe('GlobalInputs', () => {
render(<GlobalInputs />)
expect(screen.getByText('datasetPipeline.inputFieldPanel.globalInputs.title')).toBeInTheDocument()
expect(screen.getByTestId('tooltip')).toHaveTextContent('datasetPipeline.inputFieldPanel.globalInputs.tooltip')
expect(screen.getByLabelText('datasetPipeline.inputFieldPanel.globalInputs.tooltip')).toBeInTheDocument()
})
})

View File

@ -20,12 +20,6 @@ vi.mock('@/app/components/workflow/hooks', () => ({
useToolIcon: (nodeData: DataSourceNodeType) => nodeData.provider_name || 'default-icon',
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ popupContent, popupClassName }: { popupContent: string, popupClassName?: string }) => (
<div data-testid="tooltip" data-content={popupContent} className={popupClassName} />
),
}))
afterEach(() => {
cleanup()
vi.clearAllMocks()
@ -161,21 +155,19 @@ describe('GlobalInputs', () => {
it('should render tooltip component', () => {
render(<GlobalInputs />)
expect(screen.getByTestId('tooltip')).toBeInTheDocument()
expect(screen.getByLabelText('datasetPipeline.inputFieldPanel.globalInputs.tooltip')).toBeInTheDocument()
})
it('should pass correct tooltip content', () => {
render(<GlobalInputs />)
const tooltip = screen.getByTestId('tooltip')
expect(tooltip).toHaveAttribute('data-content', 'datasetPipeline.inputFieldPanel.globalInputs.tooltip')
expect(screen.getByLabelText('datasetPipeline.inputFieldPanel.globalInputs.tooltip')).toBeInTheDocument()
})
it('should have correct tooltip className', () => {
it('should render the tooltip trigger as an icon-sized button', () => {
render(<GlobalInputs />)
const tooltip = screen.getByTestId('tooltip')
expect(tooltip).toHaveClass('w-[240px]')
expect(screen.getByLabelText('datasetPipeline.inputFieldPanel.globalInputs.tooltip')).toHaveClass('h-4', 'w-4')
})
it('should have correct container layout', () => {

View File

@ -1,6 +1,6 @@
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
import { Infotip } from '@/app/components/base/infotip'
const GlobalInputs = () => {
const { t } = useTranslation()
@ -10,10 +10,9 @@ const GlobalInputs = () => {
<span className="system-sm-semibold-uppercase text-text-secondary">
{t('inputFieldPanel.globalInputs.title', { ns: 'datasetPipeline' })}
</span>
<Tooltip
popupContent={t('inputFieldPanel.globalInputs.tooltip', { ns: 'datasetPipeline' })}
popupClassName="w-[240px]"
/>
<Infotip aria-label={t('inputFieldPanel.globalInputs.tooltip', { ns: 'datasetPipeline' })} popupClassName="w-[240px]">
{t('inputFieldPanel.globalInputs.tooltip', { ns: 'datasetPipeline' })}
</Infotip>
</div>
)
}

View File

@ -330,27 +330,6 @@ describe('publisher', () => {
})
expect(mockSetShowPricingModal).toHaveBeenCalled()
})
it('should keep confirm dialog mounted when first publish opens follow-up overlay', async () => {
mockPublishedAt.mockReturnValue(null)
renderWithQueryClient(<Publisher />)
fireEvent.click(screen.getByText('workflow.common.publish'))
await waitFor(() => {
expect(screen.getByText('workflow.common.publishUpdate')).toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: /workflow.common.publishUpdate/i }))
await waitFor(() => {
expect(screen.getByText('pipeline.common.confirmPublish')).toBeInTheDocument()
})
fireEvent.mouseDown(document.body)
expect(screen.getByText('pipeline.common.confirmPublish')).toBeInTheDocument()
})
})
})

View File

@ -1,15 +1,24 @@
import type { Node } from '../types'
import { fireEvent, render, screen } from '@testing-library/react'
import NodeContextmenu from '../node-contextmenu'
import { NodeContextmenu } from '../node-contextmenu'
const mockUseClickAway = vi.hoisted(() => vi.fn())
const mockUseNodes = vi.hoisted(() => vi.fn())
const mockUsePanelInteractions = vi.hoisted(() => vi.fn())
const mockUseStore = vi.hoisted(() => vi.fn())
const mockPanelOperatorPopup = vi.hoisted(() => vi.fn())
const mockNodeActionsContextMenuContent = vi.hoisted(() => vi.fn())
const mockContextMenuContent = vi.hoisted(() => vi.fn())
vi.mock('ahooks', () => ({
useClickAway: (...args: unknown[]) => mockUseClickAway(...args),
vi.mock('@langgenius/dify-ui/context-menu', () => ({
ContextMenu: ({ children, onOpenChange }: { children: React.ReactNode, onOpenChange: (open: boolean) => void }) => (
<div>
{children}
<button type="button" onClick={() => onOpenChange(false)}>close-context-menu</button>
</div>
),
ContextMenuContent: ({ children, positionerProps, popupClassName }: { children: React.ReactNode, positionerProps?: { anchor?: unknown }, popupClassName?: string }) => {
mockContextMenuContent({ positionerProps, popupClassName })
return <div>{children}</div>
},
}))
vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({
@ -22,20 +31,19 @@ vi.mock('@/app/components/workflow/hooks', () => ({
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: { nodeMenu?: { nodeId: string, left: number, top: number } }) => unknown) => mockUseStore(selector),
useStore: (selector: (state: { nodeMenu?: { nodeId: string, clientX: number, clientY: number } }) => unknown) => mockUseStore(selector),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup', () => ({
__esModule: true,
default: (props: {
vi.mock('@/app/components/workflow/node-actions-menu/context-menu-content', () => ({
NodeActionsContextMenuContent: (props: {
id: string
data: Node['data']
showHelpLink: boolean
onClosePopup: () => void
onClose: () => void
}) => {
mockPanelOperatorPopup(props)
mockNodeActionsContextMenuContent(props)
return (
<button type="button" onClick={props.onClosePopup}>
<button type="button" onClick={props.onClose}>
{props.id}
:
{props.data.title}
@ -46,9 +54,8 @@ vi.mock('@/app/components/workflow/nodes/_base/components/panel-operator/panel-o
describe('NodeContextmenu', () => {
const mockHandleNodeContextmenuCancel = vi.fn()
let nodeMenu: { nodeId: string, left: number, top: number } | undefined
let nodeMenu: { nodeId: string, clientX: number, clientY: number } | undefined
let nodes: Node[]
let clickAwayHandler: (() => void) | undefined
beforeEach(() => {
vi.clearAllMocks()
@ -63,51 +70,50 @@ describe('NodeContextmenu', () => {
type: 'code' as never,
},
} as Node]
clickAwayHandler = undefined
mockUseClickAway.mockImplementation((handler: () => void) => {
clickAwayHandler = handler
})
mockUseNodes.mockImplementation(() => nodes)
mockUsePanelInteractions.mockReturnValue({
handleNodeContextmenuCancel: mockHandleNodeContextmenuCancel,
})
mockUseStore.mockImplementation((selector: (state: { nodeMenu?: { nodeId: string, left: number, top: number } }) => unknown) => selector({ nodeMenu }))
mockUseStore.mockImplementation((selector: (state: { nodeMenu?: { nodeId: string, clientX: number, clientY: number } }) => unknown) => selector({ nodeMenu }))
})
it('should stay hidden when the node menu is absent', () => {
render(<NodeContextmenu />)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
expect(mockPanelOperatorPopup).not.toHaveBeenCalled()
expect(mockNodeActionsContextMenuContent).not.toHaveBeenCalled()
})
it('should stay hidden when the referenced node cannot be found', () => {
nodeMenu = { nodeId: 'missing-node', left: 80, top: 120 }
nodeMenu = { nodeId: 'missing-node', clientX: 80, clientY: 120 }
render(<NodeContextmenu />)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
expect(mockPanelOperatorPopup).not.toHaveBeenCalled()
expect(mockNodeActionsContextMenuContent).not.toHaveBeenCalled()
})
it('should render the popup at the stored position and close on popup/click-away actions', () => {
nodeMenu = { nodeId: 'node-1', left: 80, top: 120 }
const { container } = render(<NodeContextmenu />)
it('should render the context menu at the stored pointer position and close on content/root actions', () => {
nodeMenu = { nodeId: 'node-1', clientX: 80, clientY: 120 }
render(<NodeContextmenu />)
expect(screen.getByRole('button')).toHaveTextContent('node-1:Node 1')
expect(mockPanelOperatorPopup).toHaveBeenCalledWith(expect.objectContaining({
expect(screen.getByText('node-1:Node 1')).toBeInTheDocument()
expect(mockNodeActionsContextMenuContent).toHaveBeenCalledWith(expect.objectContaining({
id: 'node-1',
data: expect.objectContaining({ title: 'Node 1' }),
showHelpLink: true,
}))
expect(container.firstChild).toHaveStyle({
left: '80px',
top: '120px',
})
expect(mockContextMenuContent).toHaveBeenCalledWith(expect.objectContaining({
popupClassName: 'w-[240px] rounded-lg',
}))
const anchor = mockContextMenuContent.mock.calls[0]![0].positionerProps.anchor as { getBoundingClientRect: () => DOMRect }
const rect = anchor.getBoundingClientRect()
expect(rect.x).toBe(80)
expect(rect.y).toBe(120)
fireEvent.click(screen.getByRole('button'))
clickAwayHandler?.()
fireEvent.click(screen.getByText('node-1:Node 1'))
fireEvent.click(screen.getByText('close-context-menu'))
expect(mockHandleNodeContextmenuCancel).toHaveBeenCalledTimes(2)
})

View File

@ -297,7 +297,7 @@ vi.mock('../edge-contextmenu', () => ({
}))
vi.mock('../node-contextmenu', () => ({
default: () => null,
NodeContextmenu: () => null,
}))
vi.mock('../nodes', () => ({

View File

@ -96,7 +96,7 @@ const OperationDropdown: FC<Props> = ({
className="system-md-regular"
href={getMarketplaceUrl(`/plugins/${author}/${name}`, { theme })}
target="_blank"
rel="noreferrer"
rel="noopener noreferrer"
>
{t('operation.viewDetails', { ns: 'common' })}
</DropdownMenuLinkItem>

View File

@ -82,6 +82,7 @@ const List = ({
className="sticky bottom-0 z-10 flex h-8 cursor-pointer items-center rounded-b-lg border-[0.5px] border-t border-components-panel-border bg-components-panel-bg-blur px-4 py-1 system-sm-medium text-text-accent-light-mode-only shadow-lg"
href={getMarketplaceUrl('', { category })}
target="_blank"
rel="noopener noreferrer"
>
<span>{t('findMoreInMarketplace', { ns: 'plugin' })}</span>
<RiArrowRightUpLine className="ml-0.5 h-3 w-3" />
@ -102,6 +103,7 @@ const List = ({
<Link
href={urlWithSearchText}
target="_blank"
rel="noopener noreferrer"
className="flex items-center text-text-accent-light-mode-only"
onClick={e => e.stopPropagation()}
>
@ -124,6 +126,7 @@ const List = ({
<Link
href={urlWithSearchText}
target="_blank"
rel="noopener noreferrer"
className="flex h-4 shrink-0 items-center system-sm-medium text-text-accent-light-mode-only"
>
<RiSearchLine className="mr-0.5 h-3 w-3" />

View File

@ -39,7 +39,7 @@ describe('usePanelInteractions', () => {
it('handlePaneContextMenu should set panelMenu with computed coordinates when container exists', () => {
const { result, store } = renderWorkflowHook(() => usePanelInteractions(), {
initialStoreState: {
nodeMenu: { top: 20, left: 40, nodeId: 'n1' },
nodeMenu: { clientX: 40, clientY: 20, nodeId: 'n1' },
selectionMenu: { clientX: 30, clientY: 50 },
edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' },
},
@ -116,7 +116,7 @@ describe('usePanelInteractions', () => {
it('handleNodeContextmenuCancel should clear nodeMenu', () => {
const { result, store } = renderWorkflowHook(() => usePanelInteractions(), {
initialStoreState: { nodeMenu: { top: 10, left: 20, nodeId: 'n1' } },
initialStoreState: { nodeMenu: { clientX: 20, clientY: 10, nodeId: 'n1' } },
})
result.current.handleNodeContextmenuCancel()

View File

@ -173,7 +173,7 @@ describe('useSelectionInteractions', () => {
it('handleSelectionContextMenu should set menu only when clicking on selection rect', () => {
const { result, store } = renderSelectionInteractions({
nodeMenu: { top: 10, left: 20, nodeId: 'n1' },
nodeMenu: { clientX: 20, clientY: 10, nodeId: 'n1' },
panelMenu: { top: 30, left: 40 },
edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' },
})

View File

@ -1700,15 +1700,13 @@ export const useNodesInteractions = () => {
}
e.preventDefault()
const container = document.querySelector('#workflow-container')
const { x, y } = container!.getBoundingClientRect()
workflowStore.setState({
panelMenu: undefined,
selectionMenu: undefined,
edgeMenu: undefined,
nodeMenu: {
top: e.clientY - y,
left: e.clientX - x,
clientX: e.clientX,
clientY: e.clientY,
nodeId: node.id,
},
})

View File

@ -95,7 +95,7 @@ import {
import { HooksStoreContextProvider, useHooksStore } from './hooks-store'
import { useWorkflowComment } from './hooks/use-workflow-comment'
import { useWorkflowSearch } from './hooks/use-workflow-search'
import NodeContextmenu from './node-contextmenu'
import { NodeContextmenu } from './node-contextmenu'
import CustomNode from './nodes'
import useMatchSchemaType from './nodes/_base/components/variable/use-match-schema-type'
import CustomDataSourceEmptyNode from './nodes/data-source-empty'

View File

@ -0,0 +1,311 @@
/* eslint-disable ts/no-explicit-any */
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import {
useAvailableBlocks,
useIsChatMode,
useNodeDataUpdate,
useNodeMetaData,
useNodesInteractions,
useNodesReadOnly,
useNodesSyncDraft,
} from '@/app/components/workflow/hooks'
import { useHooksStore } from '@/app/components/workflow/hooks-store'
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
import { BlockEnum } from '@/app/components/workflow/types'
import { useAllWorkflowTools } from '@/service/use-tools'
import { FlowType } from '@/types/common'
import { ChangeBlockMenuTrigger } from '../change-block-menu-trigger'
import { NodeActionsDropdownContent } from '../dropdown-content'
vi.mock('@/app/components/workflow/block-selector', () => ({
default: ({ trigger, onSelect, availableBlocksTypes, showStartTab, ignoreNodeIds, forceEnableStartTab, allowUserInputSelection }: any) => (
<div>
<div>{trigger()}</div>
<div>{`available:${(availableBlocksTypes || []).join(',')}`}</div>
<div>{`show-start:${String(showStartTab)}`}</div>
<div>{`ignore:${(ignoreNodeIds || []).join(',')}`}</div>
<div>{`force-start:${String(forceEnableStartTab)}`}</div>
<div>{`allow-start:${String(allowUserInputSelection)}`}</div>
<button type="button" onClick={() => onSelect(BlockEnum.HttpRequest)}>select-http</button>
</div>
),
}))
vi.mock('@/app/components/workflow/hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/workflow/hooks')>()
return {
...actual,
useAvailableBlocks: vi.fn(),
useIsChatMode: vi.fn(),
useNodeDataUpdate: vi.fn(),
useNodeMetaData: vi.fn(),
useNodesInteractions: vi.fn(),
useNodesReadOnly: vi.fn(),
useNodesSyncDraft: vi.fn(),
}
})
vi.mock('@/app/components/workflow/hooks-store', () => ({
useHooksStore: vi.fn(),
}))
vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({
default: vi.fn(),
}))
vi.mock('@/service/use-tools', () => ({
useAllWorkflowTools: vi.fn(),
}))
const mockUseAvailableBlocks = vi.mocked(useAvailableBlocks)
const mockUseIsChatMode = vi.mocked(useIsChatMode)
const mockUseNodeDataUpdate = vi.mocked(useNodeDataUpdate)
const mockUseNodeMetaData = vi.mocked(useNodeMetaData)
const mockUseNodesInteractions = vi.mocked(useNodesInteractions)
const mockUseNodesReadOnly = vi.mocked(useNodesReadOnly)
const mockUseNodesSyncDraft = vi.mocked(useNodesSyncDraft)
const mockUseHooksStore = vi.mocked(useHooksStore)
const mockUseNodes = vi.mocked(useNodes)
const mockUseAllWorkflowTools = vi.mocked(useAllWorkflowTools)
function renderDropdownContent({
showHelpLink = true,
onClose = vi.fn(),
}: {
showHelpLink?: boolean
onClose?: () => void
} = {}) {
return renderWorkflowFlowComponent(
<DropdownMenu open>
<DropdownMenuTrigger render={<button type="button">open</button>} />
<DropdownMenuContent>
<NodeActionsDropdownContent
id="node-1"
data={{ type: BlockEnum.Code, title: 'Code Node', desc: '' } as any}
onClose={onClose}
showHelpLink={showHelpLink}
/>
</DropdownMenuContent>
</DropdownMenu>,
{
nodes: [],
edges: [{ id: 'edge-1', source: 'node-0', target: 'node-1', sourceHandle: 'branch-a' }],
},
)
}
describe('node actions menu details', () => {
const handleNodeChange = vi.fn()
const handleNodeDelete = vi.fn()
const handleNodesDuplicate = vi.fn()
const handleNodeSelect = vi.fn()
const handleNodesCopy = vi.fn()
const handleNodeDataUpdate = vi.fn()
const handleSyncWorkflowDraft = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockUseAvailableBlocks.mockReturnValue({
getAvailableBlocks: vi.fn(() => ({
availablePrevBlocks: [BlockEnum.HttpRequest],
availableNextBlocks: [BlockEnum.HttpRequest],
})),
availablePrevBlocks: [BlockEnum.HttpRequest],
availableNextBlocks: [BlockEnum.HttpRequest],
} as ReturnType<typeof useAvailableBlocks>)
mockUseIsChatMode.mockReturnValue(false)
mockUseNodeDataUpdate.mockReturnValue({
handleNodeDataUpdate,
handleNodeDataUpdateWithSyncDraft: vi.fn(),
})
mockUseNodeMetaData.mockReturnValue({
isTypeFixed: false,
isSingleton: false,
isUndeletable: false,
description: 'Node description',
author: 'Dify',
helpLinkUri: 'https://docs.example.com/node',
} as ReturnType<typeof useNodeMetaData>)
mockUseNodesInteractions.mockReturnValue({
handleNodeChange,
handleNodeDelete,
handleNodesDuplicate,
handleNodeSelect,
handleNodesCopy,
} as unknown as ReturnType<typeof useNodesInteractions>)
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false } as ReturnType<typeof useNodesReadOnly>)
mockUseNodesSyncDraft.mockReturnValue({
doSyncWorkflowDraft: vi.fn(),
handleSyncWorkflowDraft,
syncWorkflowDraftWhenPageClose: vi.fn(),
} as ReturnType<typeof useNodesSyncDraft>)
mockUseHooksStore.mockImplementation((selector: any) => selector({ configsMap: { flowType: FlowType.appFlow } }))
mockUseNodes.mockReturnValue([{ id: 'start', position: { x: 0, y: 0 }, data: { type: BlockEnum.Start } as any }] as any)
mockUseAllWorkflowTools.mockReturnValue({ data: [] } as any)
})
it('should select a replacement block through ChangeBlockMenuTrigger', async () => {
const user = userEvent.setup()
render(
<ChangeBlockMenuTrigger
nodeId="node-1"
nodeData={{ type: BlockEnum.Code } as any}
sourceHandle="source"
/>,
)
await user.click(screen.getByText('select-http'))
expect(screen.getByText('available:http-request')).toBeInTheDocument()
expect(screen.getByText('show-start:true')).toBeInTheDocument()
expect(screen.getByText('ignore:')).toBeInTheDocument()
expect(screen.getByText('force-start:false')).toBeInTheDocument()
expect(screen.getByText('allow-start:false')).toBeInTheDocument()
expect(handleNodeChange).toHaveBeenCalledWith('node-1', BlockEnum.HttpRequest, 'source', undefined)
})
it('should expose trigger and start-node specific block selector options', () => {
mockUseAvailableBlocks.mockReturnValueOnce({
getAvailableBlocks: vi.fn(() => ({
availablePrevBlocks: [],
availableNextBlocks: [BlockEnum.HttpRequest],
})),
availablePrevBlocks: [],
availableNextBlocks: [BlockEnum.HttpRequest],
} as ReturnType<typeof useAvailableBlocks>)
mockUseIsChatMode.mockReturnValueOnce(true)
mockUseHooksStore.mockImplementationOnce((selector: any) => selector({ configsMap: { flowType: FlowType.appFlow } }))
mockUseNodes.mockReturnValueOnce([] as any)
const { rerender } = render(
<ChangeBlockMenuTrigger
nodeId="trigger-node"
nodeData={{ type: BlockEnum.TriggerWebhook } as any}
sourceHandle="source"
/>,
)
expect(screen.getByText('available:http-request')).toBeInTheDocument()
expect(screen.getByText('show-start:true')).toBeInTheDocument()
expect(screen.getByText('ignore:trigger-node')).toBeInTheDocument()
expect(screen.getByText('allow-start:true')).toBeInTheDocument()
mockUseAvailableBlocks.mockReturnValueOnce({
getAvailableBlocks: vi.fn(() => ({
availablePrevBlocks: [BlockEnum.Code],
availableNextBlocks: [],
})),
availablePrevBlocks: [BlockEnum.Code],
availableNextBlocks: [],
} as ReturnType<typeof useAvailableBlocks>)
mockUseHooksStore.mockImplementationOnce((selector: any) => selector({ configsMap: { flowType: FlowType.ragPipeline } }))
mockUseNodes.mockReturnValueOnce([{ id: 'start', position: { x: 0, y: 0 }, data: { type: BlockEnum.Start } as any }] as any)
rerender(
<ChangeBlockMenuTrigger
nodeId="start-node"
nodeData={{ type: BlockEnum.Start } as any}
sourceHandle="source"
/>,
)
expect(screen.getByText('available:code')).toBeInTheDocument()
expect(screen.getByText('show-start:false')).toBeInTheDocument()
expect(screen.getByText('ignore:start-node')).toBeInTheDocument()
expect(screen.getByText('force-start:true')).toBeInTheDocument()
})
it('should run, copy, duplicate, delete, and expose the help link', async () => {
const user = userEvent.setup()
renderDropdownContent()
await user.click(screen.getByText('workflow.panel.runThisStep'))
await user.click(screen.getByText('workflow.common.copy'))
await user.click(screen.getByText('workflow.common.duplicate'))
await user.click(screen.getByText('common.operation.delete'))
expect(handleNodeSelect).toHaveBeenCalledWith('node-1')
expect(handleNodeDataUpdate).toHaveBeenCalledWith({ id: 'node-1', data: { _isSingleRun: true } })
expect(handleSyncWorkflowDraft).toHaveBeenCalledWith(true)
expect(handleNodesCopy).toHaveBeenCalledWith('node-1')
expect(handleNodesDuplicate).toHaveBeenCalledWith('node-1')
expect(handleNodeDelete).toHaveBeenCalledWith('node-1')
expect(screen.getByRole('menuitem', { name: 'workflow.panel.helpLink' })).toHaveAttribute('href', 'https://docs.example.com/node')
})
it('should hide change action when node is undeletable', () => {
mockUseNodeMetaData.mockReturnValueOnce({
isTypeFixed: false,
isSingleton: true,
isUndeletable: true,
description: 'Undeletable node',
author: 'Dify',
} as ReturnType<typeof useNodeMetaData>)
renderDropdownContent({ showHelpLink: false })
expect(screen.getByText('workflow.panel.runThisStep')).toBeInTheDocument()
expect(screen.queryByText('workflow.panel.change')).not.toBeInTheDocument()
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
})
it('should render workflow-tool and readonly variants', () => {
mockUseAllWorkflowTools.mockReturnValueOnce({
data: [{ id: 'workflow-tool', workflow_app_id: 'app-123' }],
} as any)
const { rerender } = renderWorkflowFlowComponent(
<DropdownMenu open>
<DropdownMenuTrigger render={<button type="button">open</button>} />
<DropdownMenuContent>
<NodeActionsDropdownContent
id="node-2"
data={{ type: BlockEnum.Tool, title: 'Workflow Tool', desc: '', provider_type: 'workflow', provider_id: 'workflow-tool' } as any}
onClose={vi.fn()}
showHelpLink={false}
/>
</DropdownMenuContent>
</DropdownMenu>,
{
nodes: [],
edges: [],
},
)
expect(screen.getByRole('menuitem', { name: 'workflow.panel.openWorkflow' })).toHaveAttribute('href', '/app/app-123/workflow')
mockUseNodesReadOnly.mockReturnValueOnce({ nodesReadOnly: true } as ReturnType<typeof useNodesReadOnly>)
mockUseNodeMetaData.mockReturnValueOnce({
isTypeFixed: true,
isSingleton: true,
isUndeletable: true,
description: 'Read only node',
author: 'Dify',
} as ReturnType<typeof useNodeMetaData>)
rerender(
<DropdownMenu open>
<DropdownMenuTrigger render={<button type="button">open</button>} />
<DropdownMenuContent>
<NodeActionsDropdownContent
id="node-3"
data={{ type: BlockEnum.End, title: 'Read only node', desc: '' } as any}
onClose={vi.fn()}
showHelpLink={false}
/>
</DropdownMenuContent>
</DropdownMenu>,
)
expect(screen.queryByText('workflow.panel.runThisStep')).not.toBeInTheDocument()
expect(screen.queryByText('workflow.common.copy')).not.toBeInTheDocument()
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
})
})

View File

@ -12,7 +12,7 @@ import {
} from '@/app/components/workflow/hooks'
import { BlockEnum } from '@/app/components/workflow/types'
import { useAllWorkflowTools } from '@/service/use-tools'
import PanelOperator from '../index'
import { NodeActionsDropdown } from '../index'
vi.mock('@/app/components/workflow/hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/workflow/hooks')>()
@ -30,8 +30,8 @@ vi.mock('@/service/use-tools', () => ({
useAllWorkflowTools: vi.fn(),
}))
vi.mock('../change-block', () => ({
default: () => <div data-testid="panel-operator-change-block" />,
vi.mock('../change-block-menu-trigger', () => ({
ChangeBlockMenuTrigger: () => <div data-testid="node-actions-change-block" />,
}))
const mockUseNodeDataUpdate = vi.mocked(useNodeDataUpdate)
@ -73,18 +73,16 @@ const createQueryResult = <T,>(data: T): UseQueryResult<T, Error> => ({
const renderComponent = (
showHelpLink: boolean = true,
onOpenChange?: (open: boolean) => void,
offset?: { mainAxis: number, crossAxis: number } | number,
) =>
renderWorkflowFlowComponent(
<PanelOperator
<NodeActionsDropdown
id="node-1"
data={{
title: 'Code Node',
desc: '',
type: BlockEnum.Code,
}}
triggerClassName="panel-operator-trigger"
offset={offset}
triggerClassName="node-actions-trigger"
onOpenChange={onOpenChange}
showHelpLink={showHelpLink}
/>,
@ -94,7 +92,7 @@ const renderComponent = (
},
)
describe('PanelOperator', () => {
describe('NodeActionsDropdown', () => {
const handleNodeSelect = vi.fn()
const handleNodeDataUpdate = vi.fn()
const handleSyncWorkflowDraft = vi.fn()
@ -131,47 +129,34 @@ describe('PanelOperator', () => {
mockUseAllWorkflowTools.mockReturnValue(createQueryResult<ToolWithProvider[]>([]))
})
// The operator should open the real popup, expose actionable items, and respect help-link visibility.
describe('Popup Interaction', () => {
it('should open the popup and trigger single-run actions', async () => {
const user = userEvent.setup()
const onOpenChange = vi.fn()
const { container } = renderComponent(true, onOpenChange)
it('should open the dropdown and trigger single-run actions', async () => {
const user = userEvent.setup()
const onOpenChange = vi.fn()
renderComponent(true, onOpenChange)
await user.click(container.querySelector('.panel-operator-trigger') as HTMLElement)
await user.click(screen.getByRole('button', { name: 'common.operation.more' }))
expect(onOpenChange).toHaveBeenCalledWith(true)
expect(screen.getByText('workflow.panel.runThisStep')).toBeInTheDocument()
expect(screen.getByText('Node description')).toBeInTheDocument()
expect(onOpenChange).toHaveBeenCalledWith(true)
expect(screen.getByText('workflow.panel.runThisStep')).toBeInTheDocument()
expect(screen.getByText('Node description')).toBeInTheDocument()
await user.click(screen.getByText('workflow.panel.runThisStep'))
await user.click(screen.getByText('workflow.panel.runThisStep'))
expect(handleNodeSelect).toHaveBeenCalledWith('node-1')
expect(handleNodeDataUpdate).toHaveBeenCalledWith({
id: 'node-1',
data: { _isSingleRun: true },
})
expect(handleSyncWorkflowDraft).toHaveBeenCalledWith(true)
expect(handleNodeSelect).toHaveBeenCalledWith('node-1')
expect(handleNodeDataUpdate).toHaveBeenCalledWith({
id: 'node-1',
data: { _isSingleRun: true },
})
expect(handleSyncWorkflowDraft).toHaveBeenCalledWith(true)
})
it('should hide the help link when showHelpLink is false', async () => {
const user = userEvent.setup()
const { container } = renderComponent(false)
it('should hide the help link when showHelpLink is false', async () => {
const user = userEvent.setup()
renderComponent(false)
await user.click(container.querySelector('.panel-operator-trigger') as HTMLElement)
await user.click(screen.getByRole('button', { name: 'common.operation.more' }))
expect(screen.queryByText('workflow.panel.helpLink')).not.toBeInTheDocument()
expect(screen.getByText('Node description')).toBeInTheDocument()
})
it('should still open the popup when using a numeric offset and no open-change callback', async () => {
const user = userEvent.setup()
const { container } = renderComponent(true, undefined, 0)
await user.click(container.querySelector('.panel-operator-trigger') as HTMLElement)
expect(screen.getByText('workflow.panel.runThisStep')).toBeInTheDocument()
expect(screen.getByText('Node description')).toBeInTheDocument()
})
expect(screen.queryByText('workflow.panel.helpLink')).not.toBeInTheDocument()
expect(screen.getByText('Node description')).toBeInTheDocument()
})
})

View File

@ -4,11 +4,7 @@ import type {
OnSelectBlock,
} from '@/app/components/workflow/types'
import { intersection } from 'es-toolkit/array'
import {
memo,
useCallback,
useMemo,
} from 'react'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import BlockSelector from '@/app/components/workflow/block-selector'
import {
@ -19,19 +15,19 @@ import {
import { useHooksStore } from '@/app/components/workflow/hooks-store'
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
import { BlockEnum, isTriggerNode } from '@/app/components/workflow/types'
import { FlowType } from '@/types/common'
type ChangeBlockProps = {
type ChangeBlockMenuTriggerProps = {
nodeId: string
nodeData: Node['data']
sourceHandle: string
}
const ChangeBlock = ({
export function ChangeBlockMenuTrigger({
nodeId,
nodeData,
sourceHandle,
}: ChangeBlockProps) => {
}: ChangeBlockMenuTriggerProps) {
const { t } = useTranslation()
const { handleNodeChange } = useNodesInteractions()
const {
@ -55,10 +51,9 @@ const ChangeBlock = ({
const availableNodes = useMemo(() => {
if (availablePrevBlocks.length && availableNextBlocks.length)
return intersection(availablePrevBlocks, availableNextBlocks)
else if (availablePrevBlocks.length)
if (availablePrevBlocks.length)
return availablePrevBlocks
else
return availableNextBlocks
return availableNextBlocks
}, [availablePrevBlocks, availableNextBlocks])
const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => {
@ -67,19 +62,17 @@ const ChangeBlock = ({
const renderTrigger = useCallback(() => {
return (
<div className="flex h-8 w-[232px] cursor-pointer items-center rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover">
<button
type="button"
className="mx-1 flex h-8 w-[calc(100%-8px)] cursor-pointer items-center rounded-lg border-0 bg-transparent px-2 text-left text-sm text-text-secondary outline-hidden select-none hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:outline-hidden"
>
{t('panel.changeBlock', { ns: 'workflow' })}
</div>
</button>
)
}, [t])
return (
<BlockSelector
placement="bottom-end"
offset={{
mainAxis: -36,
crossAxis: 4,
}}
onSelect={handleSelect}
trigger={renderTrigger}
popupClassName="min-w-[240px]"
@ -91,5 +84,3 @@ const ChangeBlock = ({
/>
)
}
export default memo(ChangeBlock)

View File

@ -0,0 +1,101 @@
import type { NodeActionsMenuProps } from './types'
import {
ContextMenuGroup,
ContextMenuItem,
ContextMenuLinkItem,
ContextMenuSeparator,
} from '@langgenius/dify-ui/context-menu'
import { useTranslation } from 'react-i18next'
import { ChangeBlockMenuTrigger } from './change-block-menu-trigger'
import {
NODE_ACTIONS_MENU_ITEM_WITH_SHORTCUT_CLASS_NAME,
NodeActionsMenuAbout,
NodeActionsMenuItemContent,
} from './shared'
import { useNodeActionsMenuModel } from './use-node-actions-menu-model'
export function NodeActionsContextMenuContent(props: NodeActionsMenuProps) {
const { t } = useTranslation()
const model = useNodeActionsMenuModel(props)
const hasRunGroup = model.canRun || model.canChangeBlock
const hasEditGroup = !model.nodesReadOnly && !model.isSingleton
const hasDeleteGroup = !model.nodesReadOnly && !model.isUndeletable
return (
<>
{hasRunGroup && (
<ContextMenuGroup>
{model.canRun && (
<ContextMenuItem onClick={model.handleRun}>
{t('panel.runThisStep', { ns: 'workflow' })}
</ContextMenuItem>
)}
{model.canChangeBlock && (
<ChangeBlockMenuTrigger
nodeId={model.id}
nodeData={model.data}
sourceHandle={model.sourceHandle}
/>
)}
</ContextMenuGroup>
)}
{hasRunGroup && (hasEditGroup || hasDeleteGroup || model.workflowAppHref || model.helpLinkUri) && <ContextMenuSeparator />}
{hasEditGroup && (
<ContextMenuGroup>
<ContextMenuItem
className={NODE_ACTIONS_MENU_ITEM_WITH_SHORTCUT_CLASS_NAME}
onClick={model.handleCopy}
>
<NodeActionsMenuItemContent shortcut="workflow.copy">
{t('common.copy', { ns: 'workflow' })}
</NodeActionsMenuItemContent>
</ContextMenuItem>
<ContextMenuItem
className={NODE_ACTIONS_MENU_ITEM_WITH_SHORTCUT_CLASS_NAME}
onClick={model.handleDuplicate}
>
<NodeActionsMenuItemContent shortcut="workflow.duplicate">
{t('common.duplicate', { ns: 'workflow' })}
</NodeActionsMenuItemContent>
</ContextMenuItem>
</ContextMenuGroup>
)}
{hasEditGroup && (hasDeleteGroup || model.workflowAppHref || model.helpLinkUri) && <ContextMenuSeparator />}
{hasDeleteGroup && (
<ContextMenuGroup>
<ContextMenuItem
variant="destructive"
className={NODE_ACTIONS_MENU_ITEM_WITH_SHORTCUT_CLASS_NAME}
onClick={model.handleDelete}
>
<NodeActionsMenuItemContent shortcut="workflow.delete">
{t('operation.delete', { ns: 'common' })}
</NodeActionsMenuItemContent>
</ContextMenuItem>
</ContextMenuGroup>
)}
{hasDeleteGroup && (model.workflowAppHref || model.helpLinkUri) && <ContextMenuSeparator />}
{model.workflowAppHref && (
<ContextMenuGroup>
<ContextMenuLinkItem href={model.workflowAppHref} target="_blank" rel="noopener noreferrer">
{t('panel.openWorkflow', { ns: 'workflow' })}
</ContextMenuLinkItem>
</ContextMenuGroup>
)}
{model.workflowAppHref && model.helpLinkUri && <ContextMenuSeparator />}
{model.helpLinkUri && (
<ContextMenuGroup>
<ContextMenuLinkItem href={model.helpLinkUri} target="_blank" rel="noopener noreferrer">
{t('panel.helpLink', { ns: 'workflow' })}
</ContextMenuLinkItem>
</ContextMenuGroup>
)}
<ContextMenuSeparator />
<NodeActionsMenuAbout
title={t('panel.about', { ns: 'workflow' })}
description={model.about.description}
author={`${t('panel.createdBy', { ns: 'workflow' })} ${model.about.author}`}
/>
</>
)
}

View File

@ -0,0 +1,101 @@
import type { NodeActionsMenuProps } from './types'
import {
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLinkItem,
DropdownMenuSeparator,
} from '@langgenius/dify-ui/dropdown-menu'
import { useTranslation } from 'react-i18next'
import { ChangeBlockMenuTrigger } from './change-block-menu-trigger'
import {
NODE_ACTIONS_MENU_ITEM_WITH_SHORTCUT_CLASS_NAME,
NodeActionsMenuAbout,
NodeActionsMenuItemContent,
} from './shared'
import { useNodeActionsMenuModel } from './use-node-actions-menu-model'
export function NodeActionsDropdownContent(props: NodeActionsMenuProps) {
const { t } = useTranslation()
const model = useNodeActionsMenuModel(props)
const hasRunGroup = model.canRun || model.canChangeBlock
const hasEditGroup = !model.nodesReadOnly && !model.isSingleton
const hasDeleteGroup = !model.nodesReadOnly && !model.isUndeletable
return (
<>
{hasRunGroup && (
<DropdownMenuGroup>
{model.canRun && (
<DropdownMenuItem onClick={model.handleRun}>
{t('panel.runThisStep', { ns: 'workflow' })}
</DropdownMenuItem>
)}
{model.canChangeBlock && (
<ChangeBlockMenuTrigger
nodeId={model.id}
nodeData={model.data}
sourceHandle={model.sourceHandle}
/>
)}
</DropdownMenuGroup>
)}
{hasRunGroup && (hasEditGroup || hasDeleteGroup || model.workflowAppHref || model.helpLinkUri) && <DropdownMenuSeparator />}
{hasEditGroup && (
<DropdownMenuGroup>
<DropdownMenuItem
className={NODE_ACTIONS_MENU_ITEM_WITH_SHORTCUT_CLASS_NAME}
onClick={model.handleCopy}
>
<NodeActionsMenuItemContent shortcut="workflow.copy">
{t('common.copy', { ns: 'workflow' })}
</NodeActionsMenuItemContent>
</DropdownMenuItem>
<DropdownMenuItem
className={NODE_ACTIONS_MENU_ITEM_WITH_SHORTCUT_CLASS_NAME}
onClick={model.handleDuplicate}
>
<NodeActionsMenuItemContent shortcut="workflow.duplicate">
{t('common.duplicate', { ns: 'workflow' })}
</NodeActionsMenuItemContent>
</DropdownMenuItem>
</DropdownMenuGroup>
)}
{hasEditGroup && (hasDeleteGroup || model.workflowAppHref || model.helpLinkUri) && <DropdownMenuSeparator />}
{hasDeleteGroup && (
<DropdownMenuGroup>
<DropdownMenuItem
variant="destructive"
className={NODE_ACTIONS_MENU_ITEM_WITH_SHORTCUT_CLASS_NAME}
onClick={model.handleDelete}
>
<NodeActionsMenuItemContent shortcut="workflow.delete">
{t('operation.delete', { ns: 'common' })}
</NodeActionsMenuItemContent>
</DropdownMenuItem>
</DropdownMenuGroup>
)}
{hasDeleteGroup && (model.workflowAppHref || model.helpLinkUri) && <DropdownMenuSeparator />}
{model.workflowAppHref && (
<DropdownMenuGroup>
<DropdownMenuLinkItem href={model.workflowAppHref} target="_blank" rel="noopener noreferrer">
{t('panel.openWorkflow', { ns: 'workflow' })}
</DropdownMenuLinkItem>
</DropdownMenuGroup>
)}
{model.workflowAppHref && model.helpLinkUri && <DropdownMenuSeparator />}
{model.helpLinkUri && (
<DropdownMenuGroup>
<DropdownMenuLinkItem href={model.helpLinkUri} target="_blank" rel="noopener noreferrer">
{t('panel.helpLink', { ns: 'workflow' })}
</DropdownMenuLinkItem>
</DropdownMenuGroup>
)}
<DropdownMenuSeparator />
<NodeActionsMenuAbout
title={t('panel.about', { ns: 'workflow' })}
description={model.about.description}
author={`${t('panel.createdBy', { ns: 'workflow' })} ${model.about.author}`}
/>
</>
)
}

View File

@ -0,0 +1,75 @@
import type { Node } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { NodeActionsDropdownContent } from './dropdown-content'
import { NODE_ACTIONS_MENU_WIDTH_CLASS_NAME } from './shared'
type NodeActionsDropdownProps = {
id: string
data: Node['data']
triggerClassName?: string
onOpenChange?: (open: boolean) => void
showHelpLink?: boolean
}
export function NodeActionsDropdown({
id,
data,
triggerClassName,
onOpenChange,
showHelpLink = true,
}: NodeActionsDropdownProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const handleOpenChange = useCallback((nextOpen: boolean) => {
setOpen(nextOpen)
onOpenChange?.(nextOpen)
}, [onOpenChange])
const closeMenu = useCallback(() => {
setOpen(false)
onOpenChange?.(false)
}, [onOpenChange])
return (
<DropdownMenu
modal={false}
open={open}
onOpenChange={handleOpenChange}
>
<DropdownMenuTrigger
render={(
<button
type="button"
aria-label={t('operation.more', { ns: 'common' })}
className={cn(
'flex h-6 w-6 cursor-pointer items-center justify-center rounded-md border-0 bg-transparent p-0 text-text-tertiary hover:bg-state-base-hover',
'focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:outline-hidden data-popup-open:bg-state-base-hover',
triggerClassName,
)}
>
<span aria-hidden className="i-ri-more-fill h-4 w-4" />
</button>
)}
/>
<DropdownMenuContent
placement="bottom-end"
popupClassName={NODE_ACTIONS_MENU_WIDTH_CLASS_NAME}
>
<NodeActionsDropdownContent
id={id}
data={data}
onClose={closeMenu}
showHelpLink={showHelpLink}
/>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -0,0 +1,44 @@
import type { RegisterableHotkey } from '@tanstack/react-hotkeys'
import type { ReactNode } from 'react'
import type { WorkflowShortcutId } from '@/app/components/workflow/shortcuts/definitions'
import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd'
export const NODE_ACTIONS_MENU_WIDTH_CLASS_NAME = 'w-[240px] rounded-lg'
export const NODE_ACTIONS_MENU_ITEM_WITH_SHORTCUT_CLASS_NAME = 'w-auto justify-between gap-4'
export function NodeActionsMenuItemContent({
children,
hotkey,
shortcut,
}: {
children: ReactNode
hotkey?: RegisterableHotkey | (string & {})
shortcut?: WorkflowShortcutId
}) {
return (
<>
<span className="min-w-0 truncate">{children}</span>
{(shortcut || hotkey) && <ShortcutKbd shortcut={shortcut} hotkey={hotkey} />}
</>
)
}
export function NodeActionsMenuAbout({
author,
description,
title,
}: {
author?: string
description?: string
title: string
}) {
return (
<div className="px-3 py-2 text-xs text-text-tertiary">
<div className="mb-1 flex h-[22px] items-center font-medium">
{title.toLocaleUpperCase()}
</div>
<div className="mb-1 leading-[18px] text-text-secondary">{description}</div>
<div className="leading-[18px]">{author}</div>
</div>
)
}

View File

@ -0,0 +1,8 @@
import type { Node } from '@/app/components/workflow/types'
export type NodeActionsMenuProps = {
id: string
data: Node['data']
onClose: () => void
showHelpLink?: boolean
}

View File

@ -0,0 +1,104 @@
import type { Node } from '@/app/components/workflow/types'
import { useCallback, useMemo } from 'react'
import { useEdges } from 'reactflow'
import { CollectionType } from '@/app/components/tools/types'
import {
useNodeDataUpdate,
useNodeMetaData,
useNodesInteractions,
useNodesReadOnly,
useNodesSyncDraft,
} from '@/app/components/workflow/hooks'
import { BlockEnum } from '@/app/components/workflow/types'
import { canRunBySingle } from '@/app/components/workflow/utils'
import { useAllWorkflowTools } from '@/service/use-tools'
import { canFindTool } from '@/utils'
type UseNodeActionsMenuModelParams = {
id: string
data: Node['data']
onClose: () => void
showHelpLink?: boolean
}
export function useNodeActionsMenuModel({
id,
data,
onClose,
showHelpLink = true,
}: UseNodeActionsMenuModelParams) {
const edges = useEdges()
const {
handleNodeDelete,
handleNodesDuplicate,
handleNodeSelect,
handleNodesCopy,
} = useNodesInteractions()
const { handleNodeDataUpdate } = useNodeDataUpdate()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const { nodesReadOnly } = useNodesReadOnly()
const nodeMetaData = useNodeMetaData({ id, data } as Node)
const { data: workflowTools } = useAllWorkflowTools()
const isChildNode = !!(data.isInIteration || data.isInLoop)
const canRun = canRunBySingle(data.type, isChildNode)
const canChangeBlock = !nodeMetaData.isTypeFixed && !nodeMetaData.isUndeletable && !nodesReadOnly
const sourceHandle = useMemo(() => {
return edges.find(edge => edge.target === id)?.sourceHandle || 'source'
}, [edges, id])
const workflowAppHref = useMemo(() => {
const isWorkflowTool = data.type === BlockEnum.Tool && data.provider_type === CollectionType.workflow
if (!isWorkflowTool || !workflowTools || !data.provider_id)
return undefined
const workflowTool = workflowTools.find(item => canFindTool(item.id, data.provider_id))
if (!workflowTool?.workflow_app_id)
return undefined
return `/app/${workflowTool.workflow_app_id}/workflow`
}, [data.provider_id, data.provider_type, data.type, workflowTools])
const handleRun = useCallback(() => {
handleNodeSelect(id)
handleNodeDataUpdate({ id, data: { _isSingleRun: true } })
handleSyncWorkflowDraft(true)
onClose()
}, [handleNodeDataUpdate, handleNodeSelect, handleSyncWorkflowDraft, id, onClose])
const handleCopy = useCallback(() => {
onClose()
handleNodesCopy(id)
}, [handleNodesCopy, id, onClose])
const handleDuplicate = useCallback(() => {
onClose()
handleNodesDuplicate(id)
}, [handleNodesDuplicate, id, onClose])
const handleDelete = useCallback(() => {
onClose()
handleNodeDelete(id)
}, [handleNodeDelete, id, onClose])
return {
about: {
author: nodeMetaData.author,
description: nodeMetaData.description,
},
canChangeBlock,
canRun,
data,
handleCopy,
handleDelete,
handleDuplicate,
handleRun,
helpLinkUri: showHelpLink ? nodeMetaData.helpLinkUri : undefined,
id,
isSingleton: nodeMetaData.isSingleton,
isUndeletable: nodeMetaData.isUndeletable,
nodesReadOnly,
sourceHandle,
workflowAppHref,
}
}

View File

@ -1,45 +1,54 @@
import type { Node } from './types'
import { useClickAway } from 'ahooks'
import {
memo,
useRef,
} from 'react'
ContextMenu,
ContextMenuContent,
} from '@langgenius/dify-ui/context-menu'
import { useMemo } from 'react'
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
import { usePanelInteractions } from './hooks'
import PanelOperatorPopup from './nodes/_base/components/panel-operator/panel-operator-popup'
import { NodeActionsContextMenuContent } from './node-actions-menu/context-menu-content'
import { NODE_ACTIONS_MENU_WIDTH_CLASS_NAME } from './node-actions-menu/shared'
import { useStore } from './store'
const NodeContextmenu = () => {
const ref = useRef(null)
export function NodeContextmenu() {
const nodes = useNodes()
const { handleNodeContextmenuCancel } = usePanelInteractions()
const nodeMenu = useStore(s => s.nodeMenu)
const currentNode = nodes.find(node => node.id === nodeMenu?.nodeId) as Node
useClickAway(() => {
handleNodeContextmenuCancel()
}, ref)
const anchor = useMemo(() => {
if (!nodeMenu || !currentNode)
return undefined
if (!nodeMenu || !currentNode)
return {
getBoundingClientRect: () => DOMRect.fromRect({
width: 0,
height: 0,
x: nodeMenu.clientX,
y: nodeMenu.clientY,
}),
}
}, [currentNode, nodeMenu])
if (!nodeMenu || !currentNode || !anchor)
return null
return (
<div
className="absolute z-9"
style={{
left: nodeMenu.left,
top: nodeMenu.top,
}}
ref={ref}
<ContextMenu
open
onOpenChange={open => !open && handleNodeContextmenuCancel()}
>
<PanelOperatorPopup
id={currentNode.id}
data={currentNode.data}
onClosePopup={() => handleNodeContextmenuCancel()}
showHelpLink
/>
</div>
<ContextMenuContent
positionerProps={{ anchor }}
popupClassName={NODE_ACTIONS_MENU_WIDTH_CLASS_NAME}
>
<NodeActionsContextMenuContent
id={currentNode.id}
data={currentNode.data}
onClose={handleNodeContextmenuCancel}
showHelpLink
/>
</ContextMenuContent>
</ContextMenu>
)
}
export default memo(NodeContextmenu)

View File

@ -1,10 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import Field from '../field'
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ popupContent }: { popupContent: React.ReactNode }) => <div>{popupContent}</div>,
}))
describe('Field', () => {
it('should render subtitle styling, tooltip, operations, warning dot and required marker', () => {
const { container } = render(
@ -19,7 +15,7 @@ describe('Field', () => {
)
expect(screen.getByText('Knowledge')).toBeInTheDocument()
expect(screen.getByText('tooltip text')).toBeInTheDocument()
expect(screen.getByLabelText('tooltip text')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'operation' })).toBeInTheDocument()
expect(screen.getByText('*')).toBeInTheDocument()
expect(container.querySelector('.system-xs-medium-uppercase')).not.toBeNull()

View File

@ -32,8 +32,8 @@ vi.mock('../../../../utils', async () => {
}
})
vi.mock('../panel-operator', () => ({
default: ({ onOpenChange }: { onOpenChange: (open: boolean) => void }) => (
vi.mock('@/app/components/workflow/node-actions-menu', () => ({
NodeActionsDropdown: ({ onOpenChange }: { onOpenChange: (open: boolean) => void }) => (
<>
<button type="button" onClick={() => onOpenChange(true)}>open panel</button>
<button type="button" onClick={() => onOpenChange(false)}>close panel</button>

View File

@ -266,6 +266,7 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => {
href={docLink('/use-dify/nodes/agent')}
className="text-text-accent-secondary"
target="_blank"
rel="noopener noreferrer"
>
{t('nodes.agent.learnMore', { ns: 'workflow' })}
</Link>

View File

@ -21,6 +21,7 @@ const FailBranchCard = () => {
<a
href={docLink('/use-dify/debug/error-type')}
target="_blank"
rel="noopener noreferrer"
className="text-text-accent"
>
{t('common.learnMore', { ns: 'workflow' })}

View File

@ -6,7 +6,7 @@ import {
} from '@remixicon/react'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import Tooltip from '@/app/components/base/tooltip'
import { Infotip } from '@/app/components/base/infotip'
type Props = {
className?: string
@ -21,6 +21,17 @@ type Props = {
warningDot?: boolean
}
const getTextFromNode = (node: ReactNode): string | undefined => {
if (typeof node === 'string' || typeof node === 'number')
return `${node}`
if (Array.isArray(node))
return node.map(getTextFromNode).filter(Boolean).join(' ')
if (React.isValidElement<{ children?: ReactNode }>(node))
return getTextFromNode(node.props.children)
}
const Field: FC<Props> = ({
className,
title,
@ -36,6 +47,8 @@ const Field: FC<Props> = ({
const [fold, {
toggle: toggleFold,
}] = useBoolean(true)
const tooltipLabel = tooltip ? getTextFromNode(tooltip) || getTextFromNode(title) || 'Help' : undefined
return (
<div className={cn(className, inline && 'flex w-full items-center justify-between')}>
<div
@ -51,12 +64,10 @@ const Field: FC<Props> = ({
{' '}
{required && <span className="text-text-destructive">*</span>}
</div>
{!!tooltip && (
<Tooltip
popupContent={tooltip}
popupClassName="ml-1"
triggerClassName="w-4 h-4 ml-1"
/>
{!!tooltip && !!tooltipLabel && (
<Infotip aria-label={tooltipLabel} className="ml-1">
{tooltip}
</Infotip>
)}
</div>
<div className="flex">

View File

@ -26,7 +26,7 @@ const HelpLink = ({
aria-label={label}
href={link}
target="_blank"
rel="noreferrer"
rel="noopener noreferrer"
className="mr-1 flex h-6 w-6 items-center justify-center rounded-md hover:bg-state-base-hover"
>
<span aria-hidden className="i-ri-book-open-line h-4 w-4 text-gray-500" />

View File

@ -11,13 +11,13 @@ import { useTranslation } from 'react-i18next'
import {
Stop,
} from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
import { NodeActionsDropdown } from '@/app/components/workflow/node-actions-menu'
import { useWorkflowStore } from '@/app/components/workflow/store'
import {
useNodesInteractions,
} from '../../../hooks'
import { NodeRunningStatus } from '../../../types'
import { canRunBySingle } from '../../../utils'
import PanelOperator from './panel-operator'
type NodeControlProps = Pick<Node, 'id' | 'data'> & {
pluginInstallLocked?: boolean
@ -82,10 +82,9 @@ const NodeControl: FC<NodeControlProps> = ({
</button>
)
}
<PanelOperator
<NodeActionsDropdown
id={id}
data={data}
offset={0}
triggerClassName="w-5! h-5!"
/>
</div>

View File

@ -5,7 +5,7 @@ import { cn } from '@langgenius/dify-ui/cn'
import { cva } from 'class-variance-authority'
import * as React from 'react'
import { useCallback } from 'react'
import Tooltip from '@/app/components/base/tooltip'
import { Infotip } from '@/app/components/base/infotip'
const variants = cva([], {
variants: {
@ -60,13 +60,9 @@ const OptionCard: FC<Props> = ({
<span>{title}</span>
{tooltip
&& (
<Tooltip
popupContent={(
<div className="w-[240px]">
{tooltip}
</div>
)}
/>
<Infotip aria-label={tooltip} popupClassName="w-[240px]">
{tooltip}
</Infotip>
)}
</div>
)

View File

@ -1,295 +0,0 @@
/* eslint-disable ts/no-explicit-any */
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import {
useAvailableBlocks,
useIsChatMode,
useNodeDataUpdate,
useNodeMetaData,
useNodesInteractions,
useNodesReadOnly,
useNodesSyncDraft,
} from '@/app/components/workflow/hooks'
import { useHooksStore } from '@/app/components/workflow/hooks-store'
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
import { BlockEnum } from '@/app/components/workflow/types'
import { useAllWorkflowTools } from '@/service/use-tools'
import { FlowType } from '@/types/common'
import ChangeBlock from '../change-block'
import PanelOperatorPopup from '../panel-operator-popup'
vi.mock('@/app/components/workflow/block-selector', () => ({
default: ({ trigger, onSelect, availableBlocksTypes, showStartTab, ignoreNodeIds, forceEnableStartTab, allowUserInputSelection }: any) => (
<div>
<div>{trigger()}</div>
<div>{`available:${(availableBlocksTypes || []).join(',')}`}</div>
<div>{`show-start:${String(showStartTab)}`}</div>
<div>{`ignore:${(ignoreNodeIds || []).join(',')}`}</div>
<div>{`force-start:${String(forceEnableStartTab)}`}</div>
<div>{`allow-start:${String(allowUserInputSelection)}`}</div>
<button type="button" onClick={() => onSelect(BlockEnum.HttpRequest)}>select-http</button>
</div>
),
}))
vi.mock('@/app/components/workflow/hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/workflow/hooks')>()
return {
...actual,
useAvailableBlocks: vi.fn(),
useIsChatMode: vi.fn(),
useNodeDataUpdate: vi.fn(),
useNodeMetaData: vi.fn(),
useNodesInteractions: vi.fn(),
useNodesReadOnly: vi.fn(),
useNodesSyncDraft: vi.fn(),
}
})
vi.mock('@/app/components/workflow/hooks-store', () => ({
useHooksStore: vi.fn(),
}))
vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({
default: vi.fn(),
}))
vi.mock('@/service/use-tools', () => ({
useAllWorkflowTools: vi.fn(),
}))
const mockUseAvailableBlocks = vi.mocked(useAvailableBlocks)
const mockUseIsChatMode = vi.mocked(useIsChatMode)
const mockUseNodeDataUpdate = vi.mocked(useNodeDataUpdate)
const mockUseNodeMetaData = vi.mocked(useNodeMetaData)
const mockUseNodesInteractions = vi.mocked(useNodesInteractions)
const mockUseNodesReadOnly = vi.mocked(useNodesReadOnly)
const mockUseNodesSyncDraft = vi.mocked(useNodesSyncDraft)
const mockUseHooksStore = vi.mocked(useHooksStore)
const mockUseNodes = vi.mocked(useNodes)
const mockUseAllWorkflowTools = vi.mocked(useAllWorkflowTools)
describe('panel-operator details', () => {
const handleNodeChange = vi.fn()
const handleNodeDelete = vi.fn()
const handleNodesDuplicate = vi.fn()
const handleNodeSelect = vi.fn()
const handleNodesCopy = vi.fn()
const handleNodeDataUpdate = vi.fn()
const handleSyncWorkflowDraft = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockUseAvailableBlocks.mockReturnValue({
getAvailableBlocks: vi.fn(() => ({
availablePrevBlocks: [BlockEnum.HttpRequest],
availableNextBlocks: [BlockEnum.HttpRequest],
})),
availablePrevBlocks: [BlockEnum.HttpRequest],
availableNextBlocks: [BlockEnum.HttpRequest],
} as ReturnType<typeof useAvailableBlocks>)
mockUseIsChatMode.mockReturnValue(false)
mockUseNodeDataUpdate.mockReturnValue({
handleNodeDataUpdate,
handleNodeDataUpdateWithSyncDraft: vi.fn(),
})
mockUseNodeMetaData.mockReturnValue({
isTypeFixed: false,
isSingleton: false,
isUndeletable: false,
description: 'Node description',
author: 'Dify',
helpLinkUri: 'https://docs.example.com/node',
} as ReturnType<typeof useNodeMetaData>)
mockUseNodesInteractions.mockReturnValue({
handleNodeChange,
handleNodeDelete,
handleNodesDuplicate,
handleNodeSelect,
handleNodesCopy,
} as unknown as ReturnType<typeof useNodesInteractions>)
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false } as ReturnType<typeof useNodesReadOnly>)
mockUseNodesSyncDraft.mockReturnValue({
doSyncWorkflowDraft: vi.fn(),
handleSyncWorkflowDraft,
syncWorkflowDraftWhenPageClose: vi.fn(),
} as ReturnType<typeof useNodesSyncDraft>)
mockUseHooksStore.mockImplementation((selector: any) => selector({ configsMap: { flowType: FlowType.appFlow } }))
mockUseNodes.mockReturnValue([{ id: 'start', position: { x: 0, y: 0 }, data: { type: BlockEnum.Start } as any }] as any)
mockUseAllWorkflowTools.mockReturnValue({ data: [] } as any)
})
// The panel operator internals should expose block-change and popup actions using the real workflow popup composition.
describe('Internal Actions', () => {
it('should select a replacement block through ChangeBlock', async () => {
const user = userEvent.setup()
render(
<ChangeBlock
nodeId="node-1"
nodeData={{ type: BlockEnum.Code } as any}
sourceHandle="source"
/>,
)
await user.click(screen.getByText('select-http'))
expect(screen.getByText('available:http-request')).toBeInTheDocument()
expect(screen.getByText('show-start:true')).toBeInTheDocument()
expect(screen.getByText('ignore:')).toBeInTheDocument()
expect(screen.getByText('force-start:false')).toBeInTheDocument()
expect(screen.getByText('allow-start:false')).toBeInTheDocument()
expect(handleNodeChange).toHaveBeenCalledWith('node-1', BlockEnum.HttpRequest, 'source', undefined)
})
it('should expose trigger and start-node specific block selector options', () => {
mockUseAvailableBlocks.mockReturnValueOnce({
getAvailableBlocks: vi.fn(() => ({
availablePrevBlocks: [],
availableNextBlocks: [BlockEnum.HttpRequest],
})),
availablePrevBlocks: [],
availableNextBlocks: [BlockEnum.HttpRequest],
} as ReturnType<typeof useAvailableBlocks>)
mockUseIsChatMode.mockReturnValueOnce(true)
mockUseHooksStore.mockImplementationOnce((selector: any) => selector({ configsMap: { flowType: FlowType.appFlow } }))
mockUseNodes.mockReturnValueOnce([] as any)
const { rerender } = render(
<ChangeBlock
nodeId="trigger-node"
nodeData={{ type: BlockEnum.TriggerWebhook } as any}
sourceHandle="source"
/>,
)
expect(screen.getByText('available:http-request')).toBeInTheDocument()
expect(screen.getByText('show-start:true')).toBeInTheDocument()
expect(screen.getByText('ignore:trigger-node')).toBeInTheDocument()
expect(screen.getByText('allow-start:true')).toBeInTheDocument()
mockUseAvailableBlocks.mockReturnValueOnce({
getAvailableBlocks: vi.fn(() => ({
availablePrevBlocks: [BlockEnum.Code],
availableNextBlocks: [],
})),
availablePrevBlocks: [BlockEnum.Code],
availableNextBlocks: [],
} as ReturnType<typeof useAvailableBlocks>)
mockUseHooksStore.mockImplementationOnce((selector: any) => selector({ configsMap: { flowType: FlowType.ragPipeline } }))
mockUseNodes.mockReturnValueOnce([{ id: 'start', position: { x: 0, y: 0 }, data: { type: BlockEnum.Start } as any }] as any)
rerender(
<ChangeBlock
nodeId="start-node"
nodeData={{ type: BlockEnum.Start } as any}
sourceHandle="source"
/>,
)
expect(screen.getByText('available:code')).toBeInTheDocument()
expect(screen.getByText('show-start:false')).toBeInTheDocument()
expect(screen.getByText('ignore:start-node')).toBeInTheDocument()
expect(screen.getByText('force-start:true')).toBeInTheDocument()
})
it('should run, copy, duplicate, delete, and expose the help link in the popup', async () => {
const user = userEvent.setup()
renderWorkflowFlowComponent(
<PanelOperatorPopup
id="node-1"
data={{ type: BlockEnum.Code, title: 'Code Node', desc: '' } as any}
onClosePopup={vi.fn()}
showHelpLink
/>,
{
nodes: [],
edges: [{ id: 'edge-1', source: 'node-0', target: 'node-1', sourceHandle: 'branch-a' }],
},
)
await user.click(screen.getByText('workflow.panel.runThisStep'))
await user.click(screen.getByText('workflow.common.copy'))
await user.click(screen.getByText('workflow.common.duplicate'))
await user.click(screen.getByText('common.operation.delete'))
expect(handleNodeSelect).toHaveBeenCalledWith('node-1')
expect(handleNodeDataUpdate).toHaveBeenCalledWith({ id: 'node-1', data: { _isSingleRun: true } })
expect(handleSyncWorkflowDraft).toHaveBeenCalledWith(true)
expect(handleNodesCopy).toHaveBeenCalledWith('node-1')
expect(handleNodesDuplicate).toHaveBeenCalledWith('node-1')
expect(handleNodeDelete).toHaveBeenCalledWith('node-1')
expect(screen.getByRole('link', { name: 'workflow.panel.helpLink' })).toHaveAttribute('href', 'https://docs.example.com/node')
})
it('should hide change action when node is undeletable', () => {
mockUseNodeMetaData.mockReturnValueOnce({
isTypeFixed: false,
isSingleton: true,
isUndeletable: true,
description: 'Undeletable node',
author: 'Dify',
} as ReturnType<typeof useNodeMetaData>)
renderWorkflowFlowComponent(
<PanelOperatorPopup
id="node-4"
data={{ type: BlockEnum.Code, title: 'Undeletable node', desc: '' } as any}
onClosePopup={vi.fn()}
showHelpLink={false}
/>,
{
nodes: [],
edges: [],
},
)
expect(screen.getByText('workflow.panel.runThisStep')).toBeInTheDocument()
expect(screen.queryByText('workflow.panel.change')).not.toBeInTheDocument()
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
})
it('should render workflow-tool and readonly popup variants', () => {
mockUseAllWorkflowTools.mockReturnValueOnce({
data: [{ id: 'workflow-tool', workflow_app_id: 'app-123' }],
} as any)
const { rerender } = renderWorkflowFlowComponent(
<PanelOperatorPopup
id="node-2"
data={{ type: BlockEnum.Tool, title: 'Workflow Tool', desc: '', provider_type: 'workflow', provider_id: 'workflow-tool' } as any}
onClosePopup={vi.fn()}
showHelpLink={false}
/>,
{
nodes: [],
edges: [],
},
)
expect(screen.getByRole('link', { name: 'workflow.panel.openWorkflow' })).toHaveAttribute('href', '/app/app-123/workflow')
mockUseNodesReadOnly.mockReturnValueOnce({ nodesReadOnly: true } as ReturnType<typeof useNodesReadOnly>)
mockUseNodeMetaData.mockReturnValueOnce({
isTypeFixed: true,
isSingleton: true,
isUndeletable: true,
description: 'Read only node',
author: 'Dify',
} as ReturnType<typeof useNodeMetaData>)
rerender(
<PanelOperatorPopup
id="node-3"
data={{ type: BlockEnum.End, title: 'Read only node', desc: '' } as any}
onClosePopup={vi.fn()}
showHelpLink={false}
/>,
)
expect(screen.queryByText('workflow.panel.runThisStep')).not.toBeInTheDocument()
expect(screen.queryByText('workflow.common.copy')).not.toBeInTheDocument()
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
})
})
})

View File

@ -1,86 +0,0 @@
import type { OffsetOptions } from '@floating-ui/react'
import type { Node } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import {
memo,
useCallback,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import PanelOperatorPopup from './panel-operator-popup'
type PanelOperatorProps = {
id: string
data: Node['data']
triggerClassName?: string
offset?: OffsetOptions | number
onOpenChange?: (open: boolean) => void
showHelpLink?: boolean
}
const PanelOperator = ({
id,
data,
triggerClassName,
offset = {
mainAxis: 4,
crossAxis: 53,
},
onOpenChange,
showHelpLink = true,
}: PanelOperatorProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const sideOffset = typeof offset === 'number'
? offset
: typeof offset === 'object' && offset && 'mainAxis' in offset && typeof offset.mainAxis === 'number'
? offset.mainAxis
: 4
const alignOffset = typeof offset === 'object' && offset && 'crossAxis' in offset && typeof offset.crossAxis === 'number'
? offset.crossAxis
: 0
const handleOpenChange = useCallback((nextOpen: boolean) => {
setOpen(nextOpen)
onOpenChange?.(nextOpen)
}, [onOpenChange])
return (
<DropdownMenu
modal={false}
open={open}
onOpenChange={handleOpenChange}
>
<DropdownMenuTrigger
render={<button type="button" />}
aria-label={t('operation.more', { ns: 'common' })}
className={cn(
'nodrag nopan nowheel flex h-6 w-6 cursor-pointer items-center justify-center rounded-md hover:bg-state-base-hover',
'data-[popup-open]:bg-state-base-hover',
triggerClassName,
)}
>
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={sideOffset}
alignOffset={alignOffset}
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
>
<PanelOperatorPopup
id={id}
data={data}
onClosePopup={() => setOpen(false)}
showHelpLink={showHelpLink}
/>
</DropdownMenuContent>
</DropdownMenu>
)
}
export default memo(PanelOperator)

View File

@ -1,207 +0,0 @@
import type { Node } from '@/app/components/workflow/types'
import {
memo,
useMemo,
} from 'react'
import { useTranslation } from 'react-i18next'
import { useEdges } from 'reactflow'
import { CollectionType } from '@/app/components/tools/types'
import {
useNodeDataUpdate,
useNodeMetaData,
useNodesInteractions,
useNodesReadOnly,
useNodesSyncDraft,
} from '@/app/components/workflow/hooks'
import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd'
import { BlockEnum } from '@/app/components/workflow/types'
import {
canRunBySingle,
} from '@/app/components/workflow/utils'
import { useAllWorkflowTools } from '@/service/use-tools'
import { canFindTool } from '@/utils'
import ChangeBlock from './change-block'
type PanelOperatorPopupProps = {
id: string
data: Node['data']
onClosePopup: () => void
showHelpLink?: boolean
}
const PanelOperatorPopup = ({
id,
data,
onClosePopup,
showHelpLink,
}: PanelOperatorPopupProps) => {
const { t } = useTranslation()
const edges = useEdges()
const {
handleNodeDelete,
handleNodesDuplicate,
handleNodeSelect,
handleNodesCopy,
} = useNodesInteractions()
const { handleNodeDataUpdate } = useNodeDataUpdate()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const { nodesReadOnly } = useNodesReadOnly()
const edge = edges.find(edge => edge.target === id)
const nodeMetaData = useNodeMetaData({ id, data } as Node)
const showChangeBlock = !nodeMetaData.isTypeFixed && !nodeMetaData.isUndeletable && !nodesReadOnly
const isChildNode = !!(data.isInIteration || data.isInLoop)
const { data: workflowTools } = useAllWorkflowTools()
const isWorkflowTool = data.type === BlockEnum.Tool && data.provider_type === CollectionType.workflow
const workflowAppId = useMemo(() => {
if (!isWorkflowTool || !workflowTools || !data.provider_id)
return undefined
const workflowTool = workflowTools.find(item => canFindTool(item.id, data.provider_id))
return workflowTool?.workflow_app_id
}, [isWorkflowTool, workflowTools, data.provider_id])
return (
<div className="w-[240px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl">
{
(showChangeBlock || canRunBySingle(data.type, isChildNode)) && (
<>
<div className="p-1">
{
canRunBySingle(data.type, isChildNode) && (
<button
type="button"
className={`
flex h-8 w-full cursor-pointer items-center rounded-lg px-3 text-sm text-text-secondary
hover:bg-state-base-hover
`}
onClick={() => {
handleNodeSelect(id)
handleNodeDataUpdate({ id, data: { _isSingleRun: true } })
handleSyncWorkflowDraft(true)
onClosePopup()
}}
>
{t('panel.runThisStep', { ns: 'workflow' })}
</button>
)
}
{
showChangeBlock && (
<ChangeBlock
nodeId={id}
nodeData={data}
sourceHandle={edge?.sourceHandle || 'source'}
/>
)
}
</div>
<div className="h-px bg-divider-regular"></div>
</>
)
}
{
!nodesReadOnly && (
<>
{
!nodeMetaData.isSingleton && (
<>
<div className="p-1">
<button
type="button"
className="flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => {
onClosePopup()
handleNodesCopy(id)
}}
>
{t('common.copy', { ns: 'workflow' })}
<ShortcutKbd shortcut="workflow.copy" />
</button>
<button
type="button"
className="flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => {
onClosePopup()
handleNodesDuplicate(id)
}}
>
{t('common.duplicate', { ns: 'workflow' })}
<ShortcutKbd shortcut="workflow.duplicate" />
</button>
</div>
<div className="h-px bg-divider-regular"></div>
</>
)
}
{
!nodeMetaData.isUndeletable && (
<>
<div className="p-1">
<button
type="button"
className={`
flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary
hover:bg-state-destructive-hover hover:text-text-destructive
`}
onClick={() => handleNodeDelete(id)}
>
{t('operation.delete', { ns: 'common' })}
<ShortcutKbd shortcut="workflow.delete" />
</button>
</div>
<div className="h-px bg-divider-regular"></div>
</>
)
}
</>
)
}
{
isWorkflowTool && workflowAppId && (
<>
<div className="p-1">
<a
href={`/app/${workflowAppId}/workflow`}
target="_blank"
className="flex h-8 cursor-pointer items-center rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
>
{t('panel.openWorkflow', { ns: 'workflow' })}
</a>
</div>
<div className="h-px bg-divider-regular"></div>
</>
)
}
{
showHelpLink && nodeMetaData.helpLinkUri && (
<>
<div className="p-1">
<a
href={nodeMetaData.helpLinkUri}
target="_blank"
className="flex h-8 cursor-pointer items-center rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
>
{t('panel.helpLink', { ns: 'workflow' })}
</a>
</div>
<div className="h-px bg-divider-regular"></div>
</>
)
}
<div className="p-1">
<div className="px-3 py-2 text-xs text-text-tertiary">
<div className="mb-1 flex h-[22px] items-center font-medium">
{t('panel.about', { ns: 'workflow' }).toLocaleUpperCase()}
</div>
<div className="mb-1 leading-[18px] text-text-secondary">{nodeMetaData.description}</div>
<div className="leading-[18px]">
{t('panel.createdBy', { ns: 'workflow' })}
{' '}
{nodeMetaData.author}
</div>
</div>
</div>
</div>
)
}
export default memo(PanelOperatorPopup)

View File

@ -95,6 +95,7 @@ export const SwitchPluginVersion: FC<SwitchPluginVersionProps> = (props) => {
className="flex items-center justify-center gap-1"
href={getMarketplaceUrl(`/plugins/${pluginDetail.declaration.author}/${pluginDetail.declaration.name}`)}
target="_blank"
rel="noopener noreferrer"
>
<span className="system-xs-regular text-xs text-text-accent">
{t('nodes.agent.installPlugin.changelog', { ns: 'workflow' })}

View File

@ -241,8 +241,8 @@ vi.mock('../next-step', () => ({
default: () => <div>next-step</div>,
}))
vi.mock('../panel-operator', () => ({
default: () => <div>panel-operator</div>,
vi.mock('@/app/components/workflow/node-actions-menu', () => ({
NodeActionsDropdown: () => <div>node-actions-menu</div>,
}))
vi.mock('../retry/retry-on-panel', () => ({
@ -349,8 +349,9 @@ describe('workflow-panel index', () => {
expect(mockSaveStateToHistory).toHaveBeenCalled()
fireEvent.click(screen.getByText('authorized-in-node'))
fireEvent.click(screen.getByRole('button', { name: 'workflow.panel.runThisStep' }))
const clickableItems = container.querySelectorAll('.cursor-pointer')
fireEvent.click(clickableItems[0] as HTMLElement)
fireEvent.click(clickableItems[clickableItems.length - 1] as HTMLElement)
expect(mockHandleSingleRun).toHaveBeenCalledTimes(1)
@ -587,8 +588,7 @@ describe('workflow-panel index', () => {
expect(root.style.right).toBe('240px')
expect(root.className).toContain('absolute')
const clickableItems = container.querySelectorAll('.cursor-pointer')
fireEvent.click(clickableItems[0] as HTMLElement)
fireEvent.click(screen.getByRole('button', { name: 'workflow.debug.variableInspect.trigger.stop' }))
expect(mockHandleStop).toHaveBeenCalledTimes(1)
})

View File

@ -2,6 +2,11 @@ import type { FC, ReactNode } from 'react'
import type { SimpleSubscription } from '@/app/components/plugins/plugin-detail-panel/subscription-list'
import type { Node } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@langgenius/dify-ui/tooltip'
import {
RiCloseLine,
RiPlayLargeLine,
@ -21,7 +26,6 @@ import { useTranslation } from 'react-i18next'
import { useShallow } from 'zustand/react/shallow'
import { useStore as useAppStore } from '@/app/components/app/store'
import { Stop } from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
import Tooltip from '@/app/components/base/tooltip'
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
@ -49,6 +53,7 @@ import {
} from '@/app/components/workflow/hooks'
import { useHooksStore } from '@/app/components/workflow/hooks-store'
import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud'
import { NodeActionsDropdown } from '@/app/components/workflow/node-actions-menu'
import Split from '@/app/components/workflow/nodes/_base/components/split'
import { useLogs } from '@/app/components/workflow/run/hooks'
import SpecialResultPanel from '@/app/components/workflow/run/special-result-panel'
@ -71,7 +76,6 @@ import PanelWrap from '../before-run-form/panel-wrap'
import ErrorHandleOnPanel from '../error-handle/error-handle-on-panel'
import HelpLink from '../help-link'
import NextStep from '../next-step'
import PanelOperator from '../panel-operator'
import RetryOnPanel from '../retry/retry-on-panel'
import { DescriptionInput, TitleInput } from '../title-description-input'
import {
@ -469,6 +473,11 @@ const BasePanel: FC<BasePanelProps> = ({
)
}
const runThisStepLabel = t('panel.runThisStep', { ns: 'workflow' })
const singleRunActionLabel = isSingleRunning
? t('debug.variableInspect.trigger.stop', { ns: 'workflow' })
: runThisStepLabel
return (
<div
className={cn(
@ -516,31 +525,36 @@ const BasePanel: FC<BasePanelProps> = ({
<div className="flex shrink-0 items-center text-text-tertiary">
{
isSupportSingleRun && !nodesReadOnly && (
<Tooltip
popupContent={t('panel.runThisStep', { ns: 'workflow' })}
popupClassName="mr-1"
disabled={isSingleRunning}
>
<div
className="mr-1 flex h-6 w-6 cursor-pointer items-center justify-center rounded-md hover:bg-state-base-hover"
onClick={() => {
if (isSingleRunning)
handleStop()
else
handleSingleRun()
}}
>
{
isSingleRunning
? <Stop className="h-4 w-4 text-text-tertiary" />
: <RiPlayLargeLine className="h-4 w-4 text-text-tertiary" />
}
</div>
<Tooltip disabled={isSingleRunning}>
<TooltipTrigger
render={(
<button
type="button"
aria-label={singleRunActionLabel}
className="mr-1 flex h-6 w-6 cursor-pointer items-center justify-center rounded-md border-0 bg-transparent p-0 hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:outline-hidden"
onClick={() => {
if (isSingleRunning)
handleStop()
else
handleSingleRun()
}}
>
{
isSingleRunning
? <Stop aria-hidden className="h-4 w-4 text-text-tertiary" />
: <RiPlayLargeLine aria-hidden className="h-4 w-4 text-text-tertiary" />
}
</button>
)}
/>
<TooltipContent className="mr-1">
{runThisStepLabel}
</TooltipContent>
</Tooltip>
)
}
<HelpLink nodeType={data.type} />
<PanelOperator id={id} data={data} showHelpLink={false} />
<NodeActionsDropdown id={id} data={data} showHelpLink={false} />
<div className="mx-3 h-3.5 w-px bg-divider-regular" />
<div
className="flex h-6 w-6 cursor-pointer items-center justify-center"

View File

@ -66,7 +66,7 @@ const Panel: FC<NodePanelProps<DocExtractorNodeType>> = ({
/>
<div className="mt-1 py-0.5 body-xs-regular text-text-tertiary">
{t(`${i18nPrefix}.supportFileTypes`, { ns: 'workflow', types: supportTypesShowNames })}
<a className="text-text-accent" href={link} target="_blank">{t(`${i18nPrefix}.learnMore`, { ns: 'workflow' })}</a>
<a className="text-text-accent" href={link} target="_blank" rel="noopener noreferrer">{t(`${i18nPrefix}.learnMore`, { ns: 'workflow' })}</a>
</div>
</>
</Field>

View File

@ -1,10 +1,10 @@
'use client'
import type { FC } from 'react'
import type { ModelConfig, PromptItem, Variable } from '../../../types'
import type { ModelConfig, Node, NodeOutPutVar, PromptItem, Variable } from '../../../types'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
import { Infotip } from '@/app/components/base/infotip'
import Editor from '@/app/components/workflow/nodes/_base/components/prompt/editor'
import TypeSelector from '@/app/components/workflow/nodes/_base/components/selector'
import { PromptRole } from '@/models/debug'
@ -35,10 +35,10 @@ type Props = {
history: boolean
query: boolean
}
availableVars: any
availableNodes: any
availableVars: NodeOutPutVar[]
availableNodes: Node[]
varList: Variable[]
handleAddVariable: (payload: any) => void
handleAddVariable: (payload: Variable) => void
modelConfig?: ModelConfig
}
@ -119,12 +119,14 @@ const ConfigPromptItem: FC<Props> = ({
/>
)}
<Tooltip
popupContent={
<div className="max-w-[180px]">{!!payload.role && t(`${i18nPrefix}.roleDescription.${payload.role}`, { ns: 'workflow' })}</div>
}
triggerClassName="w-4 h-4"
/>
{!!payload.role && (
<Infotip
aria-label={t(`${i18nPrefix}.roleDescription.${payload.role}`, { ns: 'workflow' })}
popupClassName="w-[180px]"
>
{t(`${i18nPrefix}.roleDescription.${payload.role}`, { ns: 'workflow' })}
</Infotip>
)}
</div>
)}
value={payload.edition_type === EditionType.jinja2 ? (payload.jinja2_text || '') : payload.text}

View File

@ -1,9 +1,9 @@
import type { FC } from 'react'
import type { LLMNodeType } from '../types'
import type { Memory, Node, NodeOutPutVar } from '@/app/components/workflow/types'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { Infotip } from '@/app/components/base/infotip'
import MemoryConfig from '@/app/components/workflow/nodes/_base/components/memory-config'
import Editor from '@/app/components/workflow/nodes/_base/components/prompt/editor'
@ -50,18 +50,9 @@ const PanelMemorySection: FC<Props> = ({
<div className="flex h-8 items-center justify-between rounded-lg bg-components-input-bg-normal pr-2 pl-3">
<div className="flex items-center space-x-1">
<div className="text-xs font-semibold text-text-secondary uppercase">{t('nodes.common.memories.title', { ns: 'workflow' })}</div>
<Tooltip>
<TooltipTrigger
render={(
<span className="ml-1 flex h-4 w-4 shrink-0 items-center justify-center">
<span aria-hidden className="i-ri-question-line h-3.5 w-3.5 text-text-quaternary hover:text-text-tertiary" />
</span>
)}
/>
<TooltipContent>
{t('nodes.common.memories.tip', { ns: 'workflow' })}
</TooltipContent>
</Tooltip>
<Infotip aria-label={t('nodes.common.memories.tip', { ns: 'workflow' })}>
{t('nodes.common.memories.tip', { ns: 'workflow' })}
</Infotip>
</div>
<div className="flex h-[18px] items-center rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 text-xs font-semibold text-text-tertiary uppercase">
{t('nodes.common.memories.builtIn', { ns: 'workflow' })}
@ -72,18 +63,12 @@ const PanelMemorySection: FC<Props> = ({
title={(
<div className="flex items-center space-x-1">
<div className="text-xs font-semibold text-text-secondary uppercase">user</div>
<Tooltip>
<TooltipTrigger
render={(
<span className="ml-1 flex h-4 w-4 shrink-0 items-center justify-center">
<span aria-hidden className="i-ri-question-line h-3.5 w-3.5 text-text-quaternary hover:text-text-tertiary" />
</span>
)}
/>
<TooltipContent>
<div className="max-w-[180px]">{t('nodes.llm.roleDescription.user', { ns: 'workflow' })}</div>
</TooltipContent>
</Tooltip>
<Infotip
aria-label={t('nodes.llm.roleDescription.user', { ns: 'workflow' })}
popupClassName="w-[180px]"
>
{t('nodes.llm.roleDescription.user', { ns: 'workflow' })}
</Infotip>
</div>
)}
value={inputs.memory.query_prompt_template || '{{#sys.query#}}'}

View File

@ -2,9 +2,9 @@ import type { FC } from 'react'
import type { LLMNodeType, StructuredOutput } from '../types'
import { Switch } from '@langgenius/dify-ui/switch'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { RiAlertFill, RiQuestionLine } from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { Infotip } from '@/app/components/base/infotip'
import OutputVars, { VarItem } from '@/app/components/workflow/nodes/_base/components/output-vars'
import Split from '@/app/components/workflow/nodes/_base/components/split'
import StructureOutput from './structure-output'
@ -45,31 +45,23 @@ const PanelOutputSection: FC<Props> = ({
<TooltipTrigger
render={(
<div>
<RiAlertFill className="mr-1 size-4 text-text-warning-secondary" />
<span className="mr-1 i-ri-alert-fill size-4 text-text-warning-secondary" />
</div>
)}
/>
<TooltipContent>
<div className="w-[232px] rounded-xl border-[0.5px] border-components-panel-border bg-components-tooltip-bg px-4 py-3.5 shadow-lg backdrop-blur-[5px]">
<div className="title-xs-semi-bold text-text-primary">{t('structOutput.modelNotSupported', { ns: 'app' })}</div>
<div className="mt-1 body-xs-regular text-text-secondary">{t('structOutput.modelNotSupportedTip', { ns: 'app' })}</div>
</div>
<TooltipContent className="w-[232px] rounded-xl border-[0.5px] border-components-panel-border bg-components-tooltip-bg px-4 py-3.5 shadow-lg backdrop-blur-[5px]">
<div className="title-xs-semi-bold text-text-primary">{t('structOutput.modelNotSupported', { ns: 'app' })}</div>
<div className="mt-1 body-xs-regular text-text-secondary">{t('structOutput.modelNotSupportedTip', { ns: 'app' })}</div>
</TooltipContent>
</Tooltip>
)}
<div className="mr-0.5 system-xs-medium-uppercase text-text-tertiary">{t('structOutput.structured', { ns: 'app' })}</div>
<Tooltip>
<TooltipTrigger
render={(
<div>
<RiQuestionLine className="size-3.5 text-text-quaternary" />
</div>
)}
/>
<TooltipContent>
<div className="max-w-[150px]">{t('structOutput.structuredTip', { ns: 'app' })}</div>
</TooltipContent>
</Tooltip>
<Infotip
aria-label={t('structOutput.structuredTip', { ns: 'app' })}
popupClassName="w-[150px]"
>
{t('structOutput.structuredTip', { ns: 'app' })}
</Infotip>
<Switch
className="ml-2"
checked={!!inputs.structured_output_enabled}

View File

@ -77,6 +77,7 @@ const Panel: FC<NodePanelProps<TemplateTransformNodeType>> = ({
className="flex h-[18px] items-center space-x-0.5 text-xs font-normal text-text-tertiary"
href="https://jinja.palletsprojects.com/en/3.1.x/templates/"
target="_blank"
rel="noopener noreferrer"
>
<span>{t(`${i18nPrefix}.codeSupportTip`, { ns: 'workflow' })}</span>
<RiQuestionLine className="h-3 w-3" />

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