From 87add9a4f3d5b91adb577a8b7725c880620a5799 Mon Sep 17 00:00:00 2001 From: aliworksx08 <57456290+aliworksx08@users.noreply.github.com> Date: Thu, 30 Apr 2026 04:08:29 -1000 Subject: [PATCH 01/21] refactor: replace Any with [T] syntax (#35750) Co-authored-by: Asuka Minato --- scripts/stress-test/common/config_helper.py | 88 ++++++++++++++----- .../stress-test/setup/mock_openai_server.py | 13 ++- 2 files changed, 71 insertions(+), 30 deletions(-) diff --git a/scripts/stress-test/common/config_helper.py b/scripts/stress-test/common/config_helper.py index fb34b43e26..d7ea8d0fea 100644 --- a/scripts/stress-test/common/config_helper.py +++ b/scripts/stress-test/common/config_helper.py @@ -2,7 +2,43 @@ import json from pathlib import Path -from typing import Any +from typing import NotRequired, TypedDict + + +class AdminConfig(TypedDict): + """Configuration for admin section.""" + username: str + password: str + base_url: str + + +class AuthConfig(TypedDict): + """Configuration for authentication section.""" + access_token: str + refresh_token: NotRequired[str] + expires_at: NotRequired[int] + + +class AppConfig(TypedDict): + """Configuration for app section.""" + app_id: str + app_name: NotRequired[str] + description: NotRequired[str] + + +class ApiKeyConfig(TypedDict): + """Configuration for API key section.""" + token: str + key_name: NotRequired[str] + expires_at: NotRequired[int] + + +class StressTestState(TypedDict): + """Complete stress test state structure.""" + admin: NotRequired[AdminConfig] + auth: NotRequired[AuthConfig] + app: NotRequired[AppConfig] + api_key: NotRequired[ApiKeyConfig] class ConfigHelper: @@ -44,8 +80,8 @@ class ConfigHelper: filename += ".json" return self.base_dir / filename - def read_config(self, filename: str) -> dict[str, Any] | None: - """Read a configuration file. + def read_config[T](self, filename: str) -> T | None: + """Read a configuration file with generic return type. DEPRECATED: Use read_state() or get_state_section() for new code. This method provides backward compatibility. @@ -54,11 +90,12 @@ class ConfigHelper: filename: Name of the config file to read Returns: - Dictionary containing config data, or None if file doesn't exist + Configuration data of type T, or None if file doesn't exist """ # Provide backward compatibility for old config names if filename in self._LEGACY_SECTION_MAP: - return self.get_state_section(self._LEGACY_SECTION_MAP[filename]) + section_data = self.get_state_section(self._LEGACY_SECTION_MAP[filename]) + return section_data # type: ignore config_path = self.get_config_path(filename) @@ -67,12 +104,12 @@ class ConfigHelper: try: with open(config_path) as f: - return json.load(f) + return json.load(f) # type: ignore except (OSError, json.JSONDecodeError) as e: print(f"❌ Error reading {filename}: {e}") return None - def write_config(self, filename: str, data: dict[str, Any]) -> bool: + def write_config[T](self, filename: str, data: T) -> bool: """Write data to a configuration file. DEPRECATED: Use write_state() or update_state_section() for new code. @@ -80,7 +117,7 @@ class ConfigHelper: Args: filename: Name of the config file to write - data: Dictionary containing data to save + data: Data to save (must be JSON serializable) Returns: True if successful, False otherwise @@ -89,7 +126,7 @@ class ConfigHelper: if filename in self._LEGACY_SECTION_MAP: return self.update_state_section( self._LEGACY_SECTION_MAP[filename], - data, + data, # type: ignore ) self.ensure_config_dir() @@ -97,7 +134,7 @@ class ConfigHelper: try: with open(config_path, "w") as f: - json.dump(data, f, indent=2) + json.dump(data, f, indent=2) # type: ignore return True except OSError as e: print(f"❌ Error writing {filename}: {e}") @@ -135,7 +172,7 @@ class ConfigHelper: print(f"❌ Error deleting {filename}: {e}") return False - def read_state(self) -> dict[str, Any] | None: + def read_state(self) -> StressTestState | None: """Read the entire stress test state. Returns: @@ -147,12 +184,17 @@ class ConfigHelper: try: with open(state_path) as f: - return json.load(f) + data = json.load(f) + # Validate basic structure + if not isinstance(data, dict): + print(f"❌ Invalid state format in {self.state_file}") + return None + return data # type: ignore except (OSError, json.JSONDecodeError) as e: print(f"❌ Error reading {self.state_file}: {e}") return None - def write_state(self, data: dict[str, Any]) -> bool: + def write_state(self, data: StressTestState) -> bool: """Write the entire stress test state. Args: @@ -172,32 +214,32 @@ class ConfigHelper: print(f"❌ Error writing {self.state_file}: {e}") return False - def update_state_section(self, section: str, data: dict[str, Any]) -> bool: + def update_state_section[T](self, section: str, data: T) -> bool: """Update a specific section of the stress test state. Args: section: Name of the section to update (e.g., 'admin', 'auth', 'app', 'api_key') - data: Dictionary containing section data to save + data: Section data to save Returns: True if successful, False otherwise """ state = self.read_state() or {} - state[section] = data - return self.write_state(state) + state[section] = data # type: ignore + return self.write_state(state) # type: ignore - def get_state_section(self, section: str) -> dict[str, Any] | None: + def get_state_section[T](self, section: str) -> T | None: """Get a specific section from the stress test state. Args: section: Name of the section to get (e.g., 'admin', 'auth', 'app', 'api_key') Returns: - Dictionary containing section data, or None if not found + Section data of type T, or None if not found """ state = self.read_state() if state: - return state.get(section) + return state.get(section) # type: ignore return None def get_token(self) -> str | None: @@ -206,7 +248,7 @@ class ConfigHelper: Returns: Access token string or None if not found """ - auth = self.get_state_section("auth") + auth = self.get_state_section[AuthConfig]("auth") if auth: return auth.get("access_token") return None @@ -217,7 +259,7 @@ class ConfigHelper: Returns: App ID string or None if not found """ - app = self.get_state_section("app") + app = self.get_state_section[AppConfig]("app") if app: return app.get("app_id") return None @@ -228,7 +270,7 @@ class ConfigHelper: Returns: API key token string or None if not found """ - api_key = self.get_state_section("api_key") + api_key = self.get_state_section[ApiKeyConfig]("api_key") if api_key: return api_key.get("token") return None diff --git a/scripts/stress-test/setup/mock_openai_server.py b/scripts/stress-test/setup/mock_openai_server.py index 7333c66e57..1ac682bab9 100755 --- a/scripts/stress-test/setup/mock_openai_server.py +++ b/scripts/stress-test/setup/mock_openai_server.py @@ -4,7 +4,6 @@ import json import time import uuid from collections.abc import Iterator -from typing import Any from flask import Flask, Response, jsonify, request @@ -29,13 +28,13 @@ MODELS = [ @app.route("/v1/models", methods=["GET"]) -def list_models() -> Any: +def list_models() -> Response: """List available models.""" return jsonify({"object": "list", "data": MODELS}) @app.route("/v1/chat/completions", methods=["POST"]) -def chat_completions() -> Any: +def chat_completions() -> Response: """Handle chat completions.""" data = request.json or {} model = data.get("model", "gpt-3.5-turbo") @@ -123,7 +122,7 @@ def chat_completions() -> Any: @app.route("/v1/completions", methods=["POST"]) -def completions() -> Any: +def completions() -> Response: """Handle text completions.""" data = request.json or {} model = data.get("model", "gpt-3.5-turbo-instruct") @@ -155,7 +154,7 @@ def completions() -> Any: @app.route("/v1/embeddings", methods=["POST"]) -def embeddings() -> Any: +def embeddings() -> Response: """Handle embeddings requests.""" data = request.json or {} model = data.get("model", "text-embedding-ada-002") @@ -178,7 +177,7 @@ def embeddings() -> Any: @app.route("/v1/models/", methods=["GET"]) -def get_model(model_id: str) -> tuple[Any, int] | Any: +def get_model(model_id: str) -> Response | tuple[Response, int]: """Get specific model details.""" for model in MODELS: if model["id"] == model_id: @@ -188,7 +187,7 @@ def get_model(model_id: str) -> tuple[Any, int] | Any: @app.route("/health", methods=["GET"]) -def health() -> Any: +def health() -> Response: """Health check endpoint.""" return jsonify({"status": "healthy"}) From 54bde0bdf61fb902330fe79133740ea00ff48f44 Mon Sep 17 00:00:00 2001 From: Prince Pal <107296821+princepal9120@users.noreply.github.com> Date: Fri, 1 May 2026 14:50:14 +0530 Subject: [PATCH 02/21] fix(api): preserve dataset metadata filters (#35700) --- .../knowledge_entities/knowledge_entities.py | 2 + .../service_api/dataset/test_hit_testing.py | 56 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/api/services/entities/knowledge_entities/knowledge_entities.py b/api/services/entities/knowledge_entities/knowledge_entities.py index b1fe352861..910f54bebc 100644 --- a/api/services/entities/knowledge_entities/knowledge_entities.py +++ b/api/services/entities/knowledge_entities/knowledge_entities.py @@ -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): diff --git a/api/tests/unit_tests/controllers/service_api/dataset/test_hit_testing.py b/api/tests/unit_tests/controllers/service_api/dataset/test_hit_testing.py index 9be8e56f56..a26cdf6563 100644 --- a/api/tests/unit_tests/controllers/service_api/dataset/test_hit_testing.py +++ b/api/tests/unit_tests/controllers/service_api/dataset/test_hit_testing.py @@ -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") From 955c25589d7ca2b8ba44bb749ef89aefeaed4b80 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Sat, 2 May 2026 18:30:54 +0900 Subject: [PATCH 03/21] ci: Remove API contracts generation step from autofix workflow (#35768) --- .github/workflows/autofix.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index e042272bca..76fbd18f47 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -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: | From ff5c2c57a1704cfce5e3e60c931ea4b35c1fc44f Mon Sep 17 00:00:00 2001 From: Jonathan Chang Date: Sat, 2 May 2026 08:45:31 -0600 Subject: [PATCH 04/21] fix: Clean upload filenames parsed from URLs (#35706) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/controllers/common/helpers.py | 3 +- api/factories/file_factory/remote.py | 18 ++++-- .../unit_tests/factories/test_file_factory.py | 61 +++++++++++++++++++ 3 files changed, 77 insertions(+), 5 deletions(-) diff --git a/api/controllers/common/helpers.py b/api/controllers/common/helpers.py index ef89e66980..84903733b5 100644 --- a/api/controllers/common/helpers.py +++ b/api/controllers/common/helpers.py @@ -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: diff --git a/api/factories/file_factory/remote.py b/api/factories/file_factory/remote.py index e5a7186007..9b8f94b1f3 100644 --- a/api/factories/file_factory/remote.py +++ b/api/factories/file_factory/remote.py @@ -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) diff --git a/api/tests/unit_tests/factories/test_file_factory.py b/api/tests/unit_tests/factories/test_file_factory.py index 5b105d6084..c2835c4124 100644 --- a/api/tests/unit_tests/factories/test_file_factory.py +++ b/api/tests/unit_tests/factories/test_file_factory.py @@ -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" From 3708e3eef1da600e181d8e551d332953841eca18 Mon Sep 17 00:00:00 2001 From: guangyang1206 Date: Sun, 3 May 2026 01:49:20 +0800 Subject: [PATCH 05/21] refactor(tests): use db_session_with_containers in test_storage_key_loader (#35766) Co-authored-by: yeranyang Co-authored-by: Asuka Minato Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../factories/test_storage_key_loader.py | 469 +++++++++--------- scripts/stress-test/common/config_helper.py | 5 + 2 files changed, 246 insertions(+), 228 deletions(-) diff --git a/api/tests/test_containers_integration_tests/factories/test_storage_key_loader.py b/api/tests/test_containers_integration_tests/factories/test_storage_key_loader.py index 35e41035df..26b80cebbb 100644 --- a/api/tests/test_containers_integration_tests/factories/test_storage_key_loader.py +++ b/api/tests/test_containers_integration_tests/factories/test_storage_key_loader.py @@ -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): diff --git a/scripts/stress-test/common/config_helper.py b/scripts/stress-test/common/config_helper.py index d7ea8d0fea..fffb5e00d8 100644 --- a/scripts/stress-test/common/config_helper.py +++ b/scripts/stress-test/common/config_helper.py @@ -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] From 7ba408eebe09b8cabb8d6b61168b5e186f0d11d1 Mon Sep 17 00:00:00 2001 From: NeatGuyCoding <15627489+NeatGuyCoding@users.noreply.github.com> Date: Sun, 3 May 2026 22:42:56 +0800 Subject: [PATCH 06/21] fix: IDOR on console `GET /account/avatar` (#35771) Signed-off-by: NeatGuyCoding <15627489+NeatGuyCoding@users.noreply.github.com> --- api/controllers/console/workspace/account.py | 20 ++- .../console/workspace/test_accounts.py | 127 ++++++++++++++++++ 2 files changed, 146 insertions(+), 1 deletion(-) diff --git a/api/controllers/console/workspace/account.py b/api/controllers/console/workspace/account.py index c01286cc59..d69a59ecb7 100644 --- a/api/controllers/console/workspace/account.py +++ b/api/controllers/console/workspace/account.py @@ -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__]) diff --git a/api/tests/unit_tests/controllers/console/workspace/test_accounts.py b/api/tests/unit_tests/controllers/console/workspace/test_accounts.py index 42be02cdaf..bbe9d09521 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_accounts.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_accounts.py @@ -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() From 2876839d7e78b928722ca04df9a67913959da863 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 16:33:12 +0900 Subject: [PATCH 07/21] chore(deps): bump the google group in /api with 2 updates (#35779) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- api/pyproject.toml | 4 ++-- api/uv.lock | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/api/pyproject.toml b/api/pyproject.toml index f47a389f31..20eb54c9da 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -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", diff --git a/api/uv.lock b/api/uv.lock index aacca7f4ab..dd09fc7579 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -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]] From cd9daef564369b3926ce7fed242a1feb5c4a451f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 16:33:38 +0900 Subject: [PATCH 08/21] chore(deps): bump anthropics/claude-code-action from 1.0.110 to 1.0.111 in the github-actions-dependencies group (#35781) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/translate-i18n-claude.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/translate-i18n-claude.yml b/.github/workflows/translate-i18n-claude.yml index 5f244f1144..7bb6fc1bbd 100644 --- a/.github/workflows/translate-i18n-claude.yml +++ b/.github/workflows/translate-i18n-claude.yml @@ -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 }} From d92c3363940ae9cec78ff345511c38671e98849a Mon Sep 17 00:00:00 2001 From: aliworksx08 <57456290+aliworksx08@users.noreply.github.com> Date: Mon, 4 May 2026 03:18:28 -0600 Subject: [PATCH 09/21] fix(web): secure external form help links (#35751) --- .../workflow/block-selector/market-place-plugin/action.tsx | 2 +- .../workflow/block-selector/market-place-plugin/list.tsx | 3 +++ .../workflow/nodes/_base/components/agent-strategy.tsx | 1 + .../nodes/_base/components/error-handle/fail-branch-card.tsx | 1 + .../components/workflow/nodes/_base/components/help-link.tsx | 2 +- .../_base/components/panel-operator/panel-operator-popup.tsx | 2 ++ .../nodes/_base/components/switch-plugin-version.tsx | 1 + .../components/workflow/nodes/document-extractor/panel.tsx | 2 +- .../components/workflow/nodes/template-transform/panel.tsx | 1 + .../note-editor/plugins/link-editor-plugin/component.tsx | 2 +- web/app/components/workflow/run/node.tsx | 1 + web/app/components/workflow/run/status.tsx | 2 ++ web/app/components/workflow/variable-inspect/right.tsx | 1 + web/app/education-apply/education-apply-page.tsx | 5 +++-- web/app/education-apply/verify-state-modal.tsx | 2 +- 15 files changed, 21 insertions(+), 7 deletions(-) diff --git a/web/app/components/workflow/block-selector/market-place-plugin/action.tsx b/web/app/components/workflow/block-selector/market-place-plugin/action.tsx index 2160008c6b..567d3b2113 100644 --- a/web/app/components/workflow/block-selector/market-place-plugin/action.tsx +++ b/web/app/components/workflow/block-selector/market-place-plugin/action.tsx @@ -96,7 +96,7 @@ const OperationDropdown: FC = ({ className="system-md-regular" href={getMarketplaceUrl(`/plugins/${author}/${name}`, { theme })} target="_blank" - rel="noreferrer" + rel="noopener noreferrer" > {t('operation.viewDetails', { ns: 'common' })} diff --git a/web/app/components/workflow/block-selector/market-place-plugin/list.tsx b/web/app/components/workflow/block-selector/market-place-plugin/list.tsx index cddc928b19..fbc9035341 100644 --- a/web/app/components/workflow/block-selector/market-place-plugin/list.tsx +++ b/web/app/components/workflow/block-selector/market-place-plugin/list.tsx @@ -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" > {t('findMoreInMarketplace', { ns: 'plugin' })} @@ -102,6 +103,7 @@ const List = ({ e.stopPropagation()} > @@ -124,6 +126,7 @@ const List = ({ diff --git a/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx b/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx index d20acd7f19..72fffd3c86 100644 --- a/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx +++ b/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx @@ -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' })} diff --git a/web/app/components/workflow/nodes/_base/components/error-handle/fail-branch-card.tsx b/web/app/components/workflow/nodes/_base/components/error-handle/fail-branch-card.tsx index 62fcb5352c..f89f6d30fa 100644 --- a/web/app/components/workflow/nodes/_base/components/error-handle/fail-branch-card.tsx +++ b/web/app/components/workflow/nodes/_base/components/error-handle/fail-branch-card.tsx @@ -21,6 +21,7 @@ const FailBranchCard = () => { {t('common.learnMore', { ns: 'workflow' })} diff --git a/web/app/components/workflow/nodes/_base/components/help-link.tsx b/web/app/components/workflow/nodes/_base/components/help-link.tsx index 298f50738f..2f6e5fa4b9 100644 --- a/web/app/components/workflow/nodes/_base/components/help-link.tsx +++ b/web/app/components/workflow/nodes/_base/components/help-link.tsx @@ -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" > diff --git a/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx b/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx index 8445677c1e..8a73d4be87 100644 --- a/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx +++ b/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx @@ -162,6 +162,7 @@ const PanelOperatorPopup = ({ {t('panel.openWorkflow', { ns: 'workflow' })} @@ -178,6 +179,7 @@ const PanelOperatorPopup = ({ {t('panel.helpLink', { ns: 'workflow' })} diff --git a/web/app/components/workflow/nodes/_base/components/switch-plugin-version.tsx b/web/app/components/workflow/nodes/_base/components/switch-plugin-version.tsx index ccd92f7296..141323e5b3 100644 --- a/web/app/components/workflow/nodes/_base/components/switch-plugin-version.tsx +++ b/web/app/components/workflow/nodes/_base/components/switch-plugin-version.tsx @@ -95,6 +95,7 @@ export const SwitchPluginVersion: FC = (props) => { className="flex items-center justify-center gap-1" href={getMarketplaceUrl(`/plugins/${pluginDetail.declaration.author}/${pluginDetail.declaration.name}`)} target="_blank" + rel="noopener noreferrer" > {t('nodes.agent.installPlugin.changelog', { ns: 'workflow' })} diff --git a/web/app/components/workflow/nodes/document-extractor/panel.tsx b/web/app/components/workflow/nodes/document-extractor/panel.tsx index 22421fd885..6190e01357 100644 --- a/web/app/components/workflow/nodes/document-extractor/panel.tsx +++ b/web/app/components/workflow/nodes/document-extractor/panel.tsx @@ -66,7 +66,7 @@ const Panel: FC> = ({ /> diff --git a/web/app/components/workflow/nodes/template-transform/panel.tsx b/web/app/components/workflow/nodes/template-transform/panel.tsx index 08f96d8999..4a6515da7a 100644 --- a/web/app/components/workflow/nodes/template-transform/panel.tsx +++ b/web/app/components/workflow/nodes/template-transform/panel.tsx @@ -77,6 +77,7 @@ const Panel: FC> = ({ 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" > {t(`${i18nPrefix}.codeSupportTip`, { ns: 'workflow' })} diff --git a/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/component.tsx b/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/component.tsx index 7e5b6c586c..804f7c6e8d 100644 --- a/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/component.tsx +++ b/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/component.tsx @@ -135,7 +135,7 @@ const LinkEditorComponent = ({ className="flex h-6 items-center rounded-md px-2 hover:bg-state-base-hover" href={escape(url)} target="_blank" - rel="noreferrer" + rel="noopener noreferrer" >
diff --git a/web/app/components/workflow/run/node.tsx b/web/app/components/workflow/run/node.tsx index 3f7e9e9999..9895868887 100644 --- a/web/app/components/workflow/run/node.tsx +++ b/web/app/components/workflow/run/node.tsx @@ -232,6 +232,7 @@ const NodePanel: FC = ({ {t('common.learnMore', { ns: 'workflow' })} diff --git a/web/app/components/workflow/run/status.tsx b/web/app/components/workflow/run/status.tsx index 667e0236a3..a13b52816d 100644 --- a/web/app/components/workflow/run/status.tsx +++ b/web/app/components/workflow/run/status.tsx @@ -212,6 +212,7 @@ const StatusPanel: FC = ({ {t('common.learnMore', { ns: 'workflow' })} @@ -244,6 +245,7 @@ const StatusPanel: FC = ({ key={url} href={url} target="_blank" + rel="noopener noreferrer" className="system-xs-medium text-text-accent" > {url} diff --git a/web/app/components/workflow/variable-inspect/right.tsx b/web/app/components/workflow/variable-inspect/right.tsx index a57ac00ca8..db58b5e557 100644 --- a/web/app/components/workflow/variable-inspect/right.tsx +++ b/web/app/components/workflow/variable-inspect/right.tsx @@ -241,6 +241,7 @@ const Right = ({ diff --git a/web/app/education-apply/education-apply-page.tsx b/web/app/education-apply/education-apply-page.tsx index 93259c1ccb..7998af6e66 100644 --- a/web/app/education-apply/education-apply-page.tsx +++ b/web/app/education-apply/education-apply-page.tsx @@ -123,11 +123,11 @@ const EducationApplyAge = () => {
{t('form.terms.desc.front', { ns: 'education' })}   - {t('form.terms.desc.termsOfService', { ns: 'education' })} + {t('form.terms.desc.termsOfService', { ns: 'education' })}   {t('form.terms.desc.and', { ns: 'education' })}   - {t('form.terms.desc.privacyPolicy', { ns: 'education' })} + {t('form.terms.desc.privacyPolicy', { ns: 'education' })} {t('form.terms.desc.end', { ns: 'education' })}
@@ -161,6 +161,7 @@ const EducationApplyAge = () => { className="flex items-center system-xs-regular text-text-accent" href={docLink('/use-dify/workspace/subscription-management#dify-for-education')} target="_blank" + rel="noopener noreferrer" > {t('learn', { ns: 'education' })} diff --git a/web/app/education-apply/verify-state-modal.tsx b/web/app/education-apply/verify-state-modal.tsx index bb7439352e..d51a815297 100644 --- a/web/app/education-apply/verify-state-modal.tsx +++ b/web/app/education-apply/verify-state-modal.tsx @@ -101,7 +101,7 @@ function Confirm({
{showLink && ( <> - {t('learn', { ns: 'education' })} + {t('learn', { ns: 'education' })} )} From 81090effe233aa6317aa3da3fa86816acdc79c23 Mon Sep 17 00:00:00 2001 From: guangyang1206 Date: Mon, 4 May 2026 17:36:35 +0800 Subject: [PATCH 10/21] =?UTF-8?q?refactor(web):=20convert=20ValidatedStatu?= =?UTF-8?q?s=20enum=20to=20as-const=20in=20key-valida=E2=80=A6=20(#35749)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: yeranyang Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- eslint-suppressions.json | 3 --- .../account-setting/key-validator/declarations.ts | 12 +++++++----- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 2eaefd9436..f822ac2ae2 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2778,9 +2778,6 @@ } }, "web/app/components/header/account-setting/key-validator/declarations.ts": { - "erasable-syntax-only/enums": { - "count": 1 - }, "ts/no-explicit-any": { "count": 1 } diff --git a/web/app/components/header/account-setting/key-validator/declarations.ts b/web/app/components/header/account-setting/key-validator/declarations.ts index 374256ff41..bcf809f39c 100644 --- a/web/app/components/header/account-setting/key-validator/declarations.ts +++ b/web/app/components/header/account-setting/key-validator/declarations.ts @@ -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 From 4b7dc175466d2017fe50ceaf26ffec0f2fd1dacf Mon Sep 17 00:00:00 2001 From: Prince Pal <107296821+princepal9120@users.noreply.github.com> Date: Mon, 4 May 2026 18:36:58 +0530 Subject: [PATCH 11/21] fix: skip empty documents before vector embedding (#35763) Co-authored-by: Asuka Minato Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/core/rag/datasource/vdb/vector_factory.py | 18 +++++ .../rag/datasource/vdb/test_vector_factory.py | 69 +++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/api/core/rag/datasource/vdb/vector_factory.py b/api/core/rag/datasource/vdb/vector_factory.py index 9575377174..1f82f7a081 100644 --- a/api/core/rag/datasource/vdb/vector_factory.py +++ b/api/core/rag/datasource/vdb/vector_factory.py @@ -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) diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/test_vector_factory.py b/api/tests/unit_tests/core/rag/datasource/vdb/test_vector_factory.py index f84ce2771f..7b6ee97f1c 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/test_vector_factory.py +++ b/api/tests/unit_tests/core/rag/datasource/vdb/test_vector_factory.py @@ -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() From 1359c032169716ec34ffcaccb611ab895d755d61 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Mon, 4 May 2026 21:17:09 +0800 Subject: [PATCH 12/21] refactor(web): migrate legacy tooltip to infotip (#35774) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- eslint-suppressions.json | 136 ------------------ .../app-sidebar/__tests__/basic.spec.tsx | 11 +- web/app/components/app-sidebar/basic.tsx | 15 +- .../components/app-sidebar/toggle-button.tsx | 37 +++-- .../app/configuration/config-vision/index.tsx | 32 ++--- .../config-vision/param-config-content.tsx | 19 ++- .../app/configuration/config/config-audio.tsx | 15 +- .../configuration/config/config-document.tsx | 15 +- .../context-var/__tests__/index.spec.tsx | 4 +- .../dataset-config/context-var/index.tsx | 15 +- .../params-config/config-content.tsx | 41 +++--- .../base/__tests__/theme-selector.spec.tsx | 9 +- web/app/components/base/checkbox/index.tsx | 6 + .../base/param-item/__tests__/index.spec.tsx | 10 +- .../__tests__/score-threshold-item.spec.tsx | 5 +- .../param-item/__tests__/top-k-item.spec.tsx | 5 +- web/app/components/base/param-item/index.tsx | 11 +- web/app/components/base/theme-selector.tsx | 104 +++++--------- .../priority-label/__tests__/index.spec.tsx | 45 +++--- .../billing/priority-label/index.tsx | 40 +++--- .../image-input.tsx | 33 ++--- .../__tests__/index.spec.tsx | 8 +- .../common/retrieval-param-config/index.tsx | 13 +- .../list/template-card/details/index.tsx | 10 +- .../__tests__/indexing-progress-item.spec.tsx | 9 +- .../indexing-progress-item.tsx | 18 +-- .../__tests__/indexing-mode-section.spec.tsx | 7 + .../components/__tests__/inputs.spec.tsx | 5 +- .../components/indexing-mode-section.tsx | 99 +++++++------ .../create/step-two/components/inputs.tsx | 22 +-- .../step-two/components/option-card.tsx | 4 +- .../__tests__/checkbox-with-label.spec.tsx | 8 +- .../website/base/__tests__/field.spec.tsx | 6 +- .../website/base/checkbox-with-label.tsx | 36 +++-- .../datasets/create/website/base/field.tsx | 11 +- .../__tests__/checkbox-with-label.spec.tsx | 8 +- .../base/__tests__/index.spec.tsx | 25 ++-- .../base/checkbox-with-label.tsx | 36 +++-- .../status-item/__tests__/index.spec.tsx | 4 +- .../datasets/documents/status-item/index.tsx | 31 +++- .../__tests__/info-group.spec.tsx | 4 +- .../metadata/metadata-document/info-group.tsx | 10 +- .../settings/summary-index-setting.tsx | 11 +- .../__tests__/global-inputs.spec.tsx | 10 +- .../__tests__/index.spec.tsx | 16 +-- .../label-right-content/global-inputs.tsx | 9 +- .../publisher/__tests__/index.spec.tsx | 21 --- .../_base/components/__tests__/field.spec.tsx | 6 +- .../workflow/nodes/_base/components/field.tsx | 25 +++- .../nodes/_base/components/option-card.tsx | 12 +- .../workflow-panel/__tests__/index.spec.tsx | 6 +- .../_base/components/workflow-panel/index.tsx | 56 +++++--- .../llm/components/config-prompt-item.tsx | 24 ++-- .../llm/components/panel-memory-section.tsx | 35 ++--- .../llm/components/panel-output-section.tsx | 30 ++-- web/eslint.constants.mjs | 9 -- 56 files changed, 512 insertions(+), 740 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index f822ac2ae2..586590413f 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -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 @@ -3341,11 +3231,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 @@ -3932,11 +3817,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 @@ -3980,11 +3860,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 @@ -4046,9 +3921,6 @@ } }, "web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx": { - "no-restricted-imports": { - "count": 1 - }, "react/set-state-in-effect": { "count": 3 }, @@ -4539,14 +4411,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 diff --git a/web/app/components/app-sidebar/__tests__/basic.spec.tsx b/web/app/components/app-sidebar/__tests__/basic.spec.tsx index 67e708eb02..1abb56d7c6 100644 --- a/web/app/components/app-sidebar/__tests__/basic.spec.tsx +++ b/web/app/components/app-sidebar/__tests__/basic.spec.tsx @@ -7,12 +7,6 @@ vi.mock('@/app/components/base/icons/src/vender/workflow', () => ({ WindowCursor: (props: React.SVGProps) => , })) -vi.mock('@/app/components/base/tooltip', () => ({ - default: ({ popupContent }: { popupContent: React.ReactNode }) => ( -
{popupContent}
- ), -})) - 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() - 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() - expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument() + expect(screen.queryByLabelText('Some tip')).not.toBeInTheDocument() }) }) diff --git a/web/app/components/app-sidebar/basic.tsx b/web/app/components/app-sidebar/basic.tsx index 2814072860..3c7a7d2098 100644 --- a/web/app/components/app-sidebar/basic.tsx +++ b/web/app/components/app-sidebar/basic.tsx @@ -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
{hoverTip && ( - - {hoverTip} -
- )} - popupClassName="ml-1" - triggerClassName="w-4 h-4 ml-1" - position="top" - /> + + {hoverTip} + )}
{!hideType && isExtraInLine && ( diff --git a/web/app/components/app-sidebar/toggle-button.tsx b/web/app/components/app-sidebar/toggle-button.tsx index 6aca77fb4f..9dd5a58ef2 100644 --- a/web/app/components/app-sidebar/toggle-button.tsx +++ b/web/app/components/app-sidebar/toggle-button.tsx @@ -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 ( - } - popupClassName="p-1.5 rounded-lg" - position="right" - > - + {expand ? : } + + + + ) } diff --git a/web/app/components/app/configuration/config-vision/index.tsx b/web/app/components/app/configuration/config-vision/index.tsx index b9cb54cc34..81c0ac8450 100644 --- a/web/app/components/app/configuration/config-vision/index.tsx +++ b/web/app/components/app/configuration/config-vision/index.tsx @@ -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 = () => {
{t('vision.name', { ns: 'appDebug' })}
- - {t('vision.description', { ns: 'appDebug' })} -
- )} - /> + + {t('vision.description', { ns: 'appDebug' })} +
{readonly @@ -84,15 +83,14 @@ const ConfigVision: FC = () => { <>
{t('vision.visionSettings.resolution', { ns: 'appDebug' })}
- - {t('vision.visionSettings.resolutionTooltip', { ns: 'appDebug' }).split('\n').map(item => ( -
{item}
- ))} -
- )} - /> + + {t('vision.visionSettings.resolutionTooltip', { ns: 'appDebug' }).split('\n').map(item => ( +
{item}
+ ))} +
{
{t('vision.visionSettings.resolution', { ns: 'appDebug' })}
- - {t('vision.visionSettings.resolutionTooltip', { ns: 'appDebug' }).split('\n').map(item => ( -
{item}
- ))} -
- )} - /> + + {t('vision.visionSettings.resolutionTooltip', { ns: 'appDebug' }).split('\n').map(item => ( +
{item}
+ ))} +
{
{t('feature.audioUpload.title', { ns: 'appDebug' })}
- - {t('feature.audioUpload.description', { ns: 'appDebug' })} -
- )} - /> + + {t('feature.audioUpload.description', { ns: 'appDebug' })} +
{!readonly && (
diff --git a/web/app/components/app/configuration/config/config-document.tsx b/web/app/components/app/configuration/config/config-document.tsx index 156c605267..107e21b36e 100644 --- a/web/app/components/app/configuration/config/config-document.tsx +++ b/web/app/components/app/configuration/config/config-document.tsx @@ -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 = () => {
{t('feature.documentUpload.title', { ns: 'appDebug' })}
- - {t('feature.documentUpload.description', { ns: 'appDebug' })} -
- )} - /> + + {t('feature.documentUpload.description', { ns: 'appDebug' })} + {!readonly && (
diff --git a/web/app/components/app/configuration/dataset-config/context-var/__tests__/index.spec.tsx b/web/app/components/app/configuration/dataset-config/context-var/__tests__/index.spec.tsx index 91fe47d83d..e8b1583171 100644 --- a/web/app/components/app/configuration/dataset-config/context-var/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/context-var/__tests__/index.spec.tsx @@ -275,7 +275,7 @@ describe('ContextVar', () => { // Act render() - 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() - const varPickerTrigger = screen.getByTestId('popover-trigger') + const varPickerTrigger = screen.getAllByTestId('popover-trigger').at(-1)! // Open dropdown await user.click(varPickerTrigger!) diff --git a/web/app/components/app/configuration/dataset-config/context-var/index.tsx b/web/app/components/app/configuration/dataset-config/context-var/index.tsx index 634277c469..60d81548cf 100644 --- a/web/app/components/app/configuration/dataset-config/context-var/index.tsx +++ b/web/app/components/app/configuration/dataset-config/context-var/index.tsx @@ -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) => { @@ -20,13 +20,12 @@ const ContextVar: FC = (props) => {
{t('feature.dataSet.queryVariable.title', { ns: 'appDebug' })}
- - {t('feature.dataSet.queryVariable.tip', { ns: 'appDebug' })} - - )} - /> + + {t('feature.dataSet.queryVariable.tip', { ns: 'appDebug' })} + diff --git a/web/app/components/app/configuration/dataset-config/params-config/config-content.tsx b/web/app/components/app/configuration/dataset-config/params-config/config-content.tsx index d0e6b2fe9f..9c50196dcf 100644 --- a/web/app/components/app/configuration/dataset-config/params-config/config-content.tsx +++ b/web/app/components/app/configuration/dataset-config/params-config/config-content.tsx @@ -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 = ({ onClick={() => handleRerankModeChange(option.value)} >
{option.label}
- - {option.tips} - - )} - popupClassName="ml-0.5" - triggerClassName="ml-0.5 w-3.5 h-3.5" - /> + + {option.tips} + )) } @@ -273,15 +272,13 @@ const ConfigContent: FC = ({ ) }
{t('modelProvider.rerankModel.key', { ns: 'common' })}
- - {t('modelProvider.rerankModel.tip', { ns: 'common' })} - - )} - popupClassName="ml-1" - triggerClassName="ml-1 w-4 h-4" - /> + + {t('modelProvider.rerankModel.tip', { ns: 'common' })} + { showRerankModel && ( @@ -363,9 +360,9 @@ const ConfigContent: FC = ({
{t('modelProvider.systemReasoningModel.key', { ns: 'common' })}
- + + {t('modelProvider.systemReasoningModel.tip', { ns: 'common' })} +
{ it('should call setTheme with light when light option is clicked', () => { render() 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() 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() 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') }) }) diff --git a/web/app/components/base/checkbox/index.tsx b/web/app/components/base/checkbox/index.tsx index 1abf142a70..7457dc9016 100644 --- a/web/app/components/base/checkbox/index.tsx +++ b/web/app/components/base/checkbox/index.tsx @@ -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 && } diff --git a/web/app/components/base/param-item/__tests__/index.spec.tsx b/web/app/components/base/param-item/__tests__/index.spec.tsx index 889662c87d..01d28953fa 100644 --- a/web/app/components/base/param-item/__tests__/index.spec.tsx +++ b/web/app/components/base/param-item/__tests__/index.spec.tsx @@ -27,17 +27,15 @@ describe('ParamItem', () => { }) it('should render a tooltip trigger by default', () => { - const { container } = render() + render() - // 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() + render() - // 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', () => { diff --git a/web/app/components/base/param-item/__tests__/score-threshold-item.spec.tsx b/web/app/components/base/param-item/__tests__/score-threshold-item.spec.tsx index f32a707a52..0fbe727f42 100644 --- a/web/app/components/base/param-item/__tests__/score-threshold-item.spec.tsx +++ b/web/app/components/base/param-item/__tests__/score-threshold-item.spec.tsx @@ -24,10 +24,9 @@ describe('ScoreThresholdItem', () => { }) it('should render tooltip trigger', () => { - const { container } = render() + render() - // 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', () => { diff --git a/web/app/components/base/param-item/__tests__/top-k-item.spec.tsx b/web/app/components/base/param-item/__tests__/top-k-item.spec.tsx index c84fd50518..c843883285 100644 --- a/web/app/components/base/param-item/__tests__/top-k-item.spec.tsx +++ b/web/app/components/base/param-item/__tests__/top-k-item.spec.tsx @@ -29,10 +29,9 @@ describe('TopKItem', () => { }) it('should render tooltip trigger', () => { - const { container } = render() + render() - // 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', () => { diff --git a/web/app/components/base/param-item/index.tsx b/web/app/components/base/param-item/index.tsx index 93eb878551..a44176bf18 100644 --- a/web/app/components/base/param-item/index.tsx +++ b/web/app/components/base/param-item/index.tsx @@ -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 = ({ className, id, name, noTooltip, tip, step = 0.1, /> )} {name} - {!noTooltip && ( - {tip}
} - /> + {!noTooltip && tip && ( + + {tip} + )} diff --git a/web/app/components/base/theme-selector.tsx b/web/app/components/base/theme-selector.tsx index 1f173aa885..1676dfff72 100644 --- a/web/app/components/base/theme-selector.tsx +++ b/web/app/components/base/theme-selector.tsx @@ -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 ( - - setOpen(!open)} + + + )} > - - {getCurrentIcon()} - - - -
- - - -
-
-
+ {t('theme.auto', { ns: 'common' })} + + + + + ) } diff --git a/web/app/components/billing/priority-label/__tests__/index.spec.tsx b/web/app/components/billing/priority-label/__tests__/index.spec.tsx index ef613d76b8..2b6201fdff 100644 --- a/web/app/components/billing/priority-label/__tests__/index.spec.tsx +++ b/web/app/components/billing/priority-label/__tests__/index.spec.tsx @@ -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( + + + , + ) +} + 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() + 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() + 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() + 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() + 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() + 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() + 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() - 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() - 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() }) }) diff --git a/web/app/components/billing/priority-label/index.tsx b/web/app/components/billing/priority-label/index.tsx index 8940b38382..c7b7d17e87 100644 --- a/web/app/components/billing/priority-label/index.tsx +++ b/web/app/components/billing/priority-label/index.tsx @@ -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 ( - + + + )} + > + { + (plan.type === Plan.professional || plan.type === Plan.team || plan.type === Plan.enterprise) && ( + + ) + } + {t(`plansCommon.priority.${priority}`, { ns: 'billing' })} + +
{t('plansCommon.documentProcessingPriority', { ns: 'billing' })} : @@ -44,22 +61,7 @@ const PriorityLabel = ({ className }: PriorityLabelProps) => {
{t('plansCommon.documentProcessingPriorityTip', { ns: 'billing' })}
) } -
- )} - > -
- { - (plan.type === Plan.professional || plan.type === Plan.team || plan.type === Plan.enterprise) && ( - - ) - } - {t(`plansCommon.priority.${priority}`, { ns: 'billing' })} -
+
) } diff --git a/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/image-input.tsx b/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/image-input.tsx index e5b7e79d55..5d43f0b687 100644 --- a/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/image-input.tsx +++ b/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/image-input.tsx @@ -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} />
- -
+ + )} >
@@ -56,7 +50,14 @@ const ImageUploader = () => { })} )} -
+
+ + {t('imageUploader.tooltip', { + ns: 'datasetHitTesting', + size: fileUploadConfig.imageFileSizeLimit, + batchCount: fileUploadConfig.imageFileBatchLimit, + })} +
diff --git a/web/app/components/datasets/common/retrieval-param-config/__tests__/index.spec.tsx b/web/app/components/datasets/common/retrieval-param-config/__tests__/index.spec.tsx index 1b208c549d..ac5112ad22 100644 --- a/web/app/components/datasets/common/retrieval-param-config/__tests__/index.spec.tsx +++ b/web/app/components/datasets/common/retrieval-param-config/__tests__/index.spec.tsx @@ -134,12 +134,6 @@ vi.mock('@langgenius/dify-ui/switch', () => ({ ), })) -vi.mock('@/app/components/base/tooltip', () => ({ - default: ({ popupContent }: { popupContent: React.ReactNode }) => ( -
{popupContent}
- ), -})) - describe('RetrievalParamConfig', () => { const createDefaultConfig = (overrides?: Partial): 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() }) }) diff --git a/web/app/components/datasets/common/retrieval-param-config/index.tsx b/web/app/components/datasets/common/retrieval-param-config/index.tsx index 8514e1fae2..93392f2821 100644 --- a/web/app/components/datasets/common/retrieval-param-config/index.tsx +++ b/web/app/components/datasets/common/retrieval-param-config/index.tsx @@ -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 = ({ )}
{t('modelProvider.rerankModel.key', { ns: 'common' })} - {t('modelProvider.rerankModel.tip', { ns: 'common' })}
- } - /> + + {t('modelProvider.rerankModel.tip', { ns: 'common' })} + { diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/details/index.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/details/index.tsx index 22866b0b9e..d3538d3349 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/details/index.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/details/index.tsx @@ -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 = ({ {t('details.structure', { ns: 'datasetPipeline' })} - + > + {t('details.structureTooltip', { ns: 'datasetPipeline' })} + diff --git a/web/app/components/datasets/create/embedding-process/__tests__/indexing-progress-item.spec.tsx b/web/app/components/datasets/create/embedding-process/__tests__/indexing-progress-item.spec.tsx index e0ae25a80f..f71c4a0cc1 100644 --- a/web/app/components/datasets/create/embedding-process/__tests__/indexing-progress-item.spec.tsx +++ b/web/app/components/datasets/create/embedding-process/__tests__/indexing-progress-item.spec.tsx @@ -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 }) => {src}, })) -vi.mock('@/app/components/base/tooltip', () => ({ - default: ({ children, popupContent }: { children?: ReactNode, popupContent?: ReactNode }) => ( -
{children}
- ), -})) - 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', () => { diff --git a/web/app/components/datasets/create/embedding-process/indexing-progress-item.tsx b/web/app/components/datasets/create/embedding-process/indexing-progress-item.tsx index ef54bddfc5..b98d9e7e10 100644 --- a/web/app/components/datasets/create/embedding-process/indexing-progress-item.tsx +++ b/web/app/components/datasets/create/embedding-process/indexing-progress-item.tsx @@ -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 ( - - + + }> - + + + {error} + ) } diff --git a/web/app/components/datasets/create/step-two/components/__tests__/indexing-mode-section.spec.tsx b/web/app/components/datasets/create/step-two/components/__tests__/indexing-mode-section.spec.tsx index 64011c1ebe..3931478f27 100644 --- a/web/app/components/datasets/create/step-two/components/__tests__/indexing-mode-section.spec.tsx +++ b/web/app/components/datasets/create/step-two/components/__tests__/indexing-mode-section.spec.tsx @@ -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() + + expect(screen.getByText(`${ns}.stepTwo.notAvailableForParentChild`))!.toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/create/step-two/components/__tests__/inputs.spec.tsx b/web/app/components/datasets/create/step-two/components/__tests__/inputs.spec.tsx index f1ab5392ce..0e6cc10076 100644 --- a/web/app/components/datasets/create/step-two/components/__tests__/inputs.spec.tsx +++ b/web/app/components/datasets/create/step-two/components/__tests__/inputs.spec.tsx @@ -30,9 +30,7 @@ describe('DelimiterInput', () => { it('should render tooltip content', () => { render() - // 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() expect(screen.getAllByText(new RegExp(`${ns}.stepTwo.overlap`)).length).toBeGreaterThan(0) + expect(screen.getByLabelText(`${ns}.stepTwo.overlapTip`))!.toBeInTheDocument() }) it('should render number input', () => { diff --git a/web/app/components/datasets/create/step-two/components/indexing-mode-section.tsx b/web/app/components/datasets/create/step-two/components/indexing-mode-section.tsx index 1639df2320..2ddcc81d5e 100644 --- a/web/app/components/datasets/create/step-two/components/indexing-mode-section.tsx +++ b/web/app/components/datasets/create/step-two/components/indexing-mode-section.tsx @@ -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 = ({ 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 = ({
{t('stepTwo.indexMode', { ns: 'datasetCreation' })}
-
+ { + if (!open) + onQAConfirmDialogClose() + }} + > + +
+ + {t('stepTwo.qaSwitchHighQualityTipTitle', { ns: 'datasetCreation' })} + + + {t('stepTwo.qaSwitchHighQualityTipContent', { ns: 'datasetCreation' })} + +
+ + + {t('stepTwo.cancel', { ns: 'datasetCreation' })} + + + {t('stepTwo.switch', { ns: 'datasetCreation' })} + + +
+
+
{/* Qualified option */} {(!hasSetIndexType || (hasSetIndexType && indexType === IndexingType.QUALIFIED)) && ( = ({ {/* Economical option */} {(!hasSetIndexType || (hasSetIndexType && indexType === IndexingType.ECONOMICAL)) && ( - <> - -
-

- {t('stepTwo.qaSwitchHighQualityTipTitle', { ns: 'datasetCreation' })} -

-

- {t('stepTwo.qaSwitchHighQualityTipContent', { ns: 'datasetCreation' })} -

-
-
- - -
-
- - {docForm === ChunkingMode.qa - ? t('stepTwo.notAvailableForQA', { ns: 'datasetCreation' }) - : t('stepTwo.notAvailableForParentChild', { ns: 'datasetCreation' })} -
- )} - noDecoration - position="top" - asChild={false} - triggerClassName="flex-1 self-stretch" - > - } - isActive={!hasSetIndexType && indexType === IndexingType.ECONOMICAL} - disabled={hasSetIndexType || docForm !== ChunkingMode.text} - onSwitched={() => onIndexTypeChange(IndexingType.ECONOMICAL)} - /> - - + } + isActive={!hasSetIndexType && indexType === IndexingType.ECONOMICAL} + disabled={hasSetIndexType || !!economicalDisabledReason} + onSwitched={() => onIndexTypeChange(IndexingType.ECONOMICAL)} + /> )}
diff --git a/web/app/components/datasets/create/step-two/components/inputs.tsx b/web/app/components/datasets/create/step-two/components/inputs.tsx index 12af1de1aa..635056f1b2 100644 --- a/web/app/components/datasets/create/step-two/components/inputs.tsx +++ b/web/app/components/datasets/create/step-two/components/inputs.tsx @@ -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 = (props) => { @@ -38,13 +38,9 @@ export const DelimiterInput: FC = ({ tooltip, {t('stepTwo.separator', { ns: 'datasetCreation' })} - - {tooltip || t('stepTwo.separatorTip', { ns: 'datasetCreation' })} - - )} - /> + + {tooltip || t('stepTwo.separatorTip', { ns: 'datasetCreation' })} + )} > @@ -154,13 +150,9 @@ export const OverlapInput: FC = (props) => { {t('stepTwo.overlap', { ns: 'datasetCreation' })} - - {t('stepTwo.overlapTip', { ns: 'datasetCreation' })} - - )} - /> + + {t('stepTwo.overlapTip', { ns: 'datasetCreation' })} + )} > diff --git a/web/app/components/datasets/create/step-two/components/option-card.tsx b/web/app/components/datasets/create/step-two/components/option-card.tsx index 5334feaac8..43f0924d92 100644 --- a/web/app/components/datasets/create/step-two/components/option-card.tsx +++ b/web/app/components/datasets/create/step-two/components/option-card.tsx @@ -20,7 +20,7 @@ type OptionCardHeaderProps = { export const OptionCardHeader: FC = (props) => { const { icon, title, description, isActive, activeClassName, effectImg, disabled } = props return ( -
+
{isActive && effectImg && }
@@ -63,7 +63,7 @@ export const OptionCard: FC = ( const { icon, className, title, description, isActive, children, actions, activeHeaderClassName, style, effectImg, onSwitched, noHighlight, disabled, ...rest } = props return (
({ - default: ({ popupContent }: { popupContent?: React.ReactNode }) =>
{popupContent}
, -})) - 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() - expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument() + expect(screen.queryByLabelText('Help text')).not.toBeInTheDocument() }) it('should toggle checked state on checkbox click', () => { diff --git a/web/app/components/datasets/create/website/base/__tests__/field.spec.tsx b/web/app/components/datasets/create/website/base/__tests__/field.spec.tsx index 8a2e147d60..a362562437 100644 --- a/web/app/components/datasets/create/website/base/__tests__/field.spec.tsx +++ b/web/app/components/datasets/create/website/base/__tests__/field.spec.tsx @@ -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 }) =>
{popupContent}
, -})) - describe('WebsiteField', () => { const onChange = vi.fn() @@ -30,7 +26,7 @@ describe('WebsiteField', () => { it('should render tooltip when provided', () => { render() - expect(screen.getByTestId('tooltip')).toBeInTheDocument() + expect(screen.getByLabelText('Enter full URL')).toBeInTheDocument() }) it('should pass value and onChange to Input', () => { diff --git a/web/app/components/datasets/create/website/base/checkbox-with-label.tsx b/web/app/components/datasets/create/website/base/checkbox-with-label.tsx index 686976826d..117a16f401 100644 --- a/web/app/components/datasets/create/website/base/checkbox-with-label.tsx +++ b/web/app/components/datasets/create/website/base/checkbox-with-label.tsx @@ -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 = ({ tooltip, testId, }) => { + const labelId = useId() + const handleToggle = () => onChange(!isChecked) + return ( -
- } - triggerClassName="ml-0.5 w-4 h-4" - /> - )} - +
+ +
+ + {tooltip && ( + + {tooltip} + + )} +
+
) } export default React.memo(CheckboxWithLabel) diff --git a/web/app/components/datasets/create/website/base/field.tsx b/web/app/components/datasets/create/website/base/field.tsx index 860f934da2..423fa54823 100644 --- a/web/app/components/datasets/create/website/base/field.tsx +++ b/web/app/components/datasets/create/website/base/field.tsx @@ -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 = ({
{isRequired && *} {tooltip && ( - {tooltip}
- } - triggerClassName="ml-0.5 w-4 h-4" - /> + + {tooltip} + )}
({ ), })) -vi.mock('@/app/components/base/tooltip', () => ({ - default: ({ popupContent }: { popupContent: string }) =>
{popupContent}
, -})) - describe('CheckboxWithLabel', () => { const defaultProps = { isChecked: false, @@ -35,12 +31,12 @@ describe('CheckboxWithLabel', () => { it('should render tooltip when provided', () => { render() - expect(screen.getByTestId('tooltip')).toBeInTheDocument() + expect(screen.getByLabelText('Help text')).toBeInTheDocument() }) it('should not render tooltip when not provided', () => { render() - expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument() + expect(screen.queryByLabelText('Help text')).not.toBeInTheDocument() }) it('should apply custom className', () => { diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/index.spec.tsx index fa5633e2df..db3e369722 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/index.spec.tsx @@ -62,16 +62,13 @@ describe('CheckboxWithLabel', () => { it('should render tooltip when provided', () => { render() - // 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() - 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', () => { , ) - 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() - // 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() - // 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() - 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) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/checkbox-with-label.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/checkbox-with-label.tsx index 23dbea51a9..99c6625793 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/checkbox-with-label.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/checkbox-with-label.tsx @@ -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 ( -
- } - triggerClassName="ml-0.5 w-4 h-4" - /> - )} - +
+ +
+ + {tooltip && ( + + {tooltip} + + )} +
+
) } export default React.memo(CheckboxWithLabel) diff --git a/web/app/components/datasets/documents/status-item/__tests__/index.spec.tsx b/web/app/components/datasets/documents/status-item/__tests__/index.spec.tsx index b69835d7f2..1e651929fe 100644 --- a/web/app/components/datasets/documents/status-item/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/status-item/__tests__/index.spec.tsx @@ -140,12 +140,12 @@ describe('StatusItem', () => { describe('error message tooltip', () => { it('should show tooltip trigger when error message is provided', () => { render() - 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() - expect(screen.queryByTestId('error-tooltip-trigger')).not.toBeInTheDocument() + expect(screen.queryByLabelText('Test error message')).not.toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/documents/status-item/index.tsx b/web/app/components/datasets/documents/status-item/index.tsx index 9970c6bca8..911b1df55e 100644 --- a/web/app/components/datasets/documents/status-item/index.tsx +++ b/web/app/components/datasets/documents/status-item/index.tsx @@ -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 {DOC_INDEX_STATUS_MAP[localStatus]?.text} - {errorMessage && ({errorMessage}} triggerClassName="ml-1 w-4 h-4" triggerTestId="error-tooltip-trigger" />)} + {errorMessage && ( + + {errorMessage} + + )} {scene === 'detail' && (
- - !archived && handleSwitch(v ? 'enable' : 'disable')} disabled={embedding || archived} size="md" /> + + + !archived && handleSwitch(v ? 'enable' : 'disable')} + disabled={embedding || archived} + size="md" + /> + + )} + /> + + {t('list.action.enableWarning', { ns: 'datasetDocuments' })} +
)} diff --git a/web/app/components/datasets/metadata/metadata-document/__tests__/info-group.spec.tsx b/web/app/components/datasets/metadata/metadata-document/__tests__/info-group.spec.tsx index c176979a5a..c4dc89cafc 100644 --- a/web/app/components/datasets/metadata/metadata-document/__tests__/info-group.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-document/__tests__/info-group.spec.tsx @@ -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', () => { diff --git a/web/app/components/datasets/metadata/metadata-document/info-group.tsx b/web/app/components/datasets/metadata/metadata-document/info-group.tsx index 6447185758..16f5e573fb 100644 --- a/web/app/components/datasets/metadata/metadata-document/info-group.tsx +++ b/web/app/components/datasets/metadata/metadata-document/info-group.tsx @@ -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 = ({
{title}
{titleTooltip && ( - {titleTooltip}
}> -
-
+ + {titleTooltip} + )} {headerRight} diff --git a/web/app/components/datasets/settings/summary-index-setting.tsx b/web/app/components/datasets/settings/summary-index-setting.tsx index 3bd47bf4b9..0944afc2e1 100644 --- a/web/app/components/datasets/settings/summary-index-setting.tsx +++ b/web/app/components/datasets/settings/summary-index-setting.tsx @@ -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 = ({
{t('form.summaryAutoGen', { ns: 'datasetSettings' })} - - + {t('form.summaryAutoGenTip', { ns: 'datasetSettings' })} +
({ - default: ({ - popupContent, - }: { - popupContent: React.ReactNode - }) =>
{popupContent}
, -})) - describe('GlobalInputs', () => { beforeEach(() => { vi.clearAllMocks() @@ -18,6 +10,6 @@ describe('GlobalInputs', () => { render() 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() }) }) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/__tests__/index.spec.tsx index ba9390a028..ab01bdba3b 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/__tests__/index.spec.tsx @@ -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 }) => ( -
- ), -})) - afterEach(() => { cleanup() vi.clearAllMocks() @@ -161,21 +155,19 @@ describe('GlobalInputs', () => { it('should render tooltip component', () => { render() - expect(screen.getByTestId('tooltip')).toBeInTheDocument() + expect(screen.getByLabelText('datasetPipeline.inputFieldPanel.globalInputs.tooltip')).toBeInTheDocument() }) it('should pass correct tooltip content', () => { render() - 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() - 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', () => { diff --git a/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/global-inputs.tsx b/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/global-inputs.tsx index 763d1ce631..23d56722a6 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/global-inputs.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/global-inputs.tsx @@ -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 = () => { {t('inputFieldPanel.globalInputs.title', { ns: 'datasetPipeline' })} - + + {t('inputFieldPanel.globalInputs.tooltip', { ns: 'datasetPipeline' })} +
) } diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx index cc2f96aa6e..afd7c04ed1 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx @@ -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() - - 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() - }) }) }) diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/field.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/field.spec.tsx index e16ed108f7..a40d03603d 100644 --- a/web/app/components/workflow/nodes/_base/components/__tests__/field.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/__tests__/field.spec.tsx @@ -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 }) =>
{popupContent}
, -})) - 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() diff --git a/web/app/components/workflow/nodes/_base/components/field.tsx b/web/app/components/workflow/nodes/_base/components/field.tsx index 6c8e26758a..11bb778ce5 100644 --- a/web/app/components/workflow/nodes/_base/components/field.tsx +++ b/web/app/components/workflow/nodes/_base/components/field.tsx @@ -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 = ({ className, title, @@ -36,6 +47,8 @@ const Field: FC = ({ const [fold, { toggle: toggleFold, }] = useBoolean(true) + const tooltipLabel = tooltip ? getTextFromNode(tooltip) || getTextFromNode(title) || 'Help' : undefined + return (
= ({ {' '} {required && *}
- {!!tooltip && ( - + {!!tooltip && !!tooltipLabel && ( + + {tooltip} + )}
diff --git a/web/app/components/workflow/nodes/_base/components/option-card.tsx b/web/app/components/workflow/nodes/_base/components/option-card.tsx index dd8ad51437..e23ae22204 100644 --- a/web/app/components/workflow/nodes/_base/components/option-card.tsx +++ b/web/app/components/workflow/nodes/_base/components/option-card.tsx @@ -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 = ({ {title} {tooltip && ( - - {tooltip} -
- )} - /> + + {tooltip} + )}
) diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx index 17485c4344..ac920e4862 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx @@ -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) }) diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx index 5ebf5ae09b..ee96563761 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx @@ -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' @@ -469,6 +473,11 @@ const BasePanel: FC = ({ ) } + const runThisStepLabel = t('panel.runThisStep', { ns: 'workflow' }) + const singleRunActionLabel = isSingleRunning + ? t('debug.variableInspect.trigger.stop', { ns: 'workflow' }) + : runThisStepLabel + return (
= ({
{ isSupportSingleRun && !nodesReadOnly && ( - -
{ - if (isSingleRunning) - handleStop() - else - handleSingleRun() - }} - > - { - isSingleRunning - ? - : - } -
+ + { + if (isSingleRunning) + handleStop() + else + handleSingleRun() + }} + > + { + isSingleRunning + ? + : + } + + )} + /> + + {runThisStepLabel} + ) } diff --git a/web/app/components/workflow/nodes/llm/components/config-prompt-item.tsx b/web/app/components/workflow/nodes/llm/components/config-prompt-item.tsx index ddc15b5fdb..c9a55d42ef 100644 --- a/web/app/components/workflow/nodes/llm/components/config-prompt-item.tsx +++ b/web/app/components/workflow/nodes/llm/components/config-prompt-item.tsx @@ -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 = ({ /> )} - {!!payload.role && t(`${i18nPrefix}.roleDescription.${payload.role}`, { ns: 'workflow' })}
- } - triggerClassName="w-4 h-4" - /> + {!!payload.role && ( + + {t(`${i18nPrefix}.roleDescription.${payload.role}`, { ns: 'workflow' })} + + )}
)} value={payload.edition_type === EditionType.jinja2 ? (payload.jinja2_text || '') : payload.text} diff --git a/web/app/components/workflow/nodes/llm/components/panel-memory-section.tsx b/web/app/components/workflow/nodes/llm/components/panel-memory-section.tsx index 59b38b41db..d9b7612bff 100644 --- a/web/app/components/workflow/nodes/llm/components/panel-memory-section.tsx +++ b/web/app/components/workflow/nodes/llm/components/panel-memory-section.tsx @@ -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 = ({
{t('nodes.common.memories.title', { ns: 'workflow' })}
- - - - - )} - /> - - {t('nodes.common.memories.tip', { ns: 'workflow' })} - - + + {t('nodes.common.memories.tip', { ns: 'workflow' })} +
{t('nodes.common.memories.builtIn', { ns: 'workflow' })} @@ -72,18 +63,12 @@ const PanelMemorySection: FC = ({ title={(
user
- - - - - )} - /> - -
{t('nodes.llm.roleDescription.user', { ns: 'workflow' })}
-
-
+ + {t('nodes.llm.roleDescription.user', { ns: 'workflow' })} +
)} value={inputs.memory.query_prompt_template || '{{#sys.query#}}'} diff --git a/web/app/components/workflow/nodes/llm/components/panel-output-section.tsx b/web/app/components/workflow/nodes/llm/components/panel-output-section.tsx index bd333b6b1f..53f7161fba 100644 --- a/web/app/components/workflow/nodes/llm/components/panel-output-section.tsx +++ b/web/app/components/workflow/nodes/llm/components/panel-output-section.tsx @@ -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 = ({ - +
)} /> - -
-
{t('structOutput.modelNotSupported', { ns: 'app' })}
-
{t('structOutput.modelNotSupportedTip', { ns: 'app' })}
-
+ +
{t('structOutput.modelNotSupported', { ns: 'app' })}
+
{t('structOutput.modelNotSupportedTip', { ns: 'app' })}
)}
{t('structOutput.structured', { ns: 'app' })}
- - - -
- )} - /> - -
{t('structOutput.structuredTip', { ns: 'app' })}
-
-
+ + {t('structOutput.structuredTip', { ns: 'app' })} + Date: Mon, 4 May 2026 21:24:29 +0800 Subject: [PATCH 13/21] refactor(web): migrate workflow node actions menu (#35785) --- .../__tests__/node-contextmenu.spec.tsx | 72 ++-- .../__tests__/workflow-edge-events.spec.tsx | 2 +- .../__tests__/use-panel-interactions.spec.ts | 4 +- .../use-selection-interactions.spec.ts | 2 +- .../workflow/hooks/use-nodes-interactions.ts | 6 +- web/app/components/workflow/index.tsx | 2 +- .../__tests__/details.spec.tsx | 311 ++++++++++++++++++ .../__tests__/index.spec.tsx | 69 ++-- .../change-block-menu-trigger.tsx} | 33 +- .../context-menu-content.tsx | 101 ++++++ .../node-actions-menu/dropdown-content.tsx | 101 ++++++ .../workflow/node-actions-menu/index.tsx | 75 +++++ .../workflow/node-actions-menu/shared.tsx | 44 +++ .../workflow/node-actions-menu/types.ts | 8 + .../use-node-actions-menu-model.ts | 104 ++++++ .../components/workflow/node-contextmenu.tsx | 63 ++-- .../__tests__/node-control.spec.tsx | 4 +- .../nodes/_base/components/node-control.tsx | 5 +- .../panel-operator/__tests__/details.spec.tsx | 295 ----------------- .../_base/components/panel-operator/index.tsx | 86 ----- .../panel-operator/panel-operator-popup.tsx | 209 ------------ .../workflow-panel/__tests__/index.spec.tsx | 4 +- .../_base/components/workflow-panel/index.tsx | 4 +- .../store/__tests__/workflow-store.spec.ts | 2 +- .../workflow/store/workflow/node-slice.ts | 4 +- 25 files changed, 876 insertions(+), 734 deletions(-) create mode 100644 web/app/components/workflow/node-actions-menu/__tests__/details.spec.tsx rename web/app/components/workflow/{nodes/_base/components/panel-operator => node-actions-menu}/__tests__/index.spec.tsx (62%) rename web/app/components/workflow/{nodes/_base/components/panel-operator/change-block.tsx => node-actions-menu/change-block-menu-trigger.tsx} (82%) create mode 100644 web/app/components/workflow/node-actions-menu/context-menu-content.tsx create mode 100644 web/app/components/workflow/node-actions-menu/dropdown-content.tsx create mode 100644 web/app/components/workflow/node-actions-menu/index.tsx create mode 100644 web/app/components/workflow/node-actions-menu/shared.tsx create mode 100644 web/app/components/workflow/node-actions-menu/types.ts create mode 100644 web/app/components/workflow/node-actions-menu/use-node-actions-menu-model.ts delete mode 100644 web/app/components/workflow/nodes/_base/components/panel-operator/__tests__/details.spec.tsx delete mode 100644 web/app/components/workflow/nodes/_base/components/panel-operator/index.tsx delete mode 100644 web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx diff --git a/web/app/components/workflow/__tests__/node-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/node-contextmenu.spec.tsx index 7418b7f313..a34e655652 100644 --- a/web/app/components/workflow/__tests__/node-contextmenu.spec.tsx +++ b/web/app/components/workflow/__tests__/node-contextmenu.spec.tsx @@ -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 }) => ( +
+ {children} + +
+ ), + ContextMenuContent: ({ children, positionerProps, popupClassName }: { children: React.ReactNode, positionerProps?: { anchor?: unknown }, popupClassName?: string }) => { + mockContextMenuContent({ positionerProps, popupClassName }) + return
{children}
+ }, })) 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 ( - + + ), +})) + +vi.mock('@/app/components/workflow/hooks', async (importOriginal) => { + const actual = await importOriginal() + 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( + + open} /> + + + + , + { + 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) + 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) + mockUseNodesInteractions.mockReturnValue({ + handleNodeChange, + handleNodeDelete, + handleNodesDuplicate, + handleNodeSelect, + handleNodesCopy, + } as unknown as ReturnType) + mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false } as ReturnType) + mockUseNodesSyncDraft.mockReturnValue({ + doSyncWorkflowDraft: vi.fn(), + handleSyncWorkflowDraft, + syncWorkflowDraftWhenPageClose: vi.fn(), + } as ReturnType) + 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( + , + ) + + 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) + mockUseIsChatMode.mockReturnValueOnce(true) + mockUseHooksStore.mockImplementationOnce((selector: any) => selector({ configsMap: { flowType: FlowType.appFlow } })) + mockUseNodes.mockReturnValueOnce([] as any) + + const { rerender } = render( + , + ) + + 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) + 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( + , + ) + + 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) + + 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( + + open} /> + + + + , + { + nodes: [], + edges: [], + }, + ) + + expect(screen.getByRole('menuitem', { name: 'workflow.panel.openWorkflow' })).toHaveAttribute('href', '/app/app-123/workflow') + + mockUseNodesReadOnly.mockReturnValueOnce({ nodesReadOnly: true } as ReturnType) + mockUseNodeMetaData.mockReturnValueOnce({ + isTypeFixed: true, + isSingleton: true, + isUndeletable: true, + description: 'Read only node', + author: 'Dify', + } as ReturnType) + + rerender( + + open} /> + + + + , + ) + + expect(screen.queryByText('workflow.panel.runThisStep')).not.toBeInTheDocument() + expect(screen.queryByText('workflow.common.copy')).not.toBeInTheDocument() + expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/panel-operator/__tests__/index.spec.tsx b/web/app/components/workflow/node-actions-menu/__tests__/index.spec.tsx similarity index 62% rename from web/app/components/workflow/nodes/_base/components/panel-operator/__tests__/index.spec.tsx rename to web/app/components/workflow/node-actions-menu/__tests__/index.spec.tsx index 6dab0f33a5..5624de9c16 100644 --- a/web/app/components/workflow/nodes/_base/components/panel-operator/__tests__/index.spec.tsx +++ b/web/app/components/workflow/node-actions-menu/__tests__/index.spec.tsx @@ -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() @@ -30,8 +30,8 @@ vi.mock('@/service/use-tools', () => ({ useAllWorkflowTools: vi.fn(), })) -vi.mock('../change-block', () => ({ - default: () =>
, +vi.mock('../change-block-menu-trigger', () => ({ + ChangeBlockMenuTrigger: () =>
, })) const mockUseNodeDataUpdate = vi.mocked(useNodeDataUpdate) @@ -73,18 +73,16 @@ const createQueryResult = (data: T): UseQueryResult => ({ const renderComponent = ( showHelpLink: boolean = true, onOpenChange?: (open: boolean) => void, - offset?: { mainAxis: number, crossAxis: number } | number, ) => renderWorkflowFlowComponent( - , @@ -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([])) }) - // 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() }) }) diff --git a/web/app/components/workflow/nodes/_base/components/panel-operator/change-block.tsx b/web/app/components/workflow/node-actions-menu/change-block-menu-trigger.tsx similarity index 82% rename from web/app/components/workflow/nodes/_base/components/panel-operator/change-block.tsx rename to web/app/components/workflow/node-actions-menu/change-block-menu-trigger.tsx index 7dcb7c1efa..183d515cf4 100644 --- a/web/app/components/workflow/nodes/_base/components/panel-operator/change-block.tsx +++ b/web/app/components/workflow/node-actions-menu/change-block-menu-trigger.tsx @@ -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((type, pluginDefaultValue) => { @@ -67,19 +62,17 @@ const ChangeBlock = ({ const renderTrigger = useCallback(() => { return ( -
+
+ ) }, [t]) return ( ) } - -export default memo(ChangeBlock) diff --git a/web/app/components/workflow/node-actions-menu/context-menu-content.tsx b/web/app/components/workflow/node-actions-menu/context-menu-content.tsx new file mode 100644 index 0000000000..8548450d59 --- /dev/null +++ b/web/app/components/workflow/node-actions-menu/context-menu-content.tsx @@ -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 && ( + + {model.canRun && ( + + {t('panel.runThisStep', { ns: 'workflow' })} + + )} + {model.canChangeBlock && ( + + )} + + )} + {hasRunGroup && (hasEditGroup || hasDeleteGroup || model.workflowAppHref || model.helpLinkUri) && } + {hasEditGroup && ( + + + + {t('common.copy', { ns: 'workflow' })} + + + + + {t('common.duplicate', { ns: 'workflow' })} + + + + )} + {hasEditGroup && (hasDeleteGroup || model.workflowAppHref || model.helpLinkUri) && } + {hasDeleteGroup && ( + + + + {t('operation.delete', { ns: 'common' })} + + + + )} + {hasDeleteGroup && (model.workflowAppHref || model.helpLinkUri) && } + {model.workflowAppHref && ( + + + {t('panel.openWorkflow', { ns: 'workflow' })} + + + )} + {model.workflowAppHref && model.helpLinkUri && } + {model.helpLinkUri && ( + + + {t('panel.helpLink', { ns: 'workflow' })} + + + )} + + + + ) +} diff --git a/web/app/components/workflow/node-actions-menu/dropdown-content.tsx b/web/app/components/workflow/node-actions-menu/dropdown-content.tsx new file mode 100644 index 0000000000..25d793c5f3 --- /dev/null +++ b/web/app/components/workflow/node-actions-menu/dropdown-content.tsx @@ -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 && ( + + {model.canRun && ( + + {t('panel.runThisStep', { ns: 'workflow' })} + + )} + {model.canChangeBlock && ( + + )} + + )} + {hasRunGroup && (hasEditGroup || hasDeleteGroup || model.workflowAppHref || model.helpLinkUri) && } + {hasEditGroup && ( + + + + {t('common.copy', { ns: 'workflow' })} + + + + + {t('common.duplicate', { ns: 'workflow' })} + + + + )} + {hasEditGroup && (hasDeleteGroup || model.workflowAppHref || model.helpLinkUri) && } + {hasDeleteGroup && ( + + + + {t('operation.delete', { ns: 'common' })} + + + + )} + {hasDeleteGroup && (model.workflowAppHref || model.helpLinkUri) && } + {model.workflowAppHref && ( + + + {t('panel.openWorkflow', { ns: 'workflow' })} + + + )} + {model.workflowAppHref && model.helpLinkUri && } + {model.helpLinkUri && ( + + + {t('panel.helpLink', { ns: 'workflow' })} + + + )} + + + + ) +} diff --git a/web/app/components/workflow/node-actions-menu/index.tsx b/web/app/components/workflow/node-actions-menu/index.tsx new file mode 100644 index 0000000000..3f813b93ba --- /dev/null +++ b/web/app/components/workflow/node-actions-menu/index.tsx @@ -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 ( + + + + + )} + /> + + + + + ) +} diff --git a/web/app/components/workflow/node-actions-menu/shared.tsx b/web/app/components/workflow/node-actions-menu/shared.tsx new file mode 100644 index 0000000000..a150025575 --- /dev/null +++ b/web/app/components/workflow/node-actions-menu/shared.tsx @@ -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 ( + <> + {children} + {(shortcut || hotkey) && } + + ) +} + +export function NodeActionsMenuAbout({ + author, + description, + title, +}: { + author?: string + description?: string + title: string +}) { + return ( +
+
+ {title.toLocaleUpperCase()} +
+
{description}
+
{author}
+
+ ) +} diff --git a/web/app/components/workflow/node-actions-menu/types.ts b/web/app/components/workflow/node-actions-menu/types.ts new file mode 100644 index 0000000000..565ccdcbca --- /dev/null +++ b/web/app/components/workflow/node-actions-menu/types.ts @@ -0,0 +1,8 @@ +import type { Node } from '@/app/components/workflow/types' + +export type NodeActionsMenuProps = { + id: string + data: Node['data'] + onClose: () => void + showHelpLink?: boolean +} diff --git a/web/app/components/workflow/node-actions-menu/use-node-actions-menu-model.ts b/web/app/components/workflow/node-actions-menu/use-node-actions-menu-model.ts new file mode 100644 index 0000000000..45e34e6bb2 --- /dev/null +++ b/web/app/components/workflow/node-actions-menu/use-node-actions-menu-model.ts @@ -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, + } +} diff --git a/web/app/components/workflow/node-contextmenu.tsx b/web/app/components/workflow/node-contextmenu.tsx index 27ef4029fc..e5bd7c933f 100644 --- a/web/app/components/workflow/node-contextmenu.tsx +++ b/web/app/components/workflow/node-contextmenu.tsx @@ -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 ( -
!open && handleNodeContextmenuCancel()} > - handleNodeContextmenuCancel()} - showHelpLink - /> -
+ + + + ) } - -export default memo(NodeContextmenu) diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/node-control.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/node-control.spec.tsx index 82650a61f4..62d74b64da 100644 --- a/web/app/components/workflow/nodes/_base/components/__tests__/node-control.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/__tests__/node-control.spec.tsx @@ -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 }) => ( <> diff --git a/web/app/components/workflow/nodes/_base/components/node-control.tsx b/web/app/components/workflow/nodes/_base/components/node-control.tsx index 439e097bc9..5731ab27a7 100644 --- a/web/app/components/workflow/nodes/_base/components/node-control.tsx +++ b/web/app/components/workflow/nodes/_base/components/node-control.tsx @@ -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 & { pluginInstallLocked?: boolean @@ -82,10 +82,9 @@ const NodeControl: FC = ({ ) } -
diff --git a/web/app/components/workflow/nodes/_base/components/panel-operator/__tests__/details.spec.tsx b/web/app/components/workflow/nodes/_base/components/panel-operator/__tests__/details.spec.tsx deleted file mode 100644 index eeb6e48900..0000000000 --- a/web/app/components/workflow/nodes/_base/components/panel-operator/__tests__/details.spec.tsx +++ /dev/null @@ -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) => ( -
-
{trigger()}
-
{`available:${(availableBlocksTypes || []).join(',')}`}
-
{`show-start:${String(showStartTab)}`}
-
{`ignore:${(ignoreNodeIds || []).join(',')}`}
-
{`force-start:${String(forceEnableStartTab)}`}
-
{`allow-start:${String(allowUserInputSelection)}`}
- -
- ), -})) - -vi.mock('@/app/components/workflow/hooks', async (importOriginal) => { - const actual = await importOriginal() - 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) - 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) - mockUseNodesInteractions.mockReturnValue({ - handleNodeChange, - handleNodeDelete, - handleNodesDuplicate, - handleNodeSelect, - handleNodesCopy, - } as unknown as ReturnType) - mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false } as ReturnType) - mockUseNodesSyncDraft.mockReturnValue({ - doSyncWorkflowDraft: vi.fn(), - handleSyncWorkflowDraft, - syncWorkflowDraftWhenPageClose: vi.fn(), - } as ReturnType) - 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( - , - ) - - 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) - mockUseIsChatMode.mockReturnValueOnce(true) - mockUseHooksStore.mockImplementationOnce((selector: any) => selector({ configsMap: { flowType: FlowType.appFlow } })) - mockUseNodes.mockReturnValueOnce([] as any) - - const { rerender } = render( - , - ) - - 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) - 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( - , - ) - - 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( - , - { - 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) - - renderWorkflowFlowComponent( - , - { - 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( - , - { - nodes: [], - edges: [], - }, - ) - - expect(screen.getByRole('link', { name: 'workflow.panel.openWorkflow' })).toHaveAttribute('href', '/app/app-123/workflow') - - mockUseNodesReadOnly.mockReturnValueOnce({ nodesReadOnly: true } as ReturnType) - mockUseNodeMetaData.mockReturnValueOnce({ - isTypeFixed: true, - isSingleton: true, - isUndeletable: true, - description: 'Read only node', - author: 'Dify', - } as ReturnType) - - rerender( - , - ) - - expect(screen.queryByText('workflow.panel.runThisStep')).not.toBeInTheDocument() - expect(screen.queryByText('workflow.common.copy')).not.toBeInTheDocument() - expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument() - }) - }) -}) diff --git a/web/app/components/workflow/nodes/_base/components/panel-operator/index.tsx b/web/app/components/workflow/nodes/_base/components/panel-operator/index.tsx deleted file mode 100644 index ee16fd0c06..0000000000 --- a/web/app/components/workflow/nodes/_base/components/panel-operator/index.tsx +++ /dev/null @@ -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 ( - - } - 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, - )} - > - - - - setOpen(false)} - showHelpLink={showHelpLink} - /> - - - ) -} - -export default memo(PanelOperator) diff --git a/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx b/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx deleted file mode 100644 index 8a73d4be87..0000000000 --- a/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx +++ /dev/null @@ -1,209 +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 ( -
- { - (showChangeBlock || canRunBySingle(data.type, isChildNode)) && ( - <> -
- { - canRunBySingle(data.type, isChildNode) && ( - - ) - } - { - showChangeBlock && ( - - ) - } -
-
- - ) - } - { - !nodesReadOnly && ( - <> - { - !nodeMetaData.isSingleton && ( - <> -
- - -
-
- - ) - } - { - !nodeMetaData.isUndeletable && ( - <> -
- -
-
- - ) - } - - ) - } - { - isWorkflowTool && workflowAppId && ( - <> - -
- - ) - } - { - showHelpLink && nodeMetaData.helpLinkUri && ( - <> - -
- - ) - } -
-
-
- {t('panel.about', { ns: 'workflow' }).toLocaleUpperCase()} -
-
{nodeMetaData.description}
-
- {t('panel.createdBy', { ns: 'workflow' })} - {' '} - {nodeMetaData.author} -
-
-
-
- ) -} - -export default memo(PanelOperatorPopup) diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx index ac920e4862..793e773e59 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx @@ -241,8 +241,8 @@ vi.mock('../next-step', () => ({ default: () =>
next-step
, })) -vi.mock('../panel-operator', () => ({ - default: () =>
panel-operator
, +vi.mock('@/app/components/workflow/node-actions-menu', () => ({ + NodeActionsDropdown: () =>
node-actions-menu
, })) vi.mock('../retry/retry-on-panel', () => ({ diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx index ee96563761..928e1e5ee7 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx @@ -53,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' @@ -75,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 { @@ -554,7 +554,7 @@ const BasePanel: FC = ({ ) } - +
{ ['showSingleRunPanel', 'setShowSingleRunPanel', true], ['nodeAnimation', 'setNodeAnimation', true], ['candidateNode', 'setCandidateNode', undefined], - ['nodeMenu', 'setNodeMenu', { top: 100, left: 200, nodeId: 'n1' }], + ['nodeMenu', 'setNodeMenu', { clientX: 200, clientY: 100, nodeId: 'n1' }], ['showAssignVariablePopup', 'setShowAssignVariablePopup', undefined], ['hoveringAssignVariableGroupId', 'setHoveringAssignVariableGroupId', 'group-1'], ['connectingNodePayload', 'setConnectingNodePayload', { nodeId: 'n1', nodeType: 'llm', handleType: 'source', handleId: 'h1' }], diff --git a/web/app/components/workflow/store/workflow/node-slice.ts b/web/app/components/workflow/store/workflow/node-slice.ts index eb16388ef4..fc96635acb 100644 --- a/web/app/components/workflow/store/workflow/node-slice.ts +++ b/web/app/components/workflow/store/workflow/node-slice.ts @@ -19,8 +19,8 @@ export type NodeSliceShape = { candidateNode?: Node setCandidateNode: (candidateNode?: Node) => void nodeMenu?: { - top: number - left: number + clientX: number + clientY: number nodeId: string } setNodeMenu: (nodeMenu: NodeSliceShape['nodeMenu']) => void From 853b8590320603629b1b74a3ee022ea3f7a310a1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 23:22:26 +0900 Subject: [PATCH 14/21] chore(deps-dev): bump the dev group in /api with 6 updates (#35782) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- api/pyproject.toml | 12 +++++------ api/uv.lock | 54 +++++++++++++++++++++++----------------------- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/api/pyproject.toml b/api/pyproject.toml index 20eb54c9da..57a57390ff 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -127,7 +127,7 @@ dev = [ "testcontainers>=4.14.2", "types-aiofiles>=25.1.0", "types-beautifulsoup4>=4.12.0", - "types-cachetools>=6.2.0", + "types-cachetools>=7.0.0.20260503", "types-colorama>=0.4.15", "types-defusedxml>=0.7.0", "types-deprecated>=1.3.1", @@ -135,7 +135,7 @@ dev = [ "types-flask-cors>=6.0.0", "types-flask-migrate>=4.1.0", "types-gevent>=26.4.0", - "types-greenlet>=3.4.0", + "types-greenlet>=3.5.0.20260428", "types-html5lib>=1.1.11", "types-markdown>=3.10.2", "types-oauthlib>=3.3.0", @@ -143,7 +143,7 @@ dev = [ "types-olefile>=0.47.0", "types-openpyxl>=3.1.5", "types-pexpect>=4.9.0", - "types-protobuf>=7.34.1", + "types-protobuf>=7.34.1.20260503", "types-psutil>=7.2.2", "types-psycopg2>=2.9.21.20260422", "types-pygments>=2.20.0", @@ -158,11 +158,11 @@ dev = [ "types-tensorflow>=2.18.0.20260408", "types-tqdm>=4.67.3.20260408", "types-ujson>=5.10.0", - "boto3-stubs>=1.42.96", + "boto3-stubs>=1.43.2", "types-jmespath>=1.1.0.20260408", - "hypothesis>=6.152.3", + "hypothesis>=6.152.4", "types_pyOpenSSL>=24.1.0", - "types_cffi>=2.0.0.20260408", + "types_cffi>=2.0.0.20260429", "types_setuptools>=82.0.0.20260408", "pandas-stubs>=3.0.0", "scipy-stubs>=1.17.1.4", diff --git a/api/uv.lock b/api/uv.lock index dd09fc7579..06a1b8edf1 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -618,15 +618,15 @@ wheels = [ [[package]] name = "boto3-stubs" -version = "1.42.96" +version = "1.43.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore-stubs" }, { name = "types-s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/86/65f45f84621cccc2471871088bab8fe515b4346ba9e48d9001484ec440d6/boto3_stubs-1.42.96.tar.gz", hash = "sha256:1e7819c34d1eae8e5e3cfaf9d144fdcad65aad184b380488871de1d0b2851879", size = 102691, upload-time = "2026-04-24T20:25:13.984Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/7f/399bcdeaa60a89aafe5292c8364c313177d22b886dffc1bd7b56fe817900/boto3_stubs-1.43.2.tar.gz", hash = "sha256:0d46636f3e761a92070114b39a76b154c5da6c5794c890e1440a7f191bf1ff2e", size = 102658, upload-time = "2026-05-01T20:31:36.963Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/51/bdac1ff9fd4321091183776c5adffce5fc7b4d0fec7e38af9064e24a2497/boto3_stubs-1.42.96-py3-none-any.whl", hash = "sha256:2c112e257f40006147a53f6f62075804689154271973b2807f5656feaa804216", size = 70668, upload-time = "2026-04-24T20:25:09.736Z" }, + { url = "https://files.pythonhosted.org/packages/da/df/17647562444b2047ca325eaaf2fea738571822b7b4efdaa6bacf0fd4fff9/boto3_stubs-1.43.2-py3-none-any.whl", hash = "sha256:941f2907236223a1209704eaf708d3cdf1ecc8695618c558f9fb9e23e90c513b", size = 70653, upload-time = "2026-05-01T20:31:30.057Z" }, ] [package.optional-dependencies] @@ -1619,12 +1619,12 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "basedpyright", specifier = ">=1.39.3" }, - { name = "boto3-stubs", specifier = ">=1.42.96" }, + { name = "boto3-stubs", specifier = ">=1.43.2" }, { name = "celery-types", specifier = ">=0.23.0" }, { name = "coverage", specifier = ">=7.13.4" }, { name = "dotenv-linter", specifier = ">=0.7.0" }, { name = "faker", specifier = ">=40.15.0" }, - { name = "hypothesis", specifier = ">=6.152.3" }, + { name = "hypothesis", specifier = ">=6.152.4" }, { name = "import-linter", specifier = ">=2.3" }, { name = "lxml-stubs", specifier = ">=0.5.1" }, { name = "mypy", specifier = ">=1.20.2" }, @@ -1642,8 +1642,8 @@ dev = [ { name = "testcontainers", specifier = ">=4.14.2" }, { name = "types-aiofiles", specifier = ">=25.1.0" }, { name = "types-beautifulsoup4", specifier = ">=4.12.0" }, - { name = "types-cachetools", specifier = ">=6.2.0" }, - { name = "types-cffi", specifier = ">=2.0.0.20260408" }, + { name = "types-cachetools", specifier = ">=7.0.0.20260503" }, + { name = "types-cffi", specifier = ">=2.0.0.20260429" }, { name = "types-colorama", specifier = ">=0.4.15" }, { name = "types-defusedxml", specifier = ">=0.7.0" }, { name = "types-deprecated", specifier = ">=1.3.1" }, @@ -1651,7 +1651,7 @@ dev = [ { name = "types-flask-cors", specifier = ">=6.0.0" }, { name = "types-flask-migrate", specifier = ">=4.1.0" }, { name = "types-gevent", specifier = ">=26.4.0" }, - { name = "types-greenlet", specifier = ">=3.4.0" }, + { name = "types-greenlet", specifier = ">=3.5.0.20260428" }, { name = "types-html5lib", specifier = ">=1.1.11" }, { name = "types-jmespath", specifier = ">=1.1.0.20260408" }, { name = "types-markdown", specifier = ">=3.10.2" }, @@ -1660,7 +1660,7 @@ dev = [ { name = "types-olefile", specifier = ">=0.47.0" }, { name = "types-openpyxl", specifier = ">=3.1.5" }, { name = "types-pexpect", specifier = ">=4.9.0" }, - { name = "types-protobuf", specifier = ">=7.34.1" }, + { name = "types-protobuf", specifier = ">=7.34.1.20260503" }, { name = "types-psutil", specifier = ">=7.2.2" }, { name = "types-psycopg2", specifier = ">=2.9.21.20260422" }, { name = "types-pygments", specifier = ">=2.20.0" }, @@ -3319,14 +3319,14 @@ wheels = [ [[package]] name = "hypothesis" -version = "6.152.3" +version = "6.152.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sortedcontainers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/70/90/fc0b263b6f2622e5f8d2aa93f2e95ba79718a5faa7d2a74bfab10d6b0905/hypothesis-6.152.3.tar.gz", hash = "sha256:c4e5300d3755b6c8a270a28fe5abff40153e927328e89d2bb0229c1384618998", size = 466478, upload-time = "2026-04-26T17:31:07.657Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/c7/3147bd903d6b18324a016d43a259cf5b4bb4545e1ead6773dc8a0374e70a/hypothesis-6.152.4.tar.gz", hash = "sha256:31c8f9ce619716f543e2710b489b1633c833586641d9e6c94cee03f109a5afc4", size = 466444, upload-time = "2026-04-27T20:18:37.594Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/90/38/15475b91a4c12721d2be3349e9d6cf8649c76ed9bc1287e2de7c8d06c261/hypothesis-6.152.3-py3-none-any.whl", hash = "sha256:4b47f00916c858ed49cf870a2f08b04e5fff5afae0bb78f3b4a6d9c74fd6c7bc", size = 532154, upload-time = "2026-04-26T17:31:04.42Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/0f50dd0d92e8a7dffc24f69ab910ff81db89b2f082ba42682bd57695e4d2/hypothesis-6.152.4-py3-none-any.whl", hash = "sha256:e730fd93c7578182efadc7f90b3c5437ee4d55edf738930eb5043c81ac1d97e8", size = 532145, upload-time = "2026-04-27T20:18:35.043Z" }, ] [[package]] @@ -3969,11 +3969,11 @@ wheels = [ [[package]] name = "mypy-boto3-bedrock-runtime" -version = "1.42.42" +version = "1.43.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/46/bb/65dc1b2c5796a6ab5f60bdb57343bd6c3ecb82251c580eca415c8548333e/mypy_boto3_bedrock_runtime-1.42.42.tar.gz", hash = "sha256:3a4088218478b6fbbc26055c03c95bee4fc04624a801090b3cce3037e8275c8d", size = 29840, upload-time = "2026-02-04T20:53:05.999Z" } +sdist = { url = "https://files.pythonhosted.org/packages/21/f2/61519c0162307b1e4d47f63ed8b25390874640934f3d2d25c5d6c5078dd8/mypy_boto3_bedrock_runtime-1.43.0.tar.gz", hash = "sha256:19fc3167de6e66dd7a0ab293adc55c93e2fd67be35e8ab4fc3a7523a380752ce", size = 29903, upload-time = "2026-04-29T22:57:57.561Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/43/7ea062f2228f47b5779dcfa14dab48d6e29f979b35d1a5102b0ba80b9c1b/mypy_boto3_bedrock_runtime-1.42.42-py3-none-any.whl", hash = "sha256:b2d16eae22607d0685f90796b3a0afc78c0b09d45872e00eafd634a31dd9358f", size = 36077, upload-time = "2026-02-04T20:53:01.768Z" }, + { url = "https://files.pythonhosted.org/packages/40/4d/7e4c4d55af23b2b1304d6814db8c406beab7977056963200230417c1a2db/mypy_boto3_bedrock_runtime-1.43.0-py3-none-any.whl", hash = "sha256:a125296f992093d58bdcd95176002680fa81ca8a8b8bdf02afad7e5f2d8966aa", size = 36172, upload-time = "2026-04-29T22:57:54.777Z" }, ] [[package]] @@ -6585,23 +6585,23 @@ wheels = [ [[package]] name = "types-cachetools" -version = "6.2.0.20260408" +version = "7.0.0.20260503" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/61/475b0e8f4a92e5e33affcc6f4e6344c6dee540824021d22f695ea170da63/types_cachetools-6.2.0.20260408.tar.gz", hash = "sha256:0d8ae2dd5ba0b4cfe6a55c34396dd0415f1be07d0033d84781cdc4ed9c2ebc6b", size = 9854, upload-time = "2026-04-08T04:31:49.665Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/57/5d3b8b3e66b002911ec1274e87f904eeee1d843c8713d95476c25c29cf31/types_cachetools-7.0.0.20260503.tar.gz", hash = "sha256:dfa4dcdf453f397dfc6d69fc0a57423ac1f248393f70aa56b5d05fac2df7a96c", size = 10033, upload-time = "2026-05-03T05:19:54.128Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bb/7d/579f50f4f004ee93c7d1baa95339591cac1fe02f4e3fb8fc0f900ee4a80f/types_cachetools-6.2.0.20260408-py3-none-any.whl", hash = "sha256:470e0b274737feae74beed3d764885bf4664002ecc393fba3778846b13ce92cb", size = 9350, upload-time = "2026-04-08T04:31:48.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a8/84562723d9a3572e0851d82bdea6bed5a7dc033c6bd648f492c76b8c4ac8/types_cachetools-7.0.0.20260503-py3-none-any.whl", hash = "sha256:011b4fe0e85ef05c4a2471a4fda40254a78746b501cc1727359233872bb3a4e9", size = 9493, upload-time = "2026-05-03T05:19:53.124Z" }, ] [[package]] name = "types-cffi" -version = "2.0.0.20260408" +version = "2.0.0.20260429" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "types-setuptools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/64/67/eb4ef3408fdc0b4e5af38b30c0e6ad4663b41bdae9fb85a9f09a8db61a99/types_cffi-2.0.0.20260408.tar.gz", hash = "sha256:aa8b9c456ab715c079fc655929811f21f331bfb940f4a821987c581bf4e36230", size = 17541, upload-time = "2026-04-08T04:36:03.918Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/7d/56b9be8b0f9dfbffb7c73e248aacf178693ff3c6cf765b77c43a1e886e04/types_cffi-2.0.0.20260429.tar.gz", hash = "sha256:afe7d9777a2921139623af0b94647637a5bd0b938b77ec125e5e5e068a1727bd", size = 17562, upload-time = "2026-04-29T05:16:43.29Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/a3/7fbd93ededcc7c77e9e5948b9794161733ebdbf618a27965b1bea0e728a4/types_cffi-2.0.0.20260408-py3-none-any.whl", hash = "sha256:68bd296742b4ff7c0afe3547f50bd0acc55416ecf322ffefd2b7344ef6388a42", size = 20101, upload-time = "2026-04-08T04:36:02.995Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2c/79fa47a70d534f63a54b6d22e28cc842f8c6d9ebec93048355b0020bc7a9/types_cffi-2.0.0.20260429-py3-none-any.whl", hash = "sha256:6a4237bfdbd50e4d0726929070d8b9983bde541726a5a6fe0e8e24e78c1b3826", size = 20103, upload-time = "2026-04-29T05:16:42.155Z" }, ] [[package]] @@ -6680,11 +6680,11 @@ wheels = [ [[package]] name = "types-greenlet" -version = "3.4.0.20260409" +version = "3.5.0.20260428" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/27/a6/668751bc864efe820e1eb12c2a77f9e62537f433cc002e483ad01badb04b/types_greenlet-3.4.0.20260409.tar.gz", hash = "sha256:81d2cf628934a16856bb9e54136def8de5356e934f0ad5d5474f219a0c5cb205", size = 8976, upload-time = "2026-04-09T04:22:31.693Z" } +sdist = { url = "https://files.pythonhosted.org/packages/79/50/d255c0e068679d7b9441d9408424ddf9e1f35620548e121003b3660af526/types_greenlet-3.5.0.20260428.tar.gz", hash = "sha256:6c188f5e9c5775d50bd00780a3eb1fb3cde17c396cf9703e3d417936e9e7a082", size = 9003, upload-time = "2026-04-28T05:19:43.062Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/3f/c8a4d8782f78fccb4b5fe91c5eae2efce6648072754bc7096b1e3b5407ad/types_greenlet-3.4.0.20260409-py3-none-any.whl", hash = "sha256:cbceadb4594eccd95b57b3f7fa8a9b851488f5e6c05026f4a3db9aac02ec8333", size = 8812, upload-time = "2026-04-09T04:22:30.734Z" }, + { url = "https://files.pythonhosted.org/packages/30/e5/5ff280f02392ced53cb5e866b660b492b4245b1395a61e57d2a6dc02977b/types_greenlet-3.5.0.20260428-py3-none-any.whl", hash = "sha256:7b0f23ce84ee93474d4aa8058920f0578181e11431be92ce9a4ad4123de2c41b", size = 8809, upload-time = "2026-04-28T05:19:41.976Z" }, ] [[package]] @@ -6764,11 +6764,11 @@ wheels = [ [[package]] name = "types-protobuf" -version = "7.34.1.20260408" +version = "7.34.1.20260503" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5b/b1/4521e68c2cc17703d80eb42796751345376dd4c706f84007ef5e7c707774/types_protobuf-7.34.1.20260408.tar.gz", hash = "sha256:e2c0a0430e08c75b52671a6f0035abfdcc791aad12af16274282de1b721758ab", size = 68835, upload-time = "2026-04-08T04:26:43.613Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a0/31/87969cb3e62287bde7598b78b3c098d2873d54f5fb5a7cfbcaa73b8c965e/types_protobuf-7.34.1.20260503.tar.gz", hash = "sha256:effbc819aa17e02448dde99f089c6794662d66f4b2797e922f185ffe0b24e766", size = 68830, upload-time = "2026-05-03T05:19:50.739Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/b5/0bc9874d89c58fb0ce851e150055ce732d254dbb10b06becbc7635d0d635/types_protobuf-7.34.1.20260408-py3-none-any.whl", hash = "sha256:ebbcd4e27b145aef6a59bc0cb6c013b3528151c1ba5e7f7337aeee355d276a5e", size = 86012, upload-time = "2026-04-08T04:26:42.566Z" }, + { url = "https://files.pythonhosted.org/packages/f9/67/a33fb18090a927794a5ee4b1a30730b528ace0dad6b18932540d21258184/types_protobuf-7.34.1.20260503-py3-none-any.whl", hash = "sha256:75fd66121d56785c91828b8bf7b511f39ba847f11e682573e41847f01e9cd1de", size = 86019, upload-time = "2026-05-03T05:19:49.486Z" }, ] [[package]] From b43ebf539df71dafe2a1354c8ec02a236184e56f Mon Sep 17 00:00:00 2001 From: Jingyi Date: Mon, 4 May 2026 08:07:21 -0700 Subject: [PATCH 15/21] fix: preserve single-run input variable types (#35710) --- .../hooks/__tests__/use-one-step-run.spec.ts | 239 ++++++++++++++++++ .../nodes/_base/hooks/use-one-step-run.ts | 19 +- 2 files changed, 253 insertions(+), 5 deletions(-) create mode 100644 web/app/components/workflow/nodes/_base/hooks/__tests__/use-one-step-run.spec.ts diff --git a/web/app/components/workflow/nodes/_base/hooks/__tests__/use-one-step-run.spec.ts b/web/app/components/workflow/nodes/_base/hooks/__tests__/use-one-step-run.spec.ts new file mode 100644 index 0000000000..108ab818f3 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/hooks/__tests__/use-one-step-run.spec.ts @@ -0,0 +1,239 @@ +import { renderHook } from '@testing-library/react' +import { + BlockEnum, + InputVarType, + VarType, +} from '@/app/components/workflow/types' +import { FlowType } from '@/types/common' +import useOneStepRun from '../use-one-step-run' + +const mockWorkflowState = { + conversationVariables: [], + dataSourceList: [], + nodesWithInspectVars: [], + setNodesWithInspectVars: vi.fn(), + setShowSingleRunPanel: vi.fn(), + setIsListening: vi.fn(), + setListeningTriggerType: vi.fn(), + setListeningTriggerNodeId: vi.fn(), + setListeningTriggerNodeIds: vi.fn(), + setListeningTriggerIsAll: vi.fn(), + setShowVariableInspectPanel: vi.fn(), +} + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('@langgenius/dify-ui/toast', () => ({ + toast: { + error: vi.fn(), + }, +})) + +vi.mock('@/app/components/base/amplitude', () => ({ + trackEvent: vi.fn(), +})) + +vi.mock('@/app/components/workflow/hooks', () => ({ + useIsChatMode: () => false, + useNodeDataUpdate: () => ({ + handleNodeDataUpdate: vi.fn(), + }), + useWorkflow: () => ({ + getBeforeNodesInSameBranch: () => [ + { + id: 'start', + data: { + type: 'start', + title: 'Start', + variables: [], + }, + }, + ], + getBeforeNodesInSameBranchIncludeParent: () => [ + { + id: 'start', + data: { + type: 'start', + title: 'Start', + variables: [], + }, + }, + ], + }), +})) + +vi.mock('@/app/components/workflow/hooks/use-inspect-vars-crud', () => ({ + default: () => ({ + appendNodeInspectVars: vi.fn(), + invalidateSysVarValues: vi.fn(), + invalidateConversationVarValues: vi.fn(), + }), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: typeof mockWorkflowState) => unknown) => selector(mockWorkflowState), + useWorkflowStore: () => ({ + getState: () => mockWorkflowState, + }), +})) + +vi.mock('reactflow', () => ({ + useStoreApi: () => ({ + getState: () => ({ + getNodes: () => [], + }), + }), +})) + +vi.mock('@/service/use-tools', () => ({ + useAllBuiltInTools: () => ({ data: [] }), + useAllCustomTools: () => ({ data: [] }), + useAllWorkflowTools: () => ({ data: [] }), + useAllMCPTools: () => ({ data: [] }), +})) + +vi.mock('@/service/use-workflow', () => ({ + useInvalidLastRun: () => vi.fn(), +})) + +vi.mock('@/service/workflow', () => ({ + fetchNodeInspectVars: vi.fn(), + getIterationSingleNodeRunUrl: vi.fn(), + getLoopSingleNodeRunUrl: vi.fn(), + singleNodeRun: vi.fn(), +})) + +vi.mock('@/service/base', () => ({ + post: vi.fn(), + ssePost: vi.fn(), +})) + +vi.mock('@/context/event-emitter', () => ({ + useEventEmitterContextContext: () => ({ + eventEmitter: { + useSubscription: vi.fn(), + }, + }), +})) + +vi.mock('../components/variable/use-match-schema-type', () => ({ + default: () => ({ + schemaTypeDefinitions: [], + }), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/variable/use-match-schema-type', () => ({ + default: () => ({ + schemaTypeDefinitions: [], + }), +})) + +vi.mock('@/app/components/workflow/nodes/assigner/default', () => ({ + default: {}, +})) +vi.mock('@/app/components/workflow/nodes/code/default', () => ({ + default: {}, +})) +vi.mock('@/app/components/workflow/nodes/document-extractor/default', () => ({ + default: {}, +})) +vi.mock('@/app/components/workflow/nodes/http/default', () => ({ + default: {}, +})) +vi.mock('@/app/components/workflow/nodes/human-input/default', () => ({ + default: {}, +})) +vi.mock('@/app/components/workflow/nodes/if-else/default', () => ({ + default: {}, +})) +vi.mock('@/app/components/workflow/nodes/iteration/default', () => ({ + default: {}, +})) +vi.mock('@/app/components/workflow/nodes/knowledge-retrieval/default', () => ({ + default: {}, +})) +vi.mock('@/app/components/workflow/nodes/llm/default', () => ({ + default: {}, +})) +vi.mock('@/app/components/workflow/nodes/loop/default', () => ({ + default: {}, +})) +vi.mock('@/app/components/workflow/nodes/parameter-extractor/default', () => ({ + default: {}, +})) +vi.mock('@/app/components/workflow/nodes/question-classifier/default', () => ({ + default: {}, +})) +vi.mock('@/app/components/workflow/nodes/template-transform/default', () => ({ + default: {}, +})) +vi.mock('@/app/components/workflow/nodes/tool/default', () => ({ + default: {}, +})) +vi.mock('@/app/components/workflow/nodes/variable-assigner/default', () => ({ + default: {}, +})) + +const renderUseOneStepRun = () => renderHook(() => useOneStepRun({ + id: 'if-else-node', + flowId: 'app-id', + flowType: FlowType.appFlow, + data: { + type: BlockEnum.IfElse, + title: 'IF/ELSE', + desc: '', + }, + defaultRunInputData: {}, + isRunAfterSingleRun: false, + isPaused: false, +})) + +describe('useOneStepRun single-run input vars', () => { + beforeEach(() => { + vi.clearAllMocks() + Object.defineProperty(globalThis, 'location', { + value: { + pathname: '/app/test-app/workflow', + }, + configurable: true, + }) + }) + + it('uses value_type when the variable cannot be resolved from output vars', () => { + const { result } = renderUseOneStepRun() + + const inputs = result.current.toVarInputs([ + { + variable: '#start.amount#', + value_selector: ['start', 'amount'], + value_type: VarType.number, + }, + ]) + + expect(inputs).toMatchObject([ + { + variable: '#start.amount#', + type: InputVarType.number, + }, + ]) + }) + + it('resolves global system vars by full variable name', () => { + const { result } = renderUseOneStepRun() + + const inputs = result.current.varSelectorsToVarInputs([ + ['sys', 'timestamp'], + ]) + + expect(inputs).toMatchObject([ + { + variable: '#sys.timestamp#', + type: InputVarType.number, + }, + ]) + }) +}) diff --git a/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts b/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts index a455ea480a..810029f5ad 100644 --- a/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts +++ b/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts @@ -178,13 +178,15 @@ const useOneStepRun = ({ } const allOutputVars = toNodeOutputVars(availableNodes, isChatMode, undefined, undefined, conversationVariables, [], allPluginInfoList, schemaTypeDefinitions) - const targetVar = allOutputVars.find(item => isSystem ? !!item.isStartNode : item.nodeId === valueSelector[0]) + if (isSystem) { + const selectorKey = valueSelector.join('.') + return allOutputVars.flatMap(item => item.vars).find(item => item.variable === selectorKey) + } + + const targetVar = allOutputVars.find(item => item.nodeId === valueSelector[0]) if (!targetVar) return undefined - if (isSystem) - return targetVar.vars.find(item => item.variable.split('.')[1] === valueSelector[1]) - let curr: any = targetVar.vars for (let i = 1; i < valueSelector.length; i++) { const key = valueSelector[i] @@ -1079,12 +1081,19 @@ const useOneStepRun = ({ const varInputs = variables.filter(item => !isENV(item.value_selector)).map((item) => { const originalVar = getVar(item.value_selector) if (!originalVar) { + const fallbackType = item.value_type + ? varTypeToInputVarType(item.value_type, { + isSelect: !!item.options?.length, + isParagraph: !!item.isParagraph, + }) + : InputVarType.textInput return { label: item.label || item.variable, variable: item.variable, - type: InputVarType.textInput, + type: fallbackType, required: true, value_selector: item.value_selector, + options: item.options, } } return { From 90fe54ca9ef24d1739a6a45bc16cb532d8a3aed6 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Tue, 5 May 2026 07:12:26 +0800 Subject: [PATCH 16/21] refactor(web): migrate workflow panel context menu primitive (#35787) --- .../__tests__/panel-contextmenu.spec.tsx | 181 +++++++------- .../__tests__/use-panel-interactions.spec.ts | 22 +- .../use-selection-interactions.spec.ts | 2 +- .../workflow/hooks/use-panel-interactions.ts | 6 +- .../components/workflow/panel-contextmenu.tsx | 230 ++++++++++-------- .../__tests__/index.spec.tsx | 6 +- .../__tests__/version-history-item.spec.tsx | 12 +- .../__tests__/action-menu-item.spec.tsx} | 6 +- .../__tests__/index.spec.tsx | 14 +- .../__tests__/use-action-menu.spec.tsx} | 12 +- .../action-menu-item.tsx} | 6 +- .../{context-menu => action-menu}/index.tsx | 24 +- .../use-action-menu.ts} | 8 +- .../panel/version-history-panel/index.tsx | 4 +- .../version-history-item.tsx | 12 +- .../store/__tests__/workflow-store.spec.ts | 2 +- .../workflow/__tests__/panel-slice.spec.ts | 4 +- .../workflow/store/workflow/panel-slice.ts | 4 +- 18 files changed, 279 insertions(+), 276 deletions(-) rename web/app/components/workflow/panel/version-history-panel/{context-menu/__tests__/menu-item.spec.tsx => action-menu/__tests__/action-menu-item.spec.tsx} (89%) rename web/app/components/workflow/panel/version-history-panel/{context-menu => action-menu}/__tests__/index.spec.tsx (75%) rename web/app/components/workflow/panel/version-history-panel/{context-menu/__tests__/use-context-menu.spec.tsx => action-menu/__tests__/use-action-menu.spec.tsx} (83%) rename web/app/components/workflow/panel/version-history-panel/{context-menu/menu-item.tsx => action-menu/action-menu-item.tsx} (89%) rename web/app/components/workflow/panel/version-history-panel/{context-menu => action-menu}/index.tsx (68%) rename web/app/components/workflow/panel/version-history-panel/{context-menu/use-context-menu.ts => action-menu/use-action-menu.ts} (89%) diff --git a/web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx index 2c9c457245..6250ba45bd 100644 --- a/web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx +++ b/web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx @@ -1,107 +1,63 @@ -import type { ReactNode } from 'react' -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import PanelContextmenu from '../panel-contextmenu' +import { BlockEnum } from '../types' +import { createNode } from './fixtures' +import { renderWorkflowFlowComponent } from './workflow-test-env' -const mockUseClickAway = vi.hoisted(() => vi.fn()) const mockUseTranslation = vi.hoisted(() => vi.fn()) -const mockUseStore = vi.hoisted(() => vi.fn()) const mockUseNodesInteractions = vi.hoisted(() => vi.fn()) const mockUsePanelInteractions = vi.hoisted(() => vi.fn()) const mockUseWorkflowStartRun = vi.hoisted(() => vi.fn()) const mockUseWorkflowMoveMode = vi.hoisted(() => vi.fn()) const mockUseOperator = vi.hoisted(() => vi.fn()) const mockUseDSL = vi.hoisted(() => vi.fn()) - -vi.mock('ahooks', () => ({ - useClickAway: (...args: unknown[]) => mockUseClickAway(...args), -})) +const mockUseNodesReadOnly = vi.hoisted(() => vi.fn()) +const mockUseAvailableBlocks = vi.hoisted(() => vi.fn()) +const mockUseNodesMetaData = vi.hoisted(() => vi.fn()) +const mockUseIsChatMode = vi.hoisted(() => vi.fn()) vi.mock('react-i18next', () => ({ useTranslation: () => mockUseTranslation(), })) -vi.mock('@/app/components/workflow/store', () => ({ - useStore: (selector: (state: { - panelMenu?: { left: number, top: number } - clipboardElements: unknown[] - pendingComment: null | { pageX: number, pageY: number, elementX: number, elementY: number } - setCommentPlacing: (placing: boolean) => void - setCommentQuickAdd: (quickAdd: boolean) => void - setShowImportDSLModal: (visible: boolean) => void - }) => unknown) => mockUseStore(selector), -})) - vi.mock('@/app/components/workflow/hooks', () => ({ - useNodesInteractions: () => mockUseNodesInteractions(), - usePanelInteractions: () => mockUsePanelInteractions(), - useWorkflowStartRun: () => mockUseWorkflowStartRun(), - useWorkflowMoveMode: () => mockUseWorkflowMoveMode(), + useAvailableBlocks: () => mockUseAvailableBlocks(), useDSL: () => mockUseDSL(), + useIsChatMode: () => mockUseIsChatMode(), + useNodesInteractions: () => mockUseNodesInteractions(), + useNodesMetaData: () => mockUseNodesMetaData(), + useNodesReadOnly: () => mockUseNodesReadOnly(), + usePanelInteractions: () => mockUsePanelInteractions(), + useWorkflowMoveMode: () => mockUseWorkflowMoveMode(), + useWorkflowStartRun: () => mockUseWorkflowStartRun(), })) vi.mock('@/app/components/workflow/operator/hooks', () => ({ useOperator: () => mockUseOperator(), })) -vi.mock('@/app/components/workflow/operator/add-block', () => ({ - __esModule: true, - default: ({ renderTrigger }: { renderTrigger: () => ReactNode }) => ( -
{renderTrigger()}
- ), -})) - -vi.mock('@/app/components/base/divider', () => ({ - __esModule: true, - default: ({ className }: { className?: string }) =>
, -})) - -vi.mock('@/app/components/workflow/shortcuts-name', () => ({ - __esModule: true, - default: ({ keys }: { keys: string[] }) => {keys.join('+')}, -})) - describe('PanelContextmenu', () => { const mockHandleNodesPaste = vi.fn() const mockHandlePaneContextmenuCancel = vi.fn() const mockHandleStartWorkflowRun = vi.fn() + const mockHandleWorkflowStartRunInChatflow = vi.fn() const mockHandleAddNote = vi.fn() const mockExportCheck = vi.fn() - const mockSetShowImportDSLModal = vi.fn() - const mockSetCommentPlacing = vi.fn() - const mockSetCommentQuickAdd = vi.fn() - let panelMenu: { left: number, top: number } | undefined - let clipboardElements: unknown[] - let pendingComment: null | { pageX: number, pageY: number, elementX: number, elementY: number } - let clickAwayHandler: (() => void) | undefined + const defaultNodesMetaDataMap = { + [BlockEnum.Answer]: { + defaultValue: { + title: 'Answer', + desc: '', + type: BlockEnum.Answer, + }, + }, + } beforeEach(() => { vi.clearAllMocks() - panelMenu = undefined - clipboardElements = [] - pendingComment = null - clickAwayHandler = undefined - - mockUseClickAway.mockImplementation((handler: () => void) => { - clickAwayHandler = handler - }) mockUseTranslation.mockReturnValue({ t: (key: string) => key, }) - mockUseStore.mockImplementation((selector: (state: { - panelMenu?: { left: number, top: number } - clipboardElements: unknown[] - pendingComment: null | { pageX: number, pageY: number, elementX: number, elementY: number } - setCommentPlacing: (placing: boolean) => void - setCommentQuickAdd: (quickAdd: boolean) => void - setShowImportDSLModal: (visible: boolean) => void - }) => unknown) => selector({ - panelMenu, - clipboardElements, - pendingComment, - setCommentPlacing: mockSetCommentPlacing, - setCommentQuickAdd: mockSetCommentQuickAdd, - setShowImportDSLModal: mockSetShowImportDSLModal, - })) mockUseNodesInteractions.mockReturnValue({ handleNodesPaste: mockHandleNodesPaste, }) @@ -110,6 +66,7 @@ describe('PanelContextmenu', () => { }) mockUseWorkflowStartRun.mockReturnValue({ handleStartWorkflowRun: mockHandleStartWorkflowRun, + handleWorkflowStartRunInChatflow: mockHandleWorkflowStartRunInChatflow, }) mockUseWorkflowMoveMode.mockReturnValue({ isCommentModeAvailable: false, @@ -120,50 +77,86 @@ describe('PanelContextmenu', () => { mockUseDSL.mockReturnValue({ exportCheck: mockExportCheck, }) + mockUseNodesReadOnly.mockReturnValue({ + nodesReadOnly: false, + }) + mockUseAvailableBlocks.mockReturnValue({ + availableNextBlocks: [BlockEnum.Answer], + }) + mockUseNodesMetaData.mockReturnValue({ + nodesMap: defaultNodesMetaDataMap, + }) + mockUseIsChatMode.mockReturnValue(false) }) it('should stay hidden when the panel menu is absent', () => { - render() + renderWorkflowFlowComponent() - expect(screen.queryByTestId('add-block')).not.toBeInTheDocument() + expect(screen.queryByText('common.addBlock')).not.toBeInTheDocument() }) - it('should keep paste disabled when the clipboard is empty', () => { - panelMenu = { left: 24, top: 48 } - - render() + it('should keep paste disabled when the clipboard is empty', async () => { + renderWorkflowFlowComponent(, { + initialStoreState: { + panelMenu: { clientX: 24, clientY: 48 }, + }, + hooksStoreProps: {}, + }) + await screen.findByText('common.pasteHere') fireEvent.click(screen.getByText('common.pasteHere')) expect(mockHandleNodesPaste).not.toHaveBeenCalled() expect(mockHandlePaneContextmenuCancel).not.toHaveBeenCalled() }) - it('should render actions, position the menu, and execute each action', () => { - panelMenu = { left: 24, top: 48 } - clipboardElements = [{ id: 'copied-node' }] - const { container } = render() - - expect(screen.getByTestId('add-block')).toHaveTextContent('common.addBlock') - expect(screen.getByRole('button', { name: /common\.run/i })).toHaveTextContent(/Alt\s*R/) - expect(screen.getByRole('button', { name: /common\.pasteHere/i })).toHaveTextContent(/Ctrl\s*V/) - expect(container.firstChild).toHaveStyle({ - left: '24px', - top: '48px', + it('should render actions and execute enabled actions', async () => { + const { store } = renderWorkflowFlowComponent(, { + initialStoreState: { + panelMenu: { clientX: 24, clientY: 48 }, + clipboardElements: [createNode({ id: 'copied-node' })], + }, + hooksStoreProps: {}, }) + expect(await screen.findByText('common.addBlock')).toBeInTheDocument() + expect(screen.getByText('common.run')).toBeInTheDocument() + expect(screen.getByText('common.pasteHere')).toBeInTheDocument() + fireEvent.click(screen.getByText('nodes.note.addNote')) fireEvent.click(screen.getByText('common.run')) fireEvent.click(screen.getByText('common.pasteHere')) fireEvent.click(screen.getByText('export')) fireEvent.click(screen.getByText('importApp')) - clickAwayHandler?.() - expect(mockHandleAddNote).toHaveBeenCalledTimes(1) - expect(mockHandleStartWorkflowRun).toHaveBeenCalledTimes(1) - expect(mockHandleNodesPaste).toHaveBeenCalledTimes(1) - expect(mockExportCheck).toHaveBeenCalledTimes(1) - expect(mockSetShowImportDSLModal).toHaveBeenCalledWith(true) - expect(mockHandlePaneContextmenuCancel).toHaveBeenCalledTimes(4) + await waitFor(() => { + expect(mockHandleAddNote).toHaveBeenCalledTimes(1) + expect(mockHandleStartWorkflowRun).toHaveBeenCalledTimes(1) + expect(mockHandleNodesPaste).toHaveBeenCalledTimes(1) + expect(mockExportCheck).toHaveBeenCalledTimes(1) + expect(store.getState().showImportDSLModal).toBe(true) + }) + }) + + it('should render preview action in chat mode', async () => { + mockUseIsChatMode.mockReturnValue(true) + + renderWorkflowFlowComponent(, { + initialStoreState: { + panelMenu: { clientX: 24, clientY: 48 }, + }, + hooksStoreProps: {}, + }) + + expect(await screen.findByText('common.debugAndPreview')).toBeInTheDocument() + expect(screen.queryByText('common.run')).not.toBeInTheDocument() + + fireEvent.click(screen.getByText('common.debugAndPreview')) + + await waitFor(() => { + expect(mockHandleWorkflowStartRunInChatflow).toHaveBeenCalledTimes(1) + expect(mockHandleStartWorkflowRun).not.toHaveBeenCalled() + expect(mockHandlePaneContextmenuCancel).toHaveBeenCalled() + }) }) }) diff --git a/web/app/components/workflow/hooks/__tests__/use-panel-interactions.spec.ts b/web/app/components/workflow/hooks/__tests__/use-panel-interactions.spec.ts index 6b35c511c1..8452087b7c 100644 --- a/web/app/components/workflow/hooks/__tests__/use-panel-interactions.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-panel-interactions.spec.ts @@ -36,7 +36,7 @@ describe('usePanelInteractions', () => { container.remove() }) - it('handlePaneContextMenu should set panelMenu with computed coordinates when container exists', () => { + it('handlePaneContextMenu should set panelMenu with viewport coordinates', () => { const { result, store } = renderWorkflowHook(() => usePanelInteractions(), { initialStoreState: { nodeMenu: { clientX: 40, clientY: 20, nodeId: 'n1' }, @@ -54,28 +54,14 @@ describe('usePanelInteractions', () => { expect(preventDefault).toHaveBeenCalled() expect(store.getState().panelMenu).toEqual({ - top: 200, - left: 250, + clientX: 350, + clientY: 250, }) expect(store.getState().nodeMenu).toBeUndefined() expect(store.getState().selectionMenu).toBeUndefined() expect(store.getState().edgeMenu).toBeUndefined() }) - it('handlePaneContextMenu should throw when container does not exist', () => { - container.remove() - - const { result } = renderWorkflowHook(() => usePanelInteractions()) - - expect(() => { - result.current.handlePaneContextMenu({ - preventDefault: vi.fn(), - clientX: 350, - clientY: 250, - } as unknown as React.MouseEvent) - }).toThrow() - }) - it('handlePaneContextMenu should sync clipboard from navigator clipboard', async () => { const clipboardNode = createNode({ id: 'clipboard-node' }) const clipboardEdge = createEdge({ @@ -106,7 +92,7 @@ describe('usePanelInteractions', () => { it('handlePaneContextmenuCancel should clear panelMenu', () => { const { result, store } = renderWorkflowHook(() => usePanelInteractions(), { - initialStoreState: { panelMenu: { top: 10, left: 20 } }, + initialStoreState: { panelMenu: { clientX: 20, clientY: 10 } }, }) result.current.handlePaneContextmenuCancel() diff --git a/web/app/components/workflow/hooks/__tests__/use-selection-interactions.spec.ts b/web/app/components/workflow/hooks/__tests__/use-selection-interactions.spec.ts index bb35abf743..894c40c4f6 100644 --- a/web/app/components/workflow/hooks/__tests__/use-selection-interactions.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-selection-interactions.spec.ts @@ -174,7 +174,7 @@ describe('useSelectionInteractions', () => { it('handleSelectionContextMenu should set menu only when clicking on selection rect', () => { const { result, store } = renderSelectionInteractions({ nodeMenu: { clientX: 20, clientY: 10, nodeId: 'n1' }, - panelMenu: { top: 30, left: 40 }, + panelMenu: { clientX: 40, clientY: 30 }, edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' }, }) diff --git a/web/app/components/workflow/hooks/use-panel-interactions.ts b/web/app/components/workflow/hooks/use-panel-interactions.ts index 3b7a2158a1..687a80613d 100644 --- a/web/app/components/workflow/hooks/use-panel-interactions.ts +++ b/web/app/components/workflow/hooks/use-panel-interactions.ts @@ -22,15 +22,13 @@ export const usePanelInteractions = () => { workflowStore.getState().setClipboardData({ nodes, edges }) }) - const container = document.querySelector('#workflow-container') - const { x, y } = container!.getBoundingClientRect() workflowStore.setState({ nodeMenu: undefined, selectionMenu: undefined, edgeMenu: undefined, panelMenu: { - top: e.clientY - y, - left: e.clientX - x, + clientX: e.clientX, + clientY: e.clientY, }, }) }, [workflowStore, appDslVersion]) diff --git a/web/app/components/workflow/panel-contextmenu.tsx b/web/app/components/workflow/panel-contextmenu.tsx index 5bac2e2364..3c2207adab 100644 --- a/web/app/components/workflow/panel-contextmenu.tsx +++ b/web/app/components/workflow/panel-contextmenu.tsx @@ -1,13 +1,20 @@ import { cn } from '@langgenius/dify-ui/cn' -import { useClickAway } from 'ahooks' +import { + ContextMenu, + ContextMenuContent, + ContextMenuGroup, + ContextMenuItem, + ContextMenuSeparator, +} from '@langgenius/dify-ui/context-menu' import { memo, - useRef, + useCallback, + useMemo, } from 'react' import { useTranslation } from 'react-i18next' -import Divider from '../base/divider' import { useDSL, + useIsChatMode, useNodesInteractions, usePanelInteractions, useWorkflowMoveMode, @@ -20,7 +27,6 @@ import { useStore } from './store' const PanelContextmenu = () => { const { t } = useTranslation() - const ref = useRef(null) const panelMenu = useStore(s => s.panelMenu) const clipboardElements = useStore(s => s.clipboardElements) const setShowImportDSLModal = useStore(s => s.setShowImportDSLModal) @@ -29,127 +35,147 @@ const PanelContextmenu = () => { const setCommentQuickAdd = useStore(s => s.setCommentQuickAdd) const { handleNodesPaste } = useNodesInteractions() const { handlePaneContextmenuCancel } = usePanelInteractions() - const { handleStartWorkflowRun } = useWorkflowStartRun() + const { + handleStartWorkflowRun, + handleWorkflowStartRunInChatflow, + } = useWorkflowStartRun() const { handleAddNote } = useOperator() const { isCommentModeAvailable } = useWorkflowMoveMode() const { exportCheck } = useDSL() + const isChatMode = useIsChatMode() + const panelMenuClientX = panelMenu?.clientX + const panelMenuClientY = panelMenu?.clientY - useClickAway(() => { - handlePaneContextmenuCancel() - }, ref) + const anchor = useMemo(() => { + if (panelMenuClientX === undefined || panelMenuClientY === undefined) + return null - const renderTrigger = () => { + return { + getBoundingClientRect: () => DOMRect.fromRect({ + width: 0, + height: 0, + x: panelMenuClientX, + y: panelMenuClientY, + }), + } + }, [panelMenuClientX, panelMenuClientY]) + + const renderAddBlockTrigger = useCallback(() => { return ( ) - } + }, [t]) - if (!panelMenu) + const handleRunAction = useCallback(() => { + if (isChatMode) + handleWorkflowStartRunInChatflow() + else + handleStartWorkflowRun() + + handlePaneContextmenuCancel() + }, [isChatMode, handleWorkflowStartRunInChatflow, handleStartWorkflowRun, handlePaneContextmenuCancel]) + + if (!panelMenu || !anchor) return null return ( -
!open && handlePaneContextmenuCancel()} > -
- - - {isCommentModeAvailable && ( - - )} - -
- -
- -
- -
- - -
-
+ + {isChatMode ? t('common.debugAndPreview', { ns: 'workflow' }) : t('common.run', { ns: 'workflow' })} + {!isChatMode && } + + + + + { + if (clipboardElements.length) { + handleNodesPaste() + handlePaneContextmenuCancel() + } + }} + > + {t('common.pasteHere', { ns: 'workflow' })} + + + + + + exportCheck?.()} + > + {t('export', { ns: 'app' })} + + setShowImportDSLModal(true)} + > + {t('importApp', { ns: 'app' })} + + + + ) } diff --git a/web/app/components/workflow/panel/version-history-panel/__tests__/index.spec.tsx b/web/app/components/workflow/panel/version-history-panel/__tests__/index.spec.tsx index 3d73d5a61c..3c9f7dba0e 100644 --- a/web/app/components/workflow/panel/version-history-panel/__tests__/index.spec.tsx +++ b/web/app/components/workflow/panel/version-history-panel/__tests__/index.spec.tsx @@ -49,7 +49,7 @@ type MockRestoreConfirmModalProps = { type MockVersionHistoryItemProps = { item: VersionHistory onClick: (item: VersionHistory) => void - handleClickMenuItem: (operation: VersionHistoryContextMenuOptions) => void + handleClickActionMenuItem: (operation: VersionHistoryContextMenuOptions) => void } vi.mock('@/context/app-context', () => ({ @@ -148,7 +148,7 @@ vi.mock('@/app/components/app/app-publisher/version-info-modal', () => ({ vi.mock('../version-history-item', () => ({ default: (props: MockVersionHistoryItemProps) => { const MockVersionHistoryItem = () => { - const { item, onClick, handleClickMenuItem } = props + const { item, onClick, handleClickActionMenuItem } = props useEffect(() => { if (item.version === WorkflowVersion.Draft) @@ -159,7 +159,7 @@ vi.mock('../version-history-item', () => ({
{item.version !== WorkflowVersion.Draft && ( - )} diff --git a/web/app/components/workflow/panel/version-history-panel/__tests__/version-history-item.spec.tsx b/web/app/components/workflow/panel/version-history-panel/__tests__/version-history-item.spec.tsx index 2518b06c5c..545becd1cf 100644 --- a/web/app/components/workflow/panel/version-history-panel/__tests__/version-history-item.spec.tsx +++ b/web/app/components/workflow/panel/version-history-panel/__tests__/version-history-item.spec.tsx @@ -60,7 +60,7 @@ describe('VersionHistoryItem', () => { currentVersion={null} latestVersionId="latest-version" onClick={onClick} - handleClickMenuItem={vi.fn()} + handleClickActionMenuItem={vi.fn()} isLast={false} />, ) @@ -81,7 +81,7 @@ describe('VersionHistoryItem', () => { describe('Published Items', () => { it('should open the context menu for a latest named version and forward restore', async () => { const user = userEvent.setup() - const handleClickMenuItem = vi.fn() + const handleClickActionMenuItem = vi.fn() const onClick = vi.fn() render( @@ -90,7 +90,7 @@ describe('VersionHistoryItem', () => { currentVersion={null} latestVersionId="version-1" onClick={onClick} - handleClickMenuItem={handleClickMenuItem} + handleClickActionMenuItem={handleClickActionMenuItem} isLast={false} />, ) @@ -120,8 +120,8 @@ describe('VersionHistoryItem', () => { fireEvent.click(restoreItem) - expect(handleClickMenuItem).toHaveBeenCalledTimes(1) - expect(handleClickMenuItem).toHaveBeenCalledWith( + expect(handleClickActionMenuItem).toHaveBeenCalledTimes(1) + expect(handleClickActionMenuItem).toHaveBeenCalledWith( VersionHistoryContextMenuOptions.restore, VersionHistoryContextMenuOptions.restore, ) @@ -138,7 +138,7 @@ describe('VersionHistoryItem', () => { currentVersion={item} latestVersionId="other-version" onClick={onClick} - handleClickMenuItem={vi.fn()} + handleClickActionMenuItem={vi.fn()} isLast />, ) diff --git a/web/app/components/workflow/panel/version-history-panel/context-menu/__tests__/menu-item.spec.tsx b/web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/action-menu-item.spec.tsx similarity index 89% rename from web/app/components/workflow/panel/version-history-panel/context-menu/__tests__/menu-item.spec.tsx rename to web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/action-menu-item.spec.tsx index 7dfc362a90..8001f74840 100644 --- a/web/app/components/workflow/panel/version-history-panel/context-menu/__tests__/menu-item.spec.tsx +++ b/web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/action-menu-item.spec.tsx @@ -2,9 +2,9 @@ import { DropdownMenu, DropdownMenuContent } from '@langgenius/dify-ui/dropdown- import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { VersionHistoryContextMenuOptions } from '../../../../types' -import MenuItem from '../menu-item' +import ActionMenuItem from '../action-menu-item' -describe('MenuItem', () => { +describe('ActionMenuItem', () => { it('forwards the selected operation and supports destructive styling', async () => { const user = userEvent.setup() const onClick = vi.fn() @@ -12,7 +12,7 @@ describe('MenuItem', () => { render( - { +describe('ActionMenu', () => { it('toggles the trigger and forwards menu clicks', async () => { const user = userEvent.setup() const setOpen = vi.fn() - const handleClickMenuItem = vi.fn() + const handleClickActionMenuItem = vi.fn() renderWorkflowComponent( - , ) @@ -25,11 +25,11 @@ describe('ContextMenu', () => { await user.click(screen.getByText('common.operation.delete')) expect(setOpen).toHaveBeenCalled() - expect(handleClickMenuItem).toHaveBeenCalledWith( + expect(handleClickActionMenuItem).toHaveBeenCalledWith( VersionHistoryContextMenuOptions.restore, VersionHistoryContextMenuOptions.restore, ) - expect(handleClickMenuItem).toHaveBeenCalledWith( + expect(handleClickActionMenuItem).toHaveBeenCalledWith( VersionHistoryContextMenuOptions.delete, VersionHistoryContextMenuOptions.delete, ) diff --git a/web/app/components/workflow/panel/version-history-panel/context-menu/__tests__/use-context-menu.spec.tsx b/web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/use-action-menu.spec.tsx similarity index 83% rename from web/app/components/workflow/panel/version-history-panel/context-menu/__tests__/use-context-menu.spec.tsx rename to web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/use-action-menu.spec.tsx index 084016d74e..e08bc787d7 100644 --- a/web/app/components/workflow/panel/version-history-panel/context-menu/__tests__/use-context-menu.spec.tsx +++ b/web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/use-action-menu.spec.tsx @@ -1,15 +1,15 @@ import { renderWorkflowHook } from '../../../../__tests__/workflow-test-env' import { VersionHistoryContextMenuOptions } from '../../../../types' -import useContextMenu from '../use-context-menu' +import useActionMenu from '../use-action-menu' -describe('useContextMenu', () => { +describe('useActionMenu', () => { it('returns restore, edit, export, copy and delete operations for app workflows', () => { - const { result } = renderWorkflowHook(() => useContextMenu({ + const { result } = renderWorkflowHook(() => useActionMenu({ isNamedVersion: true, isShowDelete: false, open: false, setOpen: vi.fn(), - handleClickMenuItem: vi.fn(), + handleClickActionMenuItem: vi.fn(), })) expect(result.current.deleteOperation).toEqual({ @@ -25,12 +25,12 @@ describe('useContextMenu', () => { }) it('omits export for pipelines and renames the edit action for unnamed versions', () => { - const { result } = renderWorkflowHook(() => useContextMenu({ + const { result } = renderWorkflowHook(() => useActionMenu({ isNamedVersion: false, isShowDelete: true, open: false, setOpen: vi.fn(), - handleClickMenuItem: vi.fn(), + handleClickActionMenuItem: vi.fn(), }), { initialStoreState: { pipelineId: 'pipeline-1', diff --git a/web/app/components/workflow/panel/version-history-panel/context-menu/menu-item.tsx b/web/app/components/workflow/panel/version-history-panel/action-menu/action-menu-item.tsx similarity index 89% rename from web/app/components/workflow/panel/version-history-panel/context-menu/menu-item.tsx rename to web/app/components/workflow/panel/version-history-panel/action-menu/action-menu-item.tsx index 0c0096ab25..7d24e81217 100644 --- a/web/app/components/workflow/panel/version-history-panel/context-menu/menu-item.tsx +++ b/web/app/components/workflow/panel/version-history-panel/action-menu/action-menu-item.tsx @@ -4,7 +4,7 @@ import { cn } from '@langgenius/dify-ui/cn' import { DropdownMenuItem } from '@langgenius/dify-ui/dropdown-menu' import * as React from 'react' -type MenuItemProps = { +type ActionMenuItemProps = { item: { key: VersionHistoryContextMenuOptions name: string @@ -13,7 +13,7 @@ type MenuItemProps = { isDestructive?: boolean } -const MenuItem: FC = ({ +const ActionMenuItem: FC = ({ item, onClick, isDestructive = false, @@ -41,4 +41,4 @@ const MenuItem: FC = ({ ) } -export default React.memo(MenuItem) +export default React.memo(ActionMenuItem) diff --git a/web/app/components/workflow/panel/version-history-panel/context-menu/index.tsx b/web/app/components/workflow/panel/version-history-panel/action-menu/index.tsx similarity index 68% rename from web/app/components/workflow/panel/version-history-panel/context-menu/index.tsx rename to web/app/components/workflow/panel/version-history-panel/action-menu/index.tsx index 1b90166f65..8299ff2b30 100644 --- a/web/app/components/workflow/panel/version-history-panel/context-menu/index.tsx +++ b/web/app/components/workflow/panel/version-history-panel/action-menu/index.tsx @@ -9,23 +9,23 @@ import { import { RiMoreFill } from '@remixicon/react' import * as React from 'react' import { VersionHistoryContextMenuOptions } from '../../../types' -import MenuItem from './menu-item' -import useContextMenu from './use-context-menu' +import ActionMenuItem from './action-menu-item' +import useActionMenu from './use-action-menu' -export type ContextMenuProps = { +export type ActionMenuProps = { isShowDelete: boolean isNamedVersion: boolean open: boolean setOpen: React.Dispatch> - handleClickMenuItem: (operation: VersionHistoryContextMenuOptions) => void + handleClickActionMenuItem: (operation: VersionHistoryContextMenuOptions) => void } -const ContextMenu: FC = (props: ContextMenuProps) => { - const { isShowDelete, handleClickMenuItem, open, setOpen } = props +const ActionMenu: FC = (props: ActionMenuProps) => { + const { isShowDelete, handleClickActionMenuItem, open, setOpen } = props const { deleteOperation, options, - } = useContextMenu(props) + } = useActionMenu(props) return ( = (props: ContextMenuProps) => { > { options.map(option => ( - )) } @@ -55,10 +55,10 @@ const ContextMenu: FC = (props: ContextMenuProps) => { isShowDelete && ( <> - ) @@ -68,4 +68,4 @@ const ContextMenu: FC = (props: ContextMenuProps) => { ) } -export default React.memo(ContextMenu) +export default React.memo(ActionMenu) diff --git a/web/app/components/workflow/panel/version-history-panel/context-menu/use-context-menu.ts b/web/app/components/workflow/panel/version-history-panel/action-menu/use-action-menu.ts similarity index 89% rename from web/app/components/workflow/panel/version-history-panel/context-menu/use-context-menu.ts rename to web/app/components/workflow/panel/version-history-panel/action-menu/use-action-menu.ts index 92d6ee6869..4a81809aeb 100644 --- a/web/app/components/workflow/panel/version-history-panel/context-menu/use-context-menu.ts +++ b/web/app/components/workflow/panel/version-history-panel/action-menu/use-action-menu.ts @@ -1,10 +1,10 @@ -import type { ContextMenuProps } from './index' +import type { ActionMenuProps } from './index' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useStore } from '@/app/components/workflow/store' import { VersionHistoryContextMenuOptions } from '../../../types' -const useContextMenu = (props: ContextMenuProps) => { +const useActionMenu = (props: ActionMenuProps) => { const { isNamedVersion, } = props @@ -43,7 +43,7 @@ const useContextMenu = (props: ContextMenuProps) => { name: t('versionHistory.copyId', { ns: 'workflow' }), }, ] - }, [isNamedVersion, t]) + }, [isNamedVersion, pipelineId, t]) return { deleteOperation, @@ -51,4 +51,4 @@ const useContextMenu = (props: ContextMenuProps) => { } } -export default useContextMenu +export default useActionMenu diff --git a/web/app/components/workflow/panel/version-history-panel/index.tsx b/web/app/components/workflow/panel/version-history-panel/index.tsx index eb1f5c962e..851490fc5a 100644 --- a/web/app/components/workflow/panel/version-history-panel/index.tsx +++ b/web/app/components/workflow/panel/version-history-panel/index.tsx @@ -107,7 +107,7 @@ export const VersionHistoryPanel = ({ setIsOnlyShowNamedVersions(false) }, []) - const handleClickMenuItem = useCallback((item: VersionHistory, operation: VersionHistoryContextMenuOptions) => { + const handleClickActionMenuItem = useCallback((item: VersionHistory, operation: VersionHistoryContextMenuOptions) => { setOperatedItem(item) switch (operation) { case VersionHistoryContextMenuOptions.restore: @@ -292,7 +292,7 @@ export const VersionHistoryPanel = ({ currentVersion={currentVersion} latestVersionId={latestVersionId || ''} onClick={handleVersionClick} - handleClickMenuItem={handleClickMenuItem.bind(null, item)} + handleClickActionMenuItem={handleClickActionMenuItem.bind(null, item)} isLast={isLast} /> ) diff --git a/web/app/components/workflow/panel/version-history-panel/version-history-item.tsx b/web/app/components/workflow/panel/version-history-panel/version-history-item.tsx index bd0a967c49..0984b2654f 100644 --- a/web/app/components/workflow/panel/version-history-panel/version-history-item.tsx +++ b/web/app/components/workflow/panel/version-history-panel/version-history-item.tsx @@ -6,14 +6,14 @@ import * as React from 'react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { WorkflowVersion } from '../../types' -import ContextMenu from './context-menu' +import ActionMenu from './action-menu' type VersionHistoryItemProps = { item: VersionHistory currentVersion: VersionHistory | null latestVersionId: string onClick: (item: VersionHistory) => void - handleClickMenuItem: (operation: VersionHistoryContextMenuOptions) => void + handleClickActionMenuItem: (operation: VersionHistoryContextMenuOptions) => void isLast: boolean } @@ -41,7 +41,7 @@ const VersionHistoryItem: React.FC = ({ currentVersion, latestVersionId, onClick, - handleClickMenuItem, + handleClickActionMenuItem, isLast, }) => { const { t } = useTranslation() @@ -122,15 +122,15 @@ const VersionHistoryItem: React.FC = ({ ) }
- {/* Context Menu */} + {/* Action Menu */} {!isDraft && isHovering && (
-
)} diff --git a/web/app/components/workflow/store/__tests__/workflow-store.spec.ts b/web/app/components/workflow/store/__tests__/workflow-store.spec.ts index 47819c049f..4b5dc0b302 100644 --- a/web/app/components/workflow/store/__tests__/workflow-store.spec.ts +++ b/web/app/components/workflow/store/__tests__/workflow-store.spec.ts @@ -108,7 +108,7 @@ describe('createWorkflowStore', () => { ['showWorkflowVersionHistoryPanel', 'setShowWorkflowVersionHistoryPanel', true], ['showInputsPanel', 'setShowInputsPanel', true], ['showDebugAndPreviewPanel', 'setShowDebugAndPreviewPanel', true], - ['panelMenu', 'setPanelMenu', { top: 10, left: 20 }], + ['panelMenu', 'setPanelMenu', { clientX: 20, clientY: 10 }], ['selectionMenu', 'setSelectionMenu', { clientX: 50, clientY: 60 }], ['edgeMenu', 'setEdgeMenu', { clientX: 320, clientY: 180, edgeId: 'e1' }], ['showVariableInspectPanel', 'setShowVariableInspectPanel', true], diff --git a/web/app/components/workflow/store/workflow/__tests__/panel-slice.spec.ts b/web/app/components/workflow/store/workflow/__tests__/panel-slice.spec.ts index 0c7f55850f..1f30a2b7cf 100644 --- a/web/app/components/workflow/store/workflow/__tests__/panel-slice.spec.ts +++ b/web/app/components/workflow/store/workflow/__tests__/panel-slice.spec.ts @@ -19,12 +19,12 @@ describe('createPanelSlice', () => { store.getState().setShowFeaturesPanel(true) store.getState().setShowDebugAndPreviewPanel(true) - store.getState().setPanelMenu({ top: 24, left: 48 }) + store.getState().setPanelMenu({ clientX: 48, clientY: 24 }) store.getState().setEdgeMenu({ clientX: 80, clientY: 120, edgeId: 'edge-1' }) expect(store.getState().showFeaturesPanel).toBe(true) expect(store.getState().showDebugAndPreviewPanel).toBe(true) - expect(store.getState().panelMenu).toEqual({ top: 24, left: 48 }) + expect(store.getState().panelMenu).toEqual({ clientX: 48, clientY: 24 }) expect(store.getState().edgeMenu).toEqual({ clientX: 80, clientY: 120, edgeId: 'edge-1' }) }) }) diff --git a/web/app/components/workflow/store/workflow/panel-slice.ts b/web/app/components/workflow/store/workflow/panel-slice.ts index e84d33a1eb..2f4264fc78 100644 --- a/web/app/components/workflow/store/workflow/panel-slice.ts +++ b/web/app/components/workflow/store/workflow/panel-slice.ts @@ -17,8 +17,8 @@ export type PanelSliceShape = { showUserCursors: boolean setShowUserCursors: (showUserCursors: boolean) => void panelMenu?: { - top: number - left: number + clientX: number + clientY: number } setPanelMenu: (panelMenu: PanelSliceShape['panelMenu']) => void selectionMenu?: { From 1f29565673b51ee0ea1301b5dc68ede53e300069 Mon Sep 17 00:00:00 2001 From: kien duong Date: Tue, 5 May 2026 13:42:18 +0700 Subject: [PATCH 17/21] fix(rag): use doc_id dedup key for any provider, not only dify (#35759) Co-authored-by: Asuka Minato Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/core/rag/datasource/retrieval_service.py | 16 +-- .../rag/retrieval/test_dataset_retrieval.py | 97 ++++++++++++++++++- 2 files changed, 101 insertions(+), 12 deletions(-) diff --git a/api/core/rag/datasource/retrieval_service.py b/api/core/rag/datasource/retrieval_service.py index c60d19045a..b985ebbe1d 100644 --- a/api/core/rag/datasource/retrieval_service.py +++ b/api/core/rag/datasource/retrieval_service.py @@ -217,10 +217,11 @@ class RetrievalService: """Deduplicate documents in O(n) while preserving first-seen order. Rules: - - For provider == "dify" and metadata["doc_id"] exists: keep the doc with the highest - metadata["score"] among duplicates; if a later duplicate has no score, ignore it. - - For non-dify documents (or dify without doc_id): deduplicate by content key - (provider, page_content), keeping the first occurrence. + - If metadata["doc_id"] exists (any provider): deduplicate by (provider, doc_id) key; + keep the doc with the highest metadata["score"] among duplicates. If a later duplicate + has no score, ignore it. + - If metadata["doc_id"] is absent: deduplicate by content key (provider, page_content), + keeping the first occurrence. """ if not documents: return documents @@ -231,11 +232,10 @@ class RetrievalService: order: list[tuple] = [] for doc in documents: - is_dify = doc.provider == "dify" - doc_id = (doc.metadata or {}).get("doc_id") if is_dify else None + doc_id = (doc.metadata or {}).get("doc_id") - if is_dify and doc_id: - key = ("dify", doc_id) + if doc_id: + key = (doc.provider or "dify", doc_id) if key not in chosen: chosen[key] = doc order.append(key) diff --git a/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval.py b/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval.py index fd607210f1..b556ddf528 100644 --- a/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval.py +++ b/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval.py @@ -1106,11 +1106,11 @@ class TestRetrievalService: def test_deduplicate_documents_non_dify_provider(self): """ - Test deduplication with non-dify provider documents. + Test deduplication with non-dify provider documents that have no doc_id. Verifies: - - External provider documents use content-based deduplication - - Different providers are handled correctly + - External provider documents without doc_id use content-based deduplication + - Identical content from the same provider is collapsed to one result """ # Arrange doc1 = Document( @@ -1131,7 +1131,96 @@ class TestRetrievalService: # Assert # External documents without doc_id should use content-based dedup - assert len(result) >= 1 + assert len(result) == 1 + + def test_deduplicate_documents_non_dify_provider_with_doc_id_different_sources(self): + """ + Regression test for issue #35707. + + Two chunks from different source documents share identical text content but carry + different doc_ids. Before the fix, non-dify providers were forced into content-based + deduplication and the second chunk was silently dropped. After the fix, doc_id is used + as the dedup key for any provider that exposes it, so both chunks must be retained. + + Verifies: + - Non-dify provider documents with different doc_ids are NOT deduplicated even when + their page_content is identical. + """ + # Arrange — same content, different doc_ids, non-dify provider (e.g. Weaviate / Qdrant) + doc_a = Document( + page_content="Shared identical content", + metadata={"doc_id": "doc-from-file-a", "score": 0.85}, + provider="weaviate", + ) + doc_b = Document( + page_content="Shared identical content", + metadata={"doc_id": "doc-from-file-b", "score": 0.82}, + provider="weaviate", + ) + + # Act + result = RetrievalService._deduplicate_documents([doc_a, doc_b]) + + # Assert — both documents must be kept; losing either silently drops a source citation + assert len(result) == 2 + doc_ids = {doc.metadata["doc_id"] for doc in result} + assert doc_ids == {"doc-from-file-a", "doc-from-file-b"} + + def test_deduplicate_documents_non_dify_provider_with_same_doc_id(self): + """ + Test that non-dify provider documents sharing the same doc_id are deduplicated by + doc_id key (not by content), and the higher-scored duplicate is retained. + + Verifies: + - doc_id-based deduplication now applies to any provider, not only "dify" + - The document with the highest score wins when doc_ids collide + """ + # Arrange + doc_low = Document( + page_content="Content A", + metadata={"doc_id": "chunk-1", "score": 0.5}, + provider="qdrant", + ) + doc_high = Document( + page_content="Content A", + metadata={"doc_id": "chunk-1", "score": 0.9}, + provider="qdrant", + ) + + # Act + result = RetrievalService._deduplicate_documents([doc_low, doc_high]) + + # Assert + assert len(result) == 1 + assert result[0].metadata["score"] == 0.9 + + def test_deduplicate_documents_dify_provider_without_doc_id_falls_back_to_content(self): + """ + Test that a dify provider document without doc_id still falls back to content-based + deduplication (no regression from original behaviour). + + Verifies: + - Absence of doc_id triggers content-based dedup regardless of provider + - First occurrence is kept when content is identical + """ + # Arrange — dify docs with no doc_id, same content + doc1 = Document( + page_content="Same content", + metadata={"score": 0.8}, + provider="dify", + ) + doc2 = Document( + page_content="Same content", + metadata={"score": 0.9}, + provider="dify", + ) + + # Act + result = RetrievalService._deduplicate_documents([doc1, doc2]) + + # Assert — collapsed to one; first-seen wins (no score comparison in content branch) + assert len(result) == 1 + assert result[0].metadata["score"] == 0.8 # ==================== Metadata Filtering Tests ==================== From 8e2b8168be162aee6f2b7efc17e7624443384098 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Tue, 5 May 2026 16:50:49 +0800 Subject: [PATCH 18/21] refactor(web): migrate HITL overlays to base dialog (#35792) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- eslint-suppressions.json | 56 --- .../billing/billing-integration.test.tsx | 2 +- .../components/base/upgrade-modal/index.tsx | 75 ++++ .../upgrade-modal}/style.module.css | 0 .../__tests__/index.spec.tsx | 22 +- .../billing/plan-upgrade-modal/index.tsx | 85 ++--- .../__tests__/index.spec.tsx | 44 +-- .../trigger-events-limit-modal/index.tsx | 12 +- .../create/step-one/__tests__/index.spec.tsx | 14 +- .../__tests__/preview-panel.spec.tsx | 15 +- .../step-one/components/preview-panel.tsx | 2 +- .../__tests__/index.spec.tsx | 12 - .../documents/create-from-pipeline/index.tsx | 2 +- .../segment-add/__tests__/index.spec.tsx | 23 +- .../documents/detail/segment-add/index.tsx | 2 +- .../human-input/__tests__/panel.spec.tsx | 5 - .../__tests__/email-configure-modal.spec.tsx | 17 +- .../delivery-method/__tests__/index.spec.tsx | 24 +- .../__tests__/method-item.spec.tsx | 24 +- .../__tests__/method-selector.spec.tsx | 2 +- .../__tests__/test-email-sender.spec.tsx | 298 +++++++++++++++ .../__tests__/upgrade-modal.spec.tsx | 8 +- .../delivery-method/email-configure-modal.tsx | 168 ++++----- .../components/delivery-method/index.tsx | 32 +- .../delivery-method/method-item.tsx | 130 +++---- .../delivery-method/method-selector.tsx | 54 ++- .../delivery-method/test-email-sender.tsx | 349 +++++++++--------- .../delivery-method/upgrade-modal.tsx | 106 +++--- .../workflow/nodes/human-input/panel.tsx | 14 +- web/context/modal-context-provider.tsx | 6 +- web/context/modal-context.test.tsx | 78 ++-- web/context/modal-context.ts | 4 +- 32 files changed, 923 insertions(+), 762 deletions(-) create mode 100644 web/app/components/base/upgrade-modal/index.tsx rename web/app/components/{billing/plan-upgrade-modal => base/upgrade-modal}/style.module.css (100%) create mode 100644 web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/test-email-sender.spec.tsx diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 586590413f..bbb5cd5af9 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1969,11 +1969,6 @@ "count": 4 } }, - "web/app/components/billing/plan-upgrade-modal/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/billing/plan/assets/index.tsx": { "no-barrel-files/no-barrel-files": { "count": 4 @@ -4195,37 +4190,6 @@ "count": 5 } }, - "web/app/components/workflow/nodes/human-input/components/delivery-method/email-configure-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/workflow/nodes/human-input/components/delivery-method/method-item.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/workflow/nodes/human-input/components/delivery-method/method-selector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 2 - }, - "ts/no-non-null-asserted-optional-chain": { - "count": 1 - } - }, - "web/app/components/workflow/nodes/human-input/components/delivery-method/upgrade-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/human-input/components/form-content-preview.tsx": { "react/unsupported-syntax": { "count": 1 @@ -4250,11 +4214,6 @@ "count": 2 } }, - "web/app/components/workflow/nodes/human-input/panel.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/human-input/types.ts": { "erasable-syntax-only/enums": { "count": 2 @@ -5284,21 +5243,6 @@ "count": 3 } }, - "web/context/modal-context-provider.tsx": { - "ts/no-explicit-any": { - "count": 3 - } - }, - "web/context/modal-context.test.tsx": { - "ts/no-explicit-any": { - "count": 5 - } - }, - "web/context/modal-context.ts": { - "ts/no-explicit-any": { - "count": 2 - } - }, "web/context/provider-context-provider.tsx": { "ts/no-explicit-any": { "count": 1 diff --git a/web/__tests__/billing/billing-integration.test.tsx b/web/__tests__/billing/billing-integration.test.tsx index 90589ae1e4..3113e36751 100644 --- a/web/__tests__/billing/billing-integration.test.tsx +++ b/web/__tests__/billing/billing-integration.test.tsx @@ -9,7 +9,7 @@ import Billing from '@/app/components/billing/billing-page' import { defaultPlan, NUM_INFINITE } from '@/app/components/billing/config' import HeaderBillingBtn from '@/app/components/billing/header-billing-btn' import PlanComp from '@/app/components/billing/plan' -import PlanUpgradeModal from '@/app/components/billing/plan-upgrade-modal' +import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal' import PriorityLabel from '@/app/components/billing/priority-label' import TriggerEventsLimitModal from '@/app/components/billing/trigger-events-limit-modal' import { Plan } from '@/app/components/billing/type' diff --git a/web/app/components/base/upgrade-modal/index.tsx b/web/app/components/base/upgrade-modal/index.tsx new file mode 100644 index 0000000000..cfae72eaf7 --- /dev/null +++ b/web/app/components/base/upgrade-modal/index.tsx @@ -0,0 +1,75 @@ +'use client' + +import type { ComponentType, ReactNode } from 'react' +import { cn } from '@langgenius/dify-ui/cn' +import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog' +import styles from './style.module.css' + +type UpgradeModalClassNames = { + content?: string + heroOverlay?: string + body?: string + icon?: string + copy?: string + title?: string + description?: string + footer?: string +} + +type UpgradeModalProps = { + open: boolean + onOpenChange?: (open: boolean) => void + Icon?: ComponentType<{ className?: string }> + title: ReactNode + description: ReactNode + extraInfo?: ReactNode + footer: ReactNode + classNames?: UpgradeModalClassNames +} + +export function UpgradeModal({ + open, + onOpenChange, + Icon, + title, + description, + extraInfo, + footer, + classNames, +}: UpgradeModalProps) { + return ( + + +
+
+
+ {Icon && ( +
+ +
+ )} +
+ + {title} + + + {description} + +
+ {extraInfo} +
+
+ +
+ {footer} +
+ +
+ ) +} diff --git a/web/app/components/billing/plan-upgrade-modal/style.module.css b/web/app/components/base/upgrade-modal/style.module.css similarity index 100% rename from web/app/components/billing/plan-upgrade-modal/style.module.css rename to web/app/components/base/upgrade-modal/style.module.css diff --git a/web/app/components/billing/plan-upgrade-modal/__tests__/index.spec.tsx b/web/app/components/billing/plan-upgrade-modal/__tests__/index.spec.tsx index b28ffffa53..2e8b7777ee 100644 --- a/web/app/components/billing/plan-upgrade-modal/__tests__/index.spec.tsx +++ b/web/app/components/billing/plan-upgrade-modal/__tests__/index.spec.tsx @@ -1,19 +1,9 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import * as React from 'react' -import PlanUpgradeModal from '../index' +import { PlanUpgradeModal } from '../index' const mockSetShowPricingModal = vi.fn() -vi.mock('@/app/components/base/modal', () => { - const MockModal = ({ isShow, children }: { isShow: boolean, children: React.ReactNode }) => ( - isShow ?
{children}
: null - ) - return { - default: MockModal, - } -}) - vi.mock('@/context/modal-context', () => ({ useModalContext: () => ({ setShowPricingModal: mockSetShowPricingModal, @@ -70,6 +60,16 @@ describe('PlanUpgradeModal', () => { expect(onClose).toHaveBeenCalledTimes(1) }) + it('should call onClose when dialog requests close', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + renderComponent({ onClose }) + + await user.keyboard('{Escape}') + + expect(onClose).toHaveBeenCalledTimes(1) + }) + // Upgrade path uses provided callback over pricing modal it('should call onUpgrade and onClose when upgrade button is clicked with onUpgrade provided', async () => { const user = userEvent.setup() diff --git a/web/app/components/billing/plan-upgrade-modal/index.tsx b/web/app/components/billing/plan-upgrade-modal/index.tsx index da599dc36c..0d40edcfad 100644 --- a/web/app/components/billing/plan-upgrade-modal/index.tsx +++ b/web/app/components/billing/plan-upgrade-modal/index.tsx @@ -1,26 +1,24 @@ 'use client' -import type { FC } from 'react' +import type { ComponentType, ReactNode } from 'react' import { Button } from '@langgenius/dify-ui/button' -import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' -import Modal from '@/app/components/base/modal' +import { UpgradeModal } from '@/app/components/base/upgrade-modal' import UpgradeBtn from '@/app/components/billing/upgrade-btn' import { useModalContext } from '@/context/modal-context' import { SquareChecklist } from '../../base/icons/src/vender/other' -import styles from './style.module.css' type Props = { - Icon?: React.ComponentType> + Icon?: ComponentType<{ className?: string }> title: string description: string - extraInfo?: React.ReactNode + extraInfo?: ReactNode show: boolean onClose: () => void onUpgrade?: () => void } -const PlanUpgradeModal: FC = ({ +export function PlanUpgradeModal({ Icon = SquareChecklist, title, description, @@ -28,7 +26,7 @@ const PlanUpgradeModal: FC = ({ show, onClose, onUpgrade, -}) => { +}: Props) { const { t } = useTranslation() const { setShowPricingModal } = useModalContext() @@ -41,51 +39,30 @@ const PlanUpgradeModal: FC = ({ }, [onClose, onUpgrade, setShowPricingModal]) return ( - -
-
-
-
- -
-
-
- {title} -
-
- {description} -
-
- {extraInfo} -
-
- -
- - -
- + !open && onClose()} + Icon={Icon} + title={title} + description={description} + extraInfo={extraInfo} + footer={( + <> + + + + )} + /> ) } - -export default React.memo(PlanUpgradeModal) diff --git a/web/app/components/billing/trigger-events-limit-modal/__tests__/index.spec.tsx b/web/app/components/billing/trigger-events-limit-modal/__tests__/index.spec.tsx index 47577a5b48..a8dfcd63e6 100644 --- a/web/app/components/billing/trigger-events-limit-modal/__tests__/index.spec.tsx +++ b/web/app/components/billing/trigger-events-limit-modal/__tests__/index.spec.tsx @@ -4,21 +4,6 @@ import TriggerEventsLimitModal from '../index' const mockOnClose = vi.fn() const mockOnUpgrade = vi.fn() -const planUpgradeModalMock = vi.fn((props: { show: boolean, title: string, description: string, extraInfo?: React.ReactNode, onClose: () => void, onUpgrade: () => void }) => ( -
- {props.extraInfo} -
-)) - -vi.mock('@/app/components/billing/plan-upgrade-modal', () => ({ - default: (props: { show: boolean, title: string, description: string, extraInfo?: React.ReactNode, onClose: () => void, onUpgrade: () => void }) => planUpgradeModalMock(props), -})) - describe('TriggerEventsLimitModal', () => { beforeEach(() => { vi.clearAllMocks() @@ -36,16 +21,9 @@ describe('TriggerEventsLimitModal', () => { />, ) - const modal = screen.getByTestId('plan-upgrade-modal') - expect(modal.getAttribute('data-show')).toBe('true') - expect(modal.getAttribute('data-title')).toContain('billing.triggerLimitModal.title') - expect(modal.getAttribute('data-description')).toContain('billing.triggerLimitModal.description') - expect(planUpgradeModalMock).toHaveBeenCalled() - - const passedProps = planUpgradeModalMock.mock.calls[0]![0] - expect(passedProps.onClose).toBe(mockOnClose) - expect(passedProps.onUpgrade).toBe(mockOnUpgrade) - + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(screen.getByText('billing.triggerLimitModal.title')).toBeInTheDocument() + expect(screen.getByText('billing.triggerLimitModal.description')).toBeInTheDocument() expect(screen.getByText('billing.triggerLimitModal.usageTitle'))!.toBeInTheDocument() expect(screen.getByText('12'))!.toBeInTheDocument() expect(screen.getByText('20'))!.toBeInTheDocument() @@ -62,8 +40,7 @@ describe('TriggerEventsLimitModal', () => { />, ) - expect(planUpgradeModalMock).toHaveBeenCalled() - expect(screen.getByTestId('plan-upgrade-modal').getAttribute('data-show')).toBe('false') + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() }) it('renders reset info when resetInDays is provided', () => { @@ -94,9 +71,8 @@ describe('TriggerEventsLimitModal', () => { />, ) - const modal = screen.getByTestId('plan-upgrade-modal') - expect(modal.getAttribute('data-title')).toBe('billing.triggerLimitModal.title') - expect(modal.getAttribute('data-description')).toBe('billing.triggerLimitModal.description') + expect(screen.getByText('billing.triggerLimitModal.title')).toBeInTheDocument() + expect(screen.getByText('billing.triggerLimitModal.description')).toBeInTheDocument() }) it('passes onClose and onUpgrade callbacks to PlanUpgradeModal', () => { @@ -110,8 +86,10 @@ describe('TriggerEventsLimitModal', () => { />, ) - const passedProps = planUpgradeModalMock.mock.calls[0]![0] - expect(passedProps.onClose).toBe(mockOnClose) - expect(passedProps.onUpgrade).toBe(mockOnUpgrade) + screen.getByText('billing.triggerLimitModal.dismiss').click() + expect(mockOnClose).toHaveBeenCalledTimes(1) + + screen.getByText('billing.triggerLimitModal.upgrade').click() + expect(mockOnUpgrade).toHaveBeenCalledTimes(1) }) }) diff --git a/web/app/components/billing/trigger-events-limit-modal/index.tsx b/web/app/components/billing/trigger-events-limit-modal/index.tsx index 5debb5cb57..9312f772b5 100644 --- a/web/app/components/billing/trigger-events-limit-modal/index.tsx +++ b/web/app/components/billing/trigger-events-limit-modal/index.tsx @@ -1,9 +1,7 @@ 'use client' -import type { FC } from 'react' -import * as React from 'react' import { useTranslation } from 'react-i18next' import { TriggerAll } from '@/app/components/base/icons/src/vender/workflow' -import PlanUpgradeModal from '@/app/components/billing/plan-upgrade-modal' +import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal' import UsageInfo from '@/app/components/billing/usage-info' type Props = { @@ -15,14 +13,14 @@ type Props = { resetInDays?: number } -const TriggerEventsLimitModal: FC = ({ +export default function TriggerEventsLimitModal({ show, onClose, onUpgrade, usage, total, resetInDays, -}) => { +}: Props) { const { t } = useTranslation() return ( @@ -30,7 +28,7 @@ const TriggerEventsLimitModal: FC = ({ show={show} onClose={onClose} onUpgrade={onUpgrade} - Icon={TriggerAll as React.ComponentType>} + Icon={TriggerAll} title={t('triggerLimitModal.title', { ns: 'billing' })} description={t('triggerLimitModal.description', { ns: 'billing' })} extraInfo={( @@ -47,5 +45,3 @@ const TriggerEventsLimitModal: FC = ({ /> ) } - -export default React.memo(TriggerEventsLimitModal) diff --git a/web/app/components/datasets/create/step-one/__tests__/index.spec.tsx b/web/app/components/datasets/create/step-one/__tests__/index.spec.tsx index f00ff121cc..6c6c60d808 100644 --- a/web/app/components/datasets/create/step-one/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/step-one/__tests__/index.spec.tsx @@ -92,18 +92,6 @@ vi.mock('@/app/components/billing/vector-space-full', () => ({ default: () =>
Vector Space Full
, })) -vi.mock('@/app/components/billing/plan-upgrade-modal', () => ({ - default: ({ show, onClose }: { show: boolean, onClose: () => void }) => ( - show - ? ( -
- -
- ) - : null - ), -})) - vi.mock('../../file-preview', () => ({ default: ({ file, hidePreview }: { file: File, hidePreview: () => void }) => (
@@ -388,7 +376,7 @@ describe('StepOne', () => { fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) - expect(screen.getByTestId('plan-upgrade-modal')).toBeInTheDocument() + expect(screen.getByRole('dialog')).toBeInTheDocument() }) it('should show upgrade card when in sandbox plan with files', () => { diff --git a/web/app/components/datasets/create/step-one/components/__tests__/preview-panel.spec.tsx b/web/app/components/datasets/create/step-one/components/__tests__/preview-panel.spec.tsx index f495dd9f3f..a807412008 100644 --- a/web/app/components/datasets/create/step-one/components/__tests__/preview-panel.spec.tsx +++ b/web/app/components/datasets/create/step-one/components/__tests__/preview-panel.spec.tsx @@ -31,17 +31,6 @@ vi.mock('../../../website/preview', () => ({ ), })) -vi.mock('@/app/components/billing/plan-upgrade-modal', () => ({ - default: ({ show, onClose, title }: { show: boolean, onClose: () => void, title: string }) => show - ? ( -
- {title} - -
- ) - : null, -})) - const { default: PreviewPanel } = await import('../preview-panel') describe('PreviewPanel', () => { @@ -87,7 +76,7 @@ describe('PreviewPanel', () => { it('should render plan upgrade modal when isShowPlanUpgradeModal is true', () => { render() - expect(screen.getByTestId('plan-upgrade-modal')).toBeInTheDocument() + expect(screen.getByRole('dialog')).toBeInTheDocument() }) }) @@ -100,7 +89,7 @@ describe('PreviewPanel', () => { it('should call hidePlanUpgradeModal when modal close clicked', () => { render() - fireEvent.click(screen.getByTestId('close-modal')) + fireEvent.click(screen.getByRole('button', { name: 'billing.triggerLimitModal.dismiss' })) expect(defaultProps.hidePlanUpgradeModal).toHaveBeenCalledOnce() }) diff --git a/web/app/components/datasets/create/step-one/components/preview-panel.tsx b/web/app/components/datasets/create/step-one/components/preview-panel.tsx index 8ae0b7df55..51b6568154 100644 --- a/web/app/components/datasets/create/step-one/components/preview-panel.tsx +++ b/web/app/components/datasets/create/step-one/components/preview-panel.tsx @@ -3,7 +3,7 @@ import type { NotionPage } from '@/models/common' import type { CrawlResultItem } from '@/models/datasets' import { useTranslation } from 'react-i18next' -import PlanUpgradeModal from '@/app/components/billing/plan-upgrade-modal' +import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal' import FilePreview from '../../file-preview' import NotionPagePreview from '../../notion-page-preview' import WebsitePreview from '../../website/preview' diff --git a/web/app/components/datasets/documents/create-from-pipeline/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/__tests__/index.spec.tsx index 7daff43a8b..18241f2139 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/__tests__/index.spec.tsx @@ -112,18 +112,6 @@ vi.mock('@/app/components/billing/vector-space-full', () => ({ default: () =>
Vector Space Full
, })) -vi.mock('@/app/components/billing/plan-upgrade-modal', () => ({ - default: ({ show, onClose }: { show: boolean, onClose: () => void }) => ( - show - ? ( -
- -
- ) - : null - ), -})) - vi.mock('@/app/components/datasets/create/step-one/upgrade-card', () => ({ default: () =>
Upgrade Card
, })) diff --git a/web/app/components/datasets/documents/create-from-pipeline/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/index.tsx index 8b8fad5885..799f24fa2a 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/index.tsx @@ -8,7 +8,7 @@ import { useBoolean } from 'ahooks' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' -import PlanUpgradeModal from '@/app/components/billing/plan-upgrade-modal' +import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { useProviderContextSelector } from '@/context/provider-context' import { DatasourceType } from '@/models/pipeline' diff --git a/web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx index 35b62915da..b9d967a692 100644 --- a/web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx @@ -14,21 +14,6 @@ vi.mock('@/context/provider-context', () => ({ }), })) -// Mock PlanUpgradeModal -vi.mock('@/app/components/billing/plan-upgrade-modal', () => ({ - default: ({ show, onClose, title, description }: { show: boolean, onClose: () => void, title?: string, description?: string }) => ( - show - ? ( -
- {title} - {description} - -
- ) - : null - ), -})) - describe('SegmentAdd', () => { beforeEach(() => { vi.clearAllMocks() @@ -189,7 +174,7 @@ describe('SegmentAdd', () => { fireEvent.click(screen.getByText(/list\.action\.addButton/i)) - expect(screen.getByTestId('plan-upgrade-modal')).toBeInTheDocument() + expect(screen.getByRole('dialog')).toBeInTheDocument() }) it('should not call showNewSegmentModal for sandbox users', () => { @@ -219,11 +204,11 @@ describe('SegmentAdd', () => { // Show modal fireEvent.click(screen.getByText(/list\.action\.addButton/i)) - expect(screen.getByTestId('plan-upgrade-modal')).toBeInTheDocument() + expect(screen.getByRole('dialog')).toBeInTheDocument() - fireEvent.click(screen.getByTestId('close-modal')) + fireEvent.click(screen.getByRole('button', { name: 'billing.triggerLimitModal.dismiss' })) - expect(screen.queryByTestId('plan-upgrade-modal')).not.toBeInTheDocument() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/documents/detail/segment-add/index.tsx b/web/app/components/datasets/documents/detail/segment-add/index.tsx index de9735f62f..5ee0a2bcb3 100644 --- a/web/app/components/datasets/documents/detail/segment-add/index.tsx +++ b/web/app/components/datasets/documents/detail/segment-add/index.tsx @@ -11,7 +11,7 @@ import { useBoolean } from 'ahooks' import * as React from 'react' import { useCallback, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import PlanUpgradeModal from '@/app/components/billing/plan-upgrade-modal' +import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal' import { Plan } from '@/app/components/billing/type' import { useProviderContext } from '@/context/provider-context' diff --git a/web/app/components/workflow/nodes/human-input/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/human-input/__tests__/panel.spec.tsx index 143b05afae..6ff61dce3f 100644 --- a/web/app/components/workflow/nodes/human-input/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/__tests__/panel.spec.tsx @@ -30,11 +30,6 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ }, })) -vi.mock('@/app/components/base/tooltip', () => ({ - __esModule: true, - default: () =>
tooltip
, -})) - vi.mock('@/app/components/base/action-button', () => ({ __esModule: true, default: (props: { diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/email-configure-modal.spec.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/email-configure-modal.spec.tsx index c5b5c680dc..d9875c7539 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/email-configure-modal.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/email-configure-modal.spec.tsx @@ -92,9 +92,9 @@ describe('human-input/delivery-method/email-configure-modal', () => { render( , ) @@ -127,8 +127,8 @@ describe('human-input/delivery-method/email-configure-modal', () => { render( , ) @@ -162,12 +162,12 @@ describe('human-input/delivery-method/email-configure-modal', () => { }) it('should close from both the icon trigger and the cancel button', () => { - const handleClose = vi.fn() + const handleOpenChange = vi.fn() render( , ) @@ -175,6 +175,7 @@ describe('human-input/delivery-method/email-configure-modal', () => { fireEvent.click(screen.getByRole('dialog').querySelector('.absolute') as HTMLDivElement) fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) - expect(handleClose).toHaveBeenCalledTimes(2) + expect(handleOpenChange).toHaveBeenCalledTimes(2) + expect(handleOpenChange).toHaveBeenCalledWith(false) }) }) diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/index.spec.tsx index 03bc0f2b79..087440c62d 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/index.spec.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { DeliveryMethodType } from '../../../types' import DeliveryMethodForm from '../index' @@ -9,11 +9,6 @@ vi.mock('react-i18next', () => ({ useTranslation: () => mockUseTranslation(), })) -vi.mock('@/app/components/base/tooltip', () => ({ - __esModule: true, - default: ({ popupContent }: { popupContent: string }) =>
{popupContent}
, -})) - vi.mock('@/app/components/workflow/hooks', () => ({ useNodesSyncDraft: () => mockUseNodesSyncDraft(), })) @@ -62,15 +57,6 @@ vi.mock('../method-item', () => ({ ), })) -vi.mock('../upgrade-modal', () => ({ - __esModule: true, - default: ({ onClose }: { onClose: () => void }) => ( - - ), -})) - describe('DeliveryMethodForm', () => { const onChange = vi.fn() const mockHandleSyncWorkflowDraft = vi.fn() @@ -132,7 +118,7 @@ describe('DeliveryMethodForm', () => { expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true, true) }) - it('should open and close the upgrade modal', () => { + it('should open and close the upgrade modal', async () => { render( { ) fireEvent.click(screen.getByText('show-upgrade')) - expect(screen.getByText('upgrade-modal')).toBeInTheDocument() + expect(screen.getByRole('dialog')).toBeInTheDocument() - fireEvent.click(screen.getByText('upgrade-modal')) - expect(screen.queryByText('upgrade-modal')).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'nodes.humanInput.deliveryMethod.upgradeTipHide' })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) }) }) diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/method-item.spec.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/method-item.spec.tsx index 5f11552c70..8ad330e3f2 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/method-item.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/method-item.spec.tsx @@ -6,15 +6,15 @@ import { DeliveryMethodType } from '../../../types' import DeliveryMethodItem from '../method-item' type EmailConfigureModalProps = { - isShow: boolean + open: boolean config?: EmailConfig - onClose: () => void + onOpenChange: (open: boolean) => void onConfirm: (data: EmailConfig) => void } type TestEmailSenderProps = { - isShow: boolean - onClose: () => void + open: boolean + onOpenChange: (open: boolean) => void jumpToEmailConfigModal: () => void } @@ -30,7 +30,7 @@ vi.mock('@/context/app-context', () => ({ vi.mock('../email-configure-modal', () => ({ default: (props: EmailConfigureModalProps) => { mockEmailConfigureModal(props) - return props.isShow + return props.open ? (
- +
) : null @@ -54,11 +54,11 @@ vi.mock('../email-configure-modal', () => ({ vi.mock('../test-email-sender', () => ({ default: (props: TestEmailSenderProps) => { mockTestEmailSender(props) - return props.isShow + return props.open ? (
- +
) : null @@ -140,14 +140,14 @@ describe('human-input/delivery-method/method-item', () => { const row = getMethodRow('webapp') const actionButtons = within(row).getAllByRole('button') - const deleteButtonWrapper = actionButtons[0]!.parentElement as HTMLDivElement + const deleteButton = actionButtons[0]! - fireEvent.mouseEnter(deleteButtonWrapper) + fireEvent.mouseEnter(deleteButton) expect(row)!.toHaveClass('border-state-destructive-border') - fireEvent.mouseLeave(deleteButtonWrapper) + fireEvent.mouseLeave(deleteButton) expect(row).not.toHaveClass('border-state-destructive-border') - fireEvent.click(actionButtons[0]!) + fireEvent.click(deleteButton) expect(handleDelete).toHaveBeenCalledWith(DeliveryMethodType.WebApp) }) diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/method-selector.spec.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/method-selector.spec.tsx index e1008d7457..9cbcd1c189 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/method-selector.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/method-selector.spec.tsx @@ -67,7 +67,7 @@ describe('human-input/delivery-method/method-selector', () => { }) expect(handleShowUpgradeTip).not.toHaveBeenCalled() expect(screen.getByText('workflow.nodes.humanInput.deliveryMethod.contactTip1')).toBeInTheDocument() - expect(screen.getByRole('tooltip')).toHaveTextContent('nodes.humanInput.deliveryMethod.contactTip2') + expect(screen.getByText('nodes.humanInput.deliveryMethod.contactTip2')).toBeInTheDocument() }) it('should disable webapp in trigger mode and show added states without creating duplicates', () => { diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/test-email-sender.spec.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/test-email-sender.spec.tsx new file mode 100644 index 0000000000..7c62b20b8a --- /dev/null +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/test-email-sender.spec.tsx @@ -0,0 +1,298 @@ +import type { ReactNode } from 'react' +import type { EmailConfig, FormInputItem } from '../../../types' +import type { App, AppSSO } from '@/types/app' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useStore as useAppStore } from '@/app/components/app/store' +import { HooksStoreContext } from '@/app/components/workflow/hooks-store/provider' +import { createHooksStore } from '@/app/components/workflow/hooks-store/store' +import { BlockEnum, InputVarType, VarType } from '@/app/components/workflow/types' +import { AppContext, initialLangGeniusVersionInfo, initialWorkspaceInfo, userProfilePlaceholder } from '@/context/app-context' +import EmailSenderModal from '../test-email-sender' + +type RecordedRequest = { + url: string + method: string + body?: unknown +} + +const createQueryClient = () => new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + mutations: { + retry: false, + }, + }, +}) + +const renderWithProviders = (ui: ReactNode) => { + const queryClient = createQueryClient() + const hooksStore = createHooksStore({}) + + return render( + + selector({ + userProfile: { + ...userProfilePlaceholder, + id: 'user-1', + email: 'owner@example.com', + name: 'Owner', + }, + currentWorkspace: { + ...initialWorkspaceInfo, + id: 'workspace-1', + name: 'Product Team', + }, + isCurrentWorkspaceManager: true, + isCurrentWorkspaceOwner: true, + isCurrentWorkspaceEditor: true, + isCurrentWorkspaceDatasetOperator: true, + mutateUserProfile: vi.fn(), + mutateCurrentWorkspace: vi.fn(), + langGeniusVersionInfo: initialLangGeniusVersionInfo, + useSelector: vi.fn(), + isLoadingCurrentWorkspace: false, + isValidatingCurrentWorkspace: false, + }), + isLoadingCurrentWorkspace: false, + isValidatingCurrentWorkspace: false, + }} + > + + {ui} + + + , + ) +} + +const setupFetch = () => { + const requests: RecordedRequest[] = [] + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (resource: RequestInfo | URL, options?: RequestInit) => { + const request = resource instanceof Request ? resource : new Request(resource, options) + const body = request.method === 'GET' ? undefined : await request.clone().json() + requests.push({ + url: request.url, + method: request.method, + body, + }) + + if (request.url.includes('/workspaces/current/members')) { + return new Response(JSON.stringify({ + accounts: [ + { + id: 'member-1', + email: 'member@example.com', + name: 'Member One', + avatar: '', + avatar_url: '', + status: 'active', + role: 'normal', + created_at: '', + last_active_at: '', + last_login_at: '', + }, + ], + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + + return new Response(JSON.stringify({ result: 'success' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + }) + + return { + fetchSpy, + requests, + } +} + +const createConfig = (overrides: Partial = {}): EmailConfig => ({ + recipients: { + whole_workspace: true, + items: [], + }, + subject: 'Review request', + body: 'Please review {{#start.score#}}', + debug_mode: false, + ...overrides, +}) + +const createFormInput = (overrides: Partial = {}): FormInputItem => ({ + type: InputVarType.textInput, + output_variable_name: 'user_name', + default: { + type: 'variable', + selector: ['start', 'user_name'], + value: '', + }, + ...overrides, +}) + +describe('human-input/delivery-method/test-email-sender', () => { + beforeEach(() => { + vi.clearAllMocks() + useAppStore.setState({ + appDetail: { + id: 'app-1', + name: 'Workflow App', + } as App & Partial, + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should submit generated variable inputs and show the success state', async () => { + const user = userEvent.setup() + const { requests } = setupFetch() + const handleOpenChange = vi.fn() + + renderWithProviders( + , + ) + + const sendButton = screen.getByRole('button', { name: 'workflow.nodes.humanInput.deliveryMethod.emailSender.send' }) + expect(sendButton).toBeDisabled() + + await user.type(screen.getByPlaceholderText('user_name'), 'Ada') + await user.type(screen.getByPlaceholderText('score'), '42') + expect(sendButton).toBeEnabled() + + await user.click(sendButton) + + await waitFor(() => expect(screen.getByText('workflow.nodes.humanInput.deliveryMethod.emailSender.done')).toBeInTheDocument()) + expect(requests).toContainEqual(expect.objectContaining({ + url: 'http://localhost:5001/console/api/apps/app-1/workflows/draft/human-input/nodes/human-node/delivery-test', + method: 'POST', + body: { + delivery_method_id: 'delivery-1', + inputs: { + '#start.user_name#': 'Ada', + '#start.score#': '42', + }, + }, + })) + + await user.click(screen.getByRole('button', { name: 'common.operation.ok' })) + + expect(handleOpenChange).toHaveBeenCalledWith(false) + }) + + it('should render fallback variable inputs and allow cancelling', async () => { + const user = userEvent.setup() + setupFetch() + const handleOpenChange = vi.fn() + + renderWithProviders( + , + ) + + expect(screen.getByPlaceholderText('message')).toBeInTheDocument() + + await user.click(screen.getByText('workflow.nodes.humanInput.deliveryMethod.emailSender.vars')) + + expect(screen.queryByPlaceholderText('message')).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'common.operation.cancel' })) + + expect(handleOpenChange).toHaveBeenCalledWith(false) + }) + + it('should show selected recipients with the email configuration tip', () => { + setupFetch() + + renderWithProviders( + , + ) + + expect(screen.getByText('external@example.com')).toBeInTheDocument() + expect(screen.getByText('nodes.humanInput.deliveryMethod.emailSender.tip')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/upgrade-modal.spec.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/upgrade-modal.spec.tsx index 537fa351e1..0abae16f9a 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/upgrade-modal.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/upgrade-modal.spec.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react' -import UpgradeModal from '../upgrade-modal' +import { UpgradeModal } from '../upgrade-modal' const mockUseModalContextSelector = vi.hoisted(() => vi.fn()) @@ -32,8 +32,8 @@ describe('human-input/delivery-method/upgrade-modal', () => { render( , ) @@ -41,7 +41,7 @@ describe('human-input/delivery-method/upgrade-modal', () => { expect(screen.getByText('workflow.nodes.humanInput.deliveryMethod.upgradeTipContent')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'workflow.nodes.humanInput.deliveryMethod.upgradeTipHide' })) - expect(handleClose).toHaveBeenCalledTimes(1) + expect(handleClose).toHaveBeenCalledWith(false) fireEvent.click(screen.getByRole('button', { name: /billing.upgradeBtn.encourageShort/i })) expect(handleShowPricingModal).toHaveBeenCalledTimes(1) diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/email-configure-modal.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/email-configure-modal.tsx index de38564d95..046320ab37 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/email-configure-modal.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/email-configure-modal.tsx @@ -4,15 +4,13 @@ import type { NodeOutPutVar, } from '@/app/components/workflow/types' import { Button } from '@langgenius/dify-ui/button' +import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { Switch } from '@langgenius/dify-ui/switch' import { toast } from '@langgenius/dify-ui/toast' -import { RiBugLine, RiCloseLine } from '@remixicon/react' -import { noop } from 'es-toolkit/compat' +import { RiBugLine } from '@remixicon/react' import { memo, useCallback, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' -import Divider from '@/app/components/base/divider' import Input from '@/app/components/base/input' -import Modal from '@/app/components/base/modal' import { useSelector as useAppContextWithSelector } from '@/context/app-context' import MailBodyInput from './mail-body-input' import Recipient from './recipient' @@ -20,8 +18,8 @@ import Recipient from './recipient' const i18nPrefix = 'nodes.humanInput' type EmailConfigureModalProps = { - isShow: boolean - onClose: () => void + open: boolean + onOpenChange: (open: boolean) => void onConfirm: (data: EmailConfig) => void config?: EmailConfig nodesOutputVars?: NodeOutPutVar[] @@ -29,8 +27,8 @@ type EmailConfigureModalProps = { } const EmailConfigureModal = ({ - isShow, - onClose, + open, + onOpenChange, onConfirm, config, nodesOutputVars = [], @@ -78,89 +76,87 @@ const EmailConfigureModal = ({ }, [checkValidConfig, onConfirm, recipients, subject, body, debugMode]) return ( - -
- -
-
-
{t(`${i18nPrefix}.deliveryMethod.emailConfigure.title`, { ns: 'workflow' })}
-
{t(`${i18nPrefix}.deliveryMethod.emailConfigure.description`, { ns: 'workflow' })}
-
-
-
-
- {t(`${i18nPrefix}.deliveryMethod.emailConfigure.subject`, { ns: 'workflow' })} -
- setSubject(e.target.value)} - placeholder={t(`${i18nPrefix}.deliveryMethod.emailConfigure.subjectPlaceholder`, { ns: 'workflow' })} - /> + + +
+ {t(`${i18nPrefix}.deliveryMethod.emailConfigure.title`, { ns: 'workflow' })} +
{t(`${i18nPrefix}.deliveryMethod.emailConfigure.description`, { ns: 'workflow' })}
-
-
- {t(`${i18nPrefix}.deliveryMethod.emailConfigure.body`, { ns: 'workflow' })} -
- -
-
-
- {t(`${i18nPrefix}.deliveryMethod.emailConfigure.recipient`, { ns: 'workflow' })} -
- -
- -
-
- -
-
-
{t(`${i18nPrefix}.deliveryMethod.emailConfigure.debugMode`, { ns: 'workflow' })}
-
- {email} }} - values={{ email }} - /> -
{t(`${i18nPrefix}.deliveryMethod.emailConfigure.debugModeTip2`, { ns: 'workflow' })}
+
+
+
+ {t(`${i18nPrefix}.deliveryMethod.emailConfigure.subject`, { ns: 'workflow' })}
+ setSubject(e.target.value)} + placeholder={t(`${i18nPrefix}.deliveryMethod.emailConfigure.subjectPlaceholder`, { ns: 'workflow' })} + /> +
+
+
+ {t(`${i18nPrefix}.deliveryMethod.emailConfigure.body`, { ns: 'workflow' })} +
+ +
+
+
+ {t(`${i18nPrefix}.deliveryMethod.emailConfigure.recipient`, { ns: 'workflow' })} +
+ +
+
+
+ +
+
+
{t(`${i18nPrefix}.deliveryMethod.emailConfigure.debugMode`, { ns: 'workflow' })}
+
+ {email} }} + values={{ email }} + /> +
{t(`${i18nPrefix}.deliveryMethod.emailConfigure.debugModeTip2`, { ns: 'workflow' })}
+
+
+ setDebugMode(checked)} + />
- setDebugMode(checked)} - />
-
-
- - -
- +
+ + +
+ + ) } diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/index.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/index.tsx index cdfda74aeb..50c2bf333a 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/index.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/index.tsx @@ -3,14 +3,14 @@ import type { Node, NodeOutPutVar, } from '@/app/components/workflow/types' -import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { produce } from 'immer' import * as React from 'react' import { useTranslation } from 'react-i18next' +import { Infotip } from '@/app/components/base/infotip' import { useNodesSyncDraft } from '@/app/components/workflow/hooks' import MethodItem from './method-item' import MethodSelector from './method-selector' -import UpgradeModal from './upgrade-modal' +import { UpgradeModal } from './upgrade-modal' const i18nPrefix = 'nodes.humanInput' @@ -62,27 +62,15 @@ const DeliveryMethodForm: React.FC = ({ const handleShowUpgradeModal = () => { setShowUpgradeModal(true) } - const handleCloseUpgradeModal = () => { - setShowUpgradeModal(false) - } return (
{t(`${i18nPrefix}.deliveryMethod.title`, { ns: 'workflow' })}
- - - - - )} - /> - - {t(`${i18nPrefix}.deliveryMethod.tooltip`, { ns: 'workflow' })} - - + + {t(`${i18nPrefix}.deliveryMethod.tooltip`, { ns: 'workflow' })} +
{!readonly && (
@@ -115,12 +103,10 @@ const DeliveryMethodForm: React.FC = ({ ))}
)} - {showUpgradeModal && ( - - )} +
) } diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/method-item.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/method-item.tsx index 9cd63e96dd..8abace95f8 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/method-item.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/method-item.tsx @@ -7,6 +7,7 @@ import type { import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { Switch } from '@langgenius/dify-ui/switch' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { RiDeleteBinLine, RiEqualizer2Line, @@ -18,7 +19,6 @@ import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton, { ActionButtonState } from '@/app/components/base/action-button' import Badge from '@/app/components/base/badge/index' -import Tooltip from '@/app/components/base/tooltip' import Indicator from '@/app/components/header/indicator' import { useSelector as useAppContextWithSelector } from '@/context/app-context' import { DeliveryMethodType } from '../../types' @@ -79,6 +79,8 @@ const DeliveryMethodItem: FC = ({ } return t(`${i18nPrefix}.deliveryMethod.emailSender.testSendTip`, { ns: 'workflow' }) }, [method.type, method.config?.debug_mode, t, email]) + const configureLabel = t('common.configure', { ns: 'workflow' }) + const removeLabel = t('operation.remove', { ns: 'common' }) const jumpToEmailConfigModal = useCallback(() => { setShowTestEmailModal(false) @@ -114,47 +116,49 @@ const DeliveryMethodItem: FC = ({
{method.type === DeliveryMethodType.Email && method.config && ( <> - - { - setShowTestEmailModal(true) - }} - > - - + + setShowTestEmailModal(true)} + > + + + )} + /> + {emailSenderTooltipContent} - - setShowEmailModal(true)}> - - - + + setShowEmailModal(true)} + > + + + )} + /> + {configureLabel} )} - -
setIsHovering(true)} - onMouseLeave={() => setIsHovering(false)} - > - onDelete(method.type)} - > - - -
+ + setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + onClick={() => onDelete(method.type)} + > + + + )} + /> + {removeLabel}
)} @@ -178,33 +182,29 @@ const DeliveryMethodItem: FC = ({ )}
- {showEmailModal && ( - setShowEmailModal(false)} - onConfirm={(data) => { - handleConfigChange(data) - setShowEmailModal(false) - }} - /> - )} - {showTestEmailModal && ( - setShowTestEmailModal(false)} - jumpToEmailConfigModal={jumpToEmailConfigModal} - /> - )} + { + handleConfigChange(data) + setShowEmailModal(false) + }} + /> + ) } diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/method-selector.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/method-selector.tsx index 16c2345549..9ef75fd639 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/method-selector.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/method-selector.tsx @@ -2,6 +2,11 @@ import type { FC } from 'react' import type { DeliveryMethod } from '../../types' import { cn } from '@langgenius/dify-ui/cn' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' import { RiAddLine, RiDiscordFill, @@ -9,17 +14,12 @@ import { RiMailSendFill, RiRobot2Fill, } from '@remixicon/react' -import { memo, useCallback, useMemo, useRef, useState } from 'react' +import { memo, useMemo, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' import { v4 as uuid4 } from 'uuid' import ActionButton from '@/app/components/base/action-button' import Badge from '@/app/components/base/badge' import { Slack, Teams } from '@/app/components/base/icons/src/public/other' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import useWorkflowNodes from '@/app/components/workflow/store/workflow/use-nodes' import { isTriggerWorkflow } from '@/app/components/workflow/utils/workflow-entry' import { IS_CE_EDITION } from '@/config' @@ -40,20 +40,10 @@ const MethodSelector: FC = ({ onShowUpgradeTip, }) => { const { t } = useTranslation() - const [open, doSetOpen] = useState(false) + const [open, setOpen] = useState(false) const humanInputEmailDeliveryEnabled = useProviderContextSelector(s => s.humanInputEmailDeliveryEnabled) - const openRef = useRef(open) const nodes = useWorkflowNodes() - const setOpen = useCallback((v: boolean) => { - doSetOpen(v) - openRef.current = v - }, [doSetOpen]) - - const handleTrigger = useCallback(() => { - setOpen(!openRef.current) - }, [setOpen]) - const webAppDeliveryInfo = useMemo(() => { const isTriggerMode = isTriggerWorkflow(nodes) return { @@ -71,23 +61,25 @@ const MethodSelector: FC = ({ }, [data, humanInputEmailDeliveryEnabled]) return ( - - -
- + -
-
- + )} + /> +
= ({
)} - - + + ) } export default memo(MethodSelector) diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx index 9ad52fa13e..b88ec6cef6 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx @@ -3,16 +3,17 @@ import type { Node, NodeOutPutVar, ValueSelector, + Var, } from '@/app/components/workflow/types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { RiArrowRightSFill, RiCloseLine } from '@remixicon/react' +import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' +import { RiArrowRightSFill } from '@remixicon/react' import { noop, unionBy } from 'es-toolkit/compat' import { memo, useCallback, useMemo, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' import { useStore as useAppStore } from '@/app/components/app/store' import Divider from '@/app/components/base/divider' -import Modal from '@/app/components/base/modal' import { getInputVars as doGetInputVars } from '@/app/components/base/prompt-editor/constants' import FormItem from '@/app/components/workflow/nodes/_base/components/before-run-form/form-item' import { @@ -30,11 +31,11 @@ import EmailInput from './recipient/email-input' const i18nPrefix = 'nodes.humanInput' -type EmailConfigureModalProps = { +type EmailSenderModalProps = { nodeId: string deliveryId: string - isShow: boolean - onClose: () => void + open: boolean + onOpenChange: (open: boolean) => void jumpToEmailConfigModal: () => void config?: EmailConfig formContent?: string @@ -48,18 +49,22 @@ const getOriginVar = (valueSelector: string[], list: NodeOutPutVar[]) => { if (!targetVar) return undefined - let curr: any = targetVar.vars + let curr: Var[] | undefined = targetVar.vars for (let i = 1; i < valueSelector.length; i++) { const key = valueSelector[i] const isLast = i === valueSelector.length - 1 + const currentVar: Var | undefined = curr?.find(v => v.variable.replace('conversation.', '') === key) - if (Array.isArray(curr)) - curr = curr.find((v: any) => v.variable.replace('conversation.', '') === key) + if (!currentVar) + return undefined if (isLast) - return curr - else if (curr?.type === VarType.object || curr?.type === VarType.file) - curr = curr.children + return currentVar + + if ((currentVar.type === VarType.object || currentVar.type === VarType.file) && Array.isArray(currentVar.children)) + curr = currentVar.children + else + return undefined } return undefined @@ -68,15 +73,15 @@ const getOriginVar = (valueSelector: string[], list: NodeOutPutVar[]) => { const EmailSenderModal = ({ nodeId, deliveryId, - isShow, - onClose, + open, + onOpenChange, jumpToEmailConfigModal, config, formContent, formInputs, nodesOutputVars = [], availableNodes = [], -}: EmailConfigureModalProps) => { +}: EmailSenderModalProps) => { const { t } = useTranslation() const { userProfile, currentWorkspace } = useAppContext() const appDetail = useAppStore(state => state.appDetail) @@ -104,7 +109,7 @@ const EmailSenderModal = ({ return { label: { nodeType: varInfo?.type, - nodeName: varInfo?.title || availableNodes[0]?.data.title!, // default start node title + nodeName: varInfo?.title || availableNodes[0]?.data.title || '', variable: isSystemVar(item) ? item.join('.') : item[item.length - 1]!, isChatVar: isConversationVar(item), }, @@ -178,194 +183,194 @@ const EmailSenderModal = ({ if (done) { return ( - -
-
{t(`${i18nPrefix}.deliveryMethod.emailSender.done`, { ns: 'workflow' })}
- {debugEnabled && ( -
- }} - values={{ email: userProfile.email }} + +
+ {t(`${i18nPrefix}.deliveryMethod.emailSender.done`, { ns: 'workflow' })} + {debugEnabled && ( +
+ }} + values={{ email: userProfile.email }} + /> +
+ )} + {!debugEnabled && onlyWholeTeam && ( +
+ }} + values={{ team: currentWorkspace.name.replace(/'/g, '’') }} + /> +
+ )} + {!debugEnabled && onlySpecificUsers && ( +
{t(`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamDone3`, { ns: 'workflow' })}
+ )} + {!debugEnabled && combinedRecipients && ( +
+ }} + values={{ team: currentWorkspace.name.replace(/'/g, '’') }} + /> +
+ )} +
+ {(onlySpecificUsers || combinedRecipients) && !debugEnabled && ( +
+
)} +
+ +
+
+ + ) + } + + return ( + + + +
+ {t(`${i18nPrefix}.deliveryMethod.emailSender.title`, { ns: 'workflow' })} + {debugEnabled && ( + <> +
{t(`${i18nPrefix}.deliveryMethod.emailSender.debugModeTip`, { ns: 'workflow' })}
+
+ }} + values={{ email: userProfile.email }} + /> +
+ + )} {!debugEnabled && onlyWholeTeam && ( -
+
}} + components={{ team: }} values={{ team: currentWorkspace.name.replace(/'/g, '’') }} />
)} {!debugEnabled && onlySpecificUsers && ( -
{t(`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamDone3`, { ns: 'workflow' })}
+
{t(`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamTip3`, { ns: 'workflow' })}
)} {!debugEnabled && combinedRecipients && ( -
+
}} + components={{ team: }} values={{ team: currentWorkspace.name.replace(/'/g, '’') }} />
)}
{(onlySpecificUsers || combinedRecipients) && !debugEnabled && ( -
- -
- )} -
- -
- - ) - } - - return ( - -
- -
-
-
{t(`${i18nPrefix}.deliveryMethod.emailSender.title`, { ns: 'workflow' })}
- {debugEnabled && ( <> -
{t(`${i18nPrefix}.deliveryMethod.emailSender.debugModeTip`, { ns: 'workflow' })}
-
+
+ +
+
}} - values={{ email: userProfile.email }} + components={{ + strong: , + }} />
)} - {!debugEnabled && onlyWholeTeam && ( -
- }} - values={{ team: currentWorkspace.name.replace(/'/g, '’') }} - /> -
- )} - {!debugEnabled && onlySpecificUsers && ( -
{t(`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamTip3`, { ns: 'workflow' })}
- )} - {!debugEnabled && combinedRecipients && ( -
- }} - values={{ team: currentWorkspace.name.replace(/'/g, '’') }} - /> -
- )} -
- {(onlySpecificUsers || combinedRecipients) && !debugEnabled && ( - <> -
- -
-
- , - }} - /> -
- - )} - {/* vars */} - {generatedInputs.length > 0 && ( - <> -
- -
-
-
setCollapsed(!collapsed)}> -
{t(`${i18nPrefix}.deliveryMethod.emailSender.vars`, { ns: 'workflow' })}
- + {/* vars */} + {generatedInputs.length > 0 && ( + <> +
+
-
{t(`${i18nPrefix}.deliveryMethod.emailSender.varsTip`, { ns: 'workflow' })}
- {!collapsed && ( -
- {generatedInputs.map((variable, index) => ( -
- handleValueChange(variable.variable, v)} - /> -
- ))} +
+
setCollapsed(!collapsed)}> +
{t(`${i18nPrefix}.deliveryMethod.emailSender.vars`, { ns: 'workflow' })}
+
- )} -
- - )} -
- - -
- +
{t(`${i18nPrefix}.deliveryMethod.emailSender.varsTip`, { ns: 'workflow' })}
+ {!collapsed && ( +
+ {generatedInputs.map((variable, index) => ( +
+ handleValueChange(variable.variable, v)} + /> +
+ ))} +
+ )} +
+ + )} +
+ + +
+ +
) } diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/upgrade-modal.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/upgrade-modal.tsx index 060ec2428c..18a6e90796 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/upgrade-modal.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/upgrade-modal.tsx @@ -1,76 +1,60 @@ import { Button } from '@langgenius/dify-ui/button' -import { cn } from '@langgenius/dify-ui/cn' -import { - RiMailSendFill, -} from '@remixicon/react' -import { noop } from 'es-toolkit/compat' +import { RiMailSendFill } from '@remixicon/react' import { useTranslation } from 'react-i18next' import { SparklesSoft } from '@/app/components/base/icons/src/public/common' -import Modal from '@/app/components/base/modal' import PremiumBadge from '@/app/components/base/premium-badge' +import { UpgradeModal as BaseUpgradeModal } from '@/app/components/base/upgrade-modal' import { useModalContextSelector } from '@/context/modal-context' type UpgradeModalProps = { - isShow: boolean - onClose: () => void + open: boolean + onOpenChange: (open: boolean) => void } -const UpgradeModal: React.FC = ({ - isShow, - onClose, -}) => { +export function UpgradeModal({ + open, + onOpenChange, +}: UpgradeModalProps) { const { t } = useTranslation() const setShowPricingModal = useModalContextSelector(s => s.setShowPricingModal) + const handleUpgrade = () => { + setShowPricingModal() + } return ( - -
-
- -
-

- {t('nodes.humanInput.deliveryMethod.upgradeTip', { ns: 'workflow' })} -

-

- {t('nodes.humanInput.deliveryMethod.upgradeTipContent', { ns: 'workflow' })} -

-
-
- - { - setShowPricingModal() - }} - > - -
- - {t('upgradeBtn.encourageShort', { ns: 'billing' })} - -
-
-
-
+ + + + +
+ + {t('upgradeBtn.encourageShort', { ns: 'billing' })} + +
+
+ + )} + /> ) } - -export default UpgradeModal diff --git a/web/app/components/workflow/nodes/human-input/panel.tsx b/web/app/components/workflow/nodes/human-input/panel.tsx index b7b65e7de8..fa0914c098 100644 --- a/web/app/components/workflow/nodes/human-input/panel.tsx +++ b/web/app/components/workflow/nodes/human-input/panel.tsx @@ -18,7 +18,7 @@ import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import Divider from '@/app/components/base/divider' -import Tooltip from '@/app/components/base/tooltip' +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 useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list' @@ -108,9 +108,9 @@ const Panel: FC> = ({
{t(`${i18nPrefix}.formContent.title`, { ns: 'workflow' })}
- + + {t(`${i18nPrefix}.formContent.tooltip`, { ns: 'workflow' })} +
{!readOnly && (
@@ -164,9 +164,9 @@ const Panel: FC> = ({
{t(`${i18nPrefix}.userActions.title`, { ns: 'workflow' })}
- + + {t(`${i18nPrefix}.userActions.tooltip`, { ns: 'workflow' })} +
{!readOnly && (
diff --git a/web/context/modal-context-provider.tsx b/web/context/modal-context-provider.tsx index fcc37a1030..c51d422aad 100644 --- a/web/context/modal-context-provider.tsx +++ b/web/context/modal-context-provider.tsx @@ -169,13 +169,13 @@ export const ModalContextProvider = ({ showModelModal.onCancelCallback() }, [showModelModal]) - const handleSaveModelModal = useCallback((formValues?: Record) => { + const handleSaveModelModal = useCallback((formValues?: Record) => { if (showModelModal?.onSaveCallback) showModelModal.onSaveCallback(showModelModal.payload, formValues) setShowModelModal(null) }, [showModelModal]) - const handleRemoveModelModal = useCallback((formValues?: Record) => { + const handleRemoveModelModal = useCallback((formValues?: Record) => { if (showModelModal?.onRemoveCallback) showModelModal.onRemoveCallback(showModelModal.payload, formValues) setShowModelModal(null) @@ -369,7 +369,7 @@ export const ModalContextProvider = ({ }} onSave={() => { setShowUpdatePluginModal(null) - showUpdatePluginModal.onSaveCallback?.({} as any) + showUpdatePluginModal.onSaveCallback?.() }} /> ) diff --git a/web/context/modal-context.test.tsx b/web/context/modal-context.test.tsx index ce5efdb8d9..7434130c31 100644 --- a/web/context/modal-context.test.tsx +++ b/web/context/modal-context.test.tsx @@ -1,4 +1,5 @@ -import { act, screen, waitFor } from '@testing-library/react' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import * as React from 'react' import { defaultPlan } from '@/app/components/billing/config' import { Plan } from '@/app/components/billing/type' @@ -27,21 +28,6 @@ vi.mock('@/context/app-context', () => ({ useAppContext: () => mockUseAppContext(), })) -let latestTriggerEventsModalProps: any = null -const triggerEventsLimitModalMock = vi.fn((props: any) => { - latestTriggerEventsModalProps = props - return ( -
- - -
- ) -}) - -vi.mock('@/app/components/billing/trigger-events-limit-modal', () => ({ - default: (props: any) => triggerEventsLimitModalMock(props), -})) - type DefaultPlanShape = typeof defaultPlan type ResetShape = { apiRateLimit: number | null @@ -79,8 +65,6 @@ const renderProvider = () => renderWithNuqs( describe('ModalContextProvider trigger events limit modal', () => { beforeEach(() => { - latestTriggerEventsModalProps = null - triggerEventsLimitModalMock.mockClear() mockUseAppContext.mockReset() mockUseProviderContext.mockReset() window.localStorage.clear() @@ -109,25 +93,20 @@ describe('ModalContextProvider trigger events limit modal', () => { // Note: vitest.setup.ts replaces localStorage with a mock object that has vi.fn() methods // We need to spy on the mock's setItem, not Storage.prototype.setItem const setItemSpy = vi.spyOn(localStorage, 'setItem') + const user = userEvent.setup() renderProvider() - await waitFor(() => expect(screen.getByTestId('trigger-limit-modal'))!.toBeInTheDocument()) - expect(latestTriggerEventsModalProps).toMatchObject({ - usage: 3000, - total: 3000, - resetInDays: 5, - }) + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) + expect(screen.getAllByText('3000')).toHaveLength(2) - act(() => { - latestTriggerEventsModalProps.onClose() - }) + await user.click(screen.getByRole('button', { name: 'billing.triggerLimitModal.dismiss' })) - await waitFor(() => expect(screen.queryByTestId('trigger-limit-modal')).not.toBeInTheDocument()) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) await waitFor(() => { expect(setItemSpy.mock.calls.length).toBeGreaterThan(0) }) - const [key, value] = (setItemSpy.mock.calls[0] ?? []) as [any, any] + const [key, value] = (setItemSpy.mock.calls[0] ?? []) as [string, string] expect(key).toContain('trigger-events-limit-dismissed-workspace-1-professional-3000-') expect(value).toBe('1') }) @@ -147,18 +126,16 @@ describe('ModalContextProvider trigger events limit modal', () => { throw new Error('Storage disabled') }) const setItemSpy = vi.spyOn(localStorage, 'setItem') + const user = userEvent.setup() renderProvider() - await waitFor(() => expect(screen.getByTestId('trigger-limit-modal'))!.toBeInTheDocument()) + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) - act(() => { - latestTriggerEventsModalProps.onClose() - }) + await user.click(screen.getByRole('button', { name: 'billing.triggerLimitModal.dismiss' })) - await waitFor(() => expect(screen.queryByTestId('trigger-limit-modal')).not.toBeInTheDocument()) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) expect(setItemSpy).not.toHaveBeenCalled() - await waitFor(() => expect(triggerEventsLimitModalMock).toHaveBeenCalledTimes(1)) }) it('falls back to the in-memory guard when localStorage.setItem fails', async () => { @@ -175,16 +152,37 @@ describe('ModalContextProvider trigger events limit modal', () => { vi.spyOn(localStorage, 'setItem').mockImplementation(() => { throw new Error('Quota exceeded') }) + const user = userEvent.setup() renderProvider() - await waitFor(() => expect(screen.getByTestId('trigger-limit-modal'))!.toBeInTheDocument()) + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) - act(() => { - latestTriggerEventsModalProps.onClose() + await user.click(screen.getByRole('button', { name: 'billing.triggerLimitModal.dismiss' })) + + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + }) + + it('closes the trigger events limit modal and opens pricing when upgrading', async () => { + const plan = createPlan({ + type: Plan.professional, + usage: { triggerEvents: 400 }, + total: { triggerEvents: 400 }, + reset: { triggerEvents: 6 }, }) + mockUseProviderContext.mockReturnValue({ + plan, + isFetchedPlan: true, + }) + const user = userEvent.setup() - await waitFor(() => expect(screen.queryByTestId('trigger-limit-modal')).not.toBeInTheDocument()) - await waitFor(() => expect(triggerEventsLimitModalMock).toHaveBeenCalledTimes(1)) + renderProvider() + + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) + + await user.click(screen.getByText('billing.triggerLimitModal.upgrade')) + + await waitFor(() => expect(screen.getByText('billing.plansCommon.mostPopular')).toBeInTheDocument()) + expect(screen.queryByText('400')).not.toBeInTheDocument() }) }) diff --git a/web/context/modal-context.ts b/web/context/modal-context.ts index d019093955..a8f1597cdb 100644 --- a/web/context/modal-context.ts +++ b/web/context/modal-context.ts @@ -28,8 +28,8 @@ import { createContext, useContext, useContextSelector } from 'use-context-selec export type ModalState = { payload: T onCancelCallback?: () => void - onSaveCallback?: (newPayload?: T, formValues?: Record) => void - onRemoveCallback?: (newPayload?: T, formValues?: Record) => void + onSaveCallback?: (newPayload?: T, formValues?: Record) => void + onRemoveCallback?: (newPayload?: T, formValues?: Record) => void onEditCallback?: (newPayload: T) => void onValidateBeforeSaveCallback?: (newPayload: T) => boolean isEditMode?: boolean From a0af10abc8e9c17f5178ee3c481ef4bc14814be4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 19:00:42 +0900 Subject: [PATCH 19/21] chore(deps): bump the storage group across 1 directory with 2 updates (#35791) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- api/pyproject.toml | 4 ++-- api/uv.lock | 28 ++++++++++++++-------------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/api/pyproject.toml b/api/pyproject.toml index 57a57390ff..bcbde0842b 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -6,7 +6,7 @@ requires-python = "~=3.12.0" dependencies = [ # Legacy: mature and widely deployed "bleach>=6.3.0", - "boto3>=1.42.96", + "boto3>=1.43.3", "celery>=5.6.3", "croniter>=6.2.2", "flask>=3.1.3,<4.0.0", @@ -184,7 +184,7 @@ dev = [ ############################################################ storage = [ "azure-storage-blob>=12.28.0", - "bce-python-sdk>=0.9.70", + "bce-python-sdk>=0.9.71", "cos-python-sdk-v5>=1.9.42", "esdk-obs-python>=3.22.2", "google-cloud-storage>=3.10.1", diff --git a/api/uv.lock b/api/uv.lock index 06a1b8edf1..9806f506aa 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -481,7 +481,7 @@ wheels = [ [[package]] name = "bce-python-sdk" -version = "0.9.70" +version = "0.9.71" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "crc32c" }, @@ -489,9 +489,9 @@ dependencies = [ { name = "pycryptodome" }, { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f7/a9/7c21a9073eb9ad7e8cacf6f8a0e47c0d01ad7bf8fd8e0dc42164b117d60b/bce_python_sdk-0.9.70.tar.gz", hash = "sha256:3b37fd7448278dd33f745a6a23198a2cc2490fded9cb8d59b72500784853df4e", size = 299967, upload-time = "2026-04-14T12:02:42.034Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/74/72058f098b9e7184376f2b3d4c1d233ca7fdc52d0f527078f3ce4d9828b9/bce_python_sdk-0.9.71.tar.gz", hash = "sha256:7a917edaee39082694776e25a9e6556ec8072400a3be649f28eb13f9c7a0b5b5", size = 301508, upload-time = "2026-04-28T06:23:21.061Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/2d/70fc866ff98d1f6bd75b0a4235694129b3c519b014254d7bcfc02ffe1bee/bce_python_sdk-0.9.70-py3-none-any.whl", hash = "sha256:fd1f31113e4a8dca314f040662b7caf07ec11cf896c5da232627a9a2c9d2e3a1", size = 415660, upload-time = "2026-04-14T12:02:40.034Z" }, + { url = "https://files.pythonhosted.org/packages/2d/2d/821ae8878dc36b77e56bb7e5dbf9a8e73209c11d38c0ba6b38b5778668ae/bce_python_sdk-0.9.71-py3-none-any.whl", hash = "sha256:9f64a99267616456bac487983d92cc778720bf4f102c8931e8e38aea3cb63268", size = 417000, upload-time = "2026-04-28T06:23:19.078Z" }, ] [[package]] @@ -604,16 +604,16 @@ wheels = [ [[package]] name = "boto3" -version = "1.42.96" +version = "1.43.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a6/2d/69fb3acd50bab83fb295c167d33c4b653faeb5fb0f42bfca4d9b69d6fb68/boto3-1.42.96.tar.gz", hash = "sha256:b38a9e4a3fbbee9017252576f1379780d0a5814768676c08df2f539d31fcdd68", size = 113203, upload-time = "2026-04-24T19:47:18.677Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/50/ea184e159c4ac64fef816a72094fb8656eb071361a39ed22c0e3b15a35b4/boto3-1.43.3.tar.gz", hash = "sha256:7c7777862ffc898f05efa566032bbabfe226dbb810e35ec11125817f128bc5c5", size = 113111, upload-time = "2026-05-04T19:34:09.731Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2b/9d/b3f617d011c42eb804d993103b8fa9acdce153e181a3042f58bfe33d7cb4/boto3-1.42.96-py3-none-any.whl", hash = "sha256:2f4566da2c209a98bdbfc874d813ef231c84ad24e4f815e9bc91de5f63351a24", size = 140557, upload-time = "2026-04-24T19:47:15.824Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ad/8a6946a329f0127322108e537dc1c0d9f8eea4f1d1231702c073d2e85f46/boto3-1.43.3-py3-none-any.whl", hash = "sha256:fb9fe51849ef2a78198d582756fc06f14f7de27f73e0fa90275d6aa4171eb4d0", size = 140501, upload-time = "2026-05-04T19:34:07.991Z" }, ] [[package]] @@ -636,16 +636,16 @@ bedrock-runtime = [ [[package]] name = "botocore" -version = "1.42.96" +version = "1.43.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/61/77/2c333622a1d47cf5bf73cdcab0cb6c92addafbef2ec05f81b9f75687d9e5/botocore-1.42.96.tar.gz", hash = "sha256:75b3b841ffacaa944f645196655a21ca777591dd8911e732bfb6614545af0250", size = 15263344, upload-time = "2026-04-24T19:47:05.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/ac/cd55f886e17b6b952dbc95b792d3645a73d58586a1400ababe54406073bd/botocore-1.43.3.tar.gz", hash = "sha256:eac6da0fffccf87888ebf4d89f0b2378218a707efa748cd955b838995e944695", size = 15308705, upload-time = "2026-05-04T19:33:56.28Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/56/152c3a859ca1b9d77ed16deac3cf81682013677c68cf5715698781fc81bd/botocore-1.42.96-py3-none-any.whl", hash = "sha256:db2c3e2006628be6fde81a24124a6563c363d6982fb92728837cf174bad9d98a", size = 14945920, upload-time = "2026-04-24T19:47:00.323Z" }, + { url = "https://files.pythonhosted.org/packages/be/99/1d9e296edf244f47e0508032f20999f8fd40704dd3c5b601fed099424eb6/botocore-1.43.3-py3-none-any.whl", hash = "sha256:ec0769eb0f7c5034856bb406a92698dbc02a3d4be0f78a384747106b161d8ea3", size = 14989027, upload-time = "2026-05-04T19:33:50.81Z" }, ] [[package]] @@ -1578,7 +1578,7 @@ requires-dist = [ { name = "aliyun-log-python-sdk", specifier = ">=0.9.44,<1.0.0" }, { name = "azure-identity", specifier = ">=1.25.3,<2.0.0" }, { name = "bleach", specifier = ">=6.3.0" }, - { name = "boto3", specifier = ">=1.42.96" }, + { name = "boto3", specifier = ">=1.43.3" }, { name = "celery", specifier = ">=5.6.3" }, { name = "croniter", specifier = ">=6.2.2" }, { name = "fastopenapi", extras = ["flask"], specifier = "~=0.7.0" }, @@ -1683,7 +1683,7 @@ dev = [ ] storage = [ { name = "azure-storage-blob", specifier = ">=12.28.0" }, - { name = "bce-python-sdk", specifier = ">=0.9.70" }, + { name = "bce-python-sdk", specifier = ">=0.9.71" }, { name = "cos-python-sdk-v5", specifier = ">=1.9.42" }, { name = "esdk-obs-python", specifier = ">=3.22.2" }, { name = "google-cloud-storage", specifier = ">=3.10.1" }, @@ -5914,14 +5914,14 @@ wheels = [ [[package]] name = "s3transfer" -version = "0.16.0" +version = "0.17.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/ec/7c692cde9125b77e84b307354d4fb705f98b8ccad59a036d5957ca75bfc3/s3transfer-0.17.0.tar.gz", hash = "sha256:9edeb6d1c3c2f89d6050348548834ad8289610d886e5bf7b7207728bd43ce33a", size = 155337, upload-time = "2026-04-29T22:07:36.33Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" }, + { url = "https://files.pythonhosted.org/packages/87/72/c6c32d2b657fa3dad1de340254e14390b1e334ce38268b7ad51abda3c8c2/s3transfer-0.17.0-py3-none-any.whl", hash = "sha256:ce3801712acf4ad3e89fb9990df97b4972e93f4b3b0004d214be5bce12814c20", size = 86811, upload-time = "2026-04-29T22:07:34.966Z" }, ] [[package]] From c0431ec843d05b6d40952fef0d4aa16e8d770d31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=9E=E6=B3=95=E6=93=8D=E4=BD=9C?= Date: Tue, 5 May 2026 18:48:14 +0800 Subject: [PATCH 20/21] fix: workflow online users polling for large app lists (#35786) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/controllers/console/app/workflow.py | 47 +++++++++----- .../controllers/console/app/test_workflow.py | 61 ++++++++++++++----- web/app/components/apps/list.tsx | 21 ++++--- web/contract/console/apps.ts | 6 +- web/service/apps.ts | 2 +- 5 files changed, 94 insertions(+), 43 deletions(-) diff --git a/api/controllers/console/app/workflow.py b/api/controllers/console/app/workflow.py index 478f783eb0..68dd8b7a8d 100644 --- a/api/controllers/console/app/workflow.py +++ b/api/controllers/console/app/workflow.py @@ -60,7 +60,8 @@ _file_access_controller = DatabaseFileAccessController() LISTENING_RETRY_IN = 2000 DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" RESTORE_SOURCE_WORKFLOW_MUST_BE_PUBLISHED_MESSAGE = "source workflow must be published" -MAX_WORKFLOW_ONLINE_USERS_QUERY_IDS = 50 +MAX_WORKFLOW_ONLINE_USERS_REQUEST_IDS = 1000 +WORKFLOW_ONLINE_USERS_REDIS_BATCH_SIZE = 50 # Register models for flask_restx to avoid dict type issues in Swagger # Register in dependency order: base models first, then dependent models @@ -158,8 +159,13 @@ class WorkflowFeaturesPayload(BaseModel): features: dict[str, Any] = Field(..., description="Workflow feature configuration") -class WorkflowOnlineUsersQuery(BaseModel): - app_ids: str = Field(..., description="Comma-separated app IDs") +class WorkflowOnlineUsersPayload(BaseModel): + app_ids: list[str] = Field(default_factory=list, description="App IDs") + + @field_validator("app_ids") + @classmethod + def normalize_app_ids(cls, app_ids: list[str]) -> list[str]: + return list(dict.fromkeys(app_id.strip() for app_id in app_ids if app_id.strip())) class DraftWorkflowTriggerRunPayload(BaseModel): @@ -186,7 +192,7 @@ reg(ConvertToWorkflowPayload) reg(WorkflowListQuery) reg(WorkflowUpdatePayload) reg(WorkflowFeaturesPayload) -reg(WorkflowOnlineUsersQuery) +reg(WorkflowOnlineUsersPayload) reg(DraftWorkflowTriggerRunPayload) reg(DraftWorkflowTriggerRunAllPayload) @@ -1384,19 +1390,19 @@ class DraftWorkflowTriggerRunAllApi(Resource): @console_ns.route("/apps/workflows/online-users") class WorkflowOnlineUsersApi(Resource): - @console_ns.expect(console_ns.models[WorkflowOnlineUsersQuery.__name__]) + @console_ns.expect(console_ns.models[WorkflowOnlineUsersPayload.__name__]) @console_ns.doc("get_workflow_online_users") @console_ns.doc(description="Get workflow online users") @setup_required @login_required @account_initialization_required @marshal_with(online_user_list_fields) - def get(self): - args = WorkflowOnlineUsersQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + def post(self): + args = WorkflowOnlineUsersPayload.model_validate(console_ns.payload or {}) - app_ids = list(dict.fromkeys(app_id.strip() for app_id in args.app_ids.split(",") if app_id.strip())) - if len(app_ids) > MAX_WORKFLOW_ONLINE_USERS_QUERY_IDS: - raise BadRequest(f"Maximum {MAX_WORKFLOW_ONLINE_USERS_QUERY_IDS} app_ids are allowed per request.") + app_ids = args.app_ids + if len(app_ids) > MAX_WORKFLOW_ONLINE_USERS_REQUEST_IDS: + raise BadRequest(f"Maximum {MAX_WORKFLOW_ONLINE_USERS_REQUEST_IDS} app_ids are allowed per request.") if not app_ids: return {"data": []} @@ -1404,13 +1410,24 @@ class WorkflowOnlineUsersApi(Resource): _, current_tenant_id = current_account_with_tenant() workflow_service = WorkflowService() accessible_app_ids = workflow_service.get_accessible_app_ids(app_ids, current_tenant_id) + ordered_accessible_app_ids = [app_id for app_id in app_ids if app_id in accessible_app_ids] + + users_json_by_app_id: dict[str, Any] = {} + for start_index in range(0, len(ordered_accessible_app_ids), WORKFLOW_ONLINE_USERS_REDIS_BATCH_SIZE): + app_id_batch = ordered_accessible_app_ids[ + start_index : start_index + WORKFLOW_ONLINE_USERS_REDIS_BATCH_SIZE + ] + pipe = redis_client.pipeline(transaction=False) + for app_id in app_id_batch: + pipe.hgetall(f"{WORKFLOW_ONLINE_USERS_PREFIX}{app_id}") + + users_json_batch = pipe.execute() + for app_id, users_json in zip(app_id_batch, users_json_batch): + users_json_by_app_id[app_id] = users_json results = [] - for app_id in app_ids: - if app_id not in accessible_app_ids: - continue - - users_json = redis_client.hgetall(f"{WORKFLOW_ONLINE_USERS_PREFIX}{app_id}") + for app_id in ordered_accessible_app_ids: + users_json = users_json_by_app_id.get(app_id, {}) users = [] for _, user_info_json in users_json.items(): diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow.py b/api/tests/unit_tests/controllers/console/app/test_workflow.py index e91c0a0597..7c470eb9a8 100644 --- a/api/tests/unit_tests/controllers/console/app/test_workflow.py +++ b/api/tests/unit_tests/controllers/console/app/test_workflow.py @@ -363,7 +363,8 @@ def test_workflow_online_users_filters_inaccessible_workflow(app, monkeypatch: p ) monkeypatch.setattr(workflow_module.file_helpers, "get_signed_file_url", sign_avatar) - workflow_module.redis_client.hgetall.side_effect = lambda key: ( + redis_pipeline = Mock() + redis_pipeline.execute.return_value = [ { b"sid-1": json.dumps( { @@ -374,16 +375,16 @@ def test_workflow_online_users_filters_inaccessible_workflow(app, monkeypatch: p } ) } - if key == f"{workflow_module.WORKFLOW_ONLINE_USERS_PREFIX}{app_id_1}" - else {} - ) + ] + workflow_module.redis_client.pipeline.return_value = redis_pipeline api = workflow_module.WorkflowOnlineUsersApi() - handler = _unwrap(api.get) + handler = _unwrap(api.post) with app.test_request_context( - f"/apps/workflows/online-users?app_ids={app_id_1},{app_id_2}", - method="GET", + "/apps/workflows/online-users", + method="POST", + json={"app_ids": [app_id_1, app_id_2]}, ): response = handler(api) @@ -402,12 +403,43 @@ def test_workflow_online_users_filters_inaccessible_workflow(app, monkeypatch: p } ] } - workflow_module.redis_client.hgetall.assert_called_once_with( - f"{workflow_module.WORKFLOW_ONLINE_USERS_PREFIX}{app_id_1}" - ) + workflow_module.redis_client.pipeline.assert_called_once_with(transaction=False) + redis_pipeline.hgetall.assert_called_once_with(f"{workflow_module.WORKFLOW_ONLINE_USERS_PREFIX}{app_id_1}") + redis_pipeline.execute.assert_called_once_with() sign_avatar.assert_called_once_with("avatar-file-id") +def test_workflow_online_users_batches_redis_reads(app, monkeypatch: pytest.MonkeyPatch) -> None: + app_ids = [f"wf-{index}" for index in range(workflow_module.WORKFLOW_ONLINE_USERS_REDIS_BATCH_SIZE + 1)] + monkeypatch.setattr(workflow_module, "current_account_with_tenant", lambda: (SimpleNamespace(), "tenant-1")) + monkeypatch.setattr( + workflow_module, + "WorkflowService", + lambda: SimpleNamespace(get_accessible_app_ids=lambda app_ids, tenant_id: set(app_ids)), + ) + + first_pipeline = Mock() + first_pipeline.execute.return_value = [{} for _ in range(workflow_module.WORKFLOW_ONLINE_USERS_REDIS_BATCH_SIZE)] + second_pipeline = Mock() + second_pipeline.execute.return_value = [{}] + workflow_module.redis_client.pipeline.side_effect = [first_pipeline, second_pipeline] + + api = workflow_module.WorkflowOnlineUsersApi() + handler = _unwrap(api.post) + + with app.test_request_context( + "/apps/workflows/online-users", + method="POST", + json={"app_ids": app_ids}, + ): + response = handler(api) + + assert len(response["data"]) == len(app_ids) + assert workflow_module.redis_client.pipeline.call_count == 2 + assert first_pipeline.hgetall.call_count == workflow_module.WORKFLOW_ONLINE_USERS_REDIS_BATCH_SIZE + assert second_pipeline.hgetall.call_count == 1 + + def test_workflow_online_users_rejects_excessive_workflow_ids(app, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(workflow_module, "current_account_with_tenant", lambda: (SimpleNamespace(), "tenant-1")) accessible_app_ids = Mock(return_value=set()) @@ -417,14 +449,15 @@ def test_workflow_online_users_rejects_excessive_workflow_ids(app, monkeypatch: lambda: SimpleNamespace(get_accessible_app_ids=accessible_app_ids), ) - excessive_ids = ",".join(f"wf-{index}" for index in range(workflow_module.MAX_WORKFLOW_ONLINE_USERS_QUERY_IDS + 1)) + excessive_ids = [f"wf-{index}" for index in range(workflow_module.MAX_WORKFLOW_ONLINE_USERS_REQUEST_IDS + 1)] api = workflow_module.WorkflowOnlineUsersApi() - handler = _unwrap(api.get) + handler = _unwrap(api.post) with app.test_request_context( - f"/apps/workflows/online-users?app_ids={excessive_ids}", - method="GET", + "/apps/workflows/online-users", + method="POST", + json={"app_ids": excessive_ids}, ): with pytest.raises(HTTPException) as exc: handler(api) diff --git a/web/app/components/apps/list.tsx b/web/app/components/apps/list.tsx index d1bdf533fe..b744fe77aa 100644 --- a/web/app/components/apps/list.tsx +++ b/web/app/components/apps/list.tsx @@ -187,15 +187,16 @@ const List: FC = ({ }, [isCreatedByMe, setQuery]) const pages = useMemo(() => data?.pages ?? [], [data?.pages]) - const appIds = useMemo(() => { - const ids = new Set() - pages.forEach((page) => { - page.data?.forEach((app) => { - if (app.id) - ids.add(app.id) + + const workflowOnlineUserAppIds = useMemo(() => { + const appIds = new Set() + pages.forEach(({ data: apps }) => { + apps.forEach((app) => { + if (app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT) + appIds.add(app.id) }) }) - return Array.from(ids) + return Array.from(appIds) }, [pages]) const refreshWorkflowOnlineUsers = useCallback(async () => { @@ -204,19 +205,19 @@ const List: FC = ({ return } - if (!appIds.length) { + if (!workflowOnlineUserAppIds.length) { setWorkflowOnlineUsersMap({}) return } try { - const onlineUsersMap = await fetchWorkflowOnlineUsers({ appIds }) + const onlineUsersMap = await fetchWorkflowOnlineUsers({ appIds: workflowOnlineUserAppIds }) setWorkflowOnlineUsersMap(onlineUsersMap) } catch { setWorkflowOnlineUsersMap({}) } - }, [appIds, systemFeatures.enable_collaboration_mode]) + }, [systemFeatures.enable_collaboration_mode, workflowOnlineUserAppIds]) useEffect(() => { void refreshWorkflowOnlineUsers() diff --git a/web/contract/console/apps.ts b/web/contract/console/apps.ts index ff4f5096b2..2f5f16c25e 100644 --- a/web/contract/console/apps.ts +++ b/web/contract/console/apps.ts @@ -17,11 +17,11 @@ export const appDeleteContract = base export const workflowOnlineUsersContract = base .route({ path: '/apps/workflows/online-users', - method: 'GET', + method: 'POST', }) .input(type<{ - query: { - app_ids: string + body: { + app_ids: string[] } }>()) .output(type()) diff --git a/web/service/apps.ts b/web/service/apps.ts index d2c6593a34..221e83cf39 100644 --- a/web/service/apps.ts +++ b/web/service/apps.ts @@ -14,7 +14,7 @@ export const fetchWorkflowOnlineUsers = async ({ appIds }: { appIds: string[] }) return {} const response = await consoleClient.apps.workflowOnlineUsers({ - query: { app_ids: appIds.join(',') }, + body: { app_ids: appIds }, }) if (!response?.data) From 995c43f3dd0c88be298237673ac385d3c1e01cb8 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Tue, 5 May 2026 22:53:38 +0800 Subject: [PATCH 21/21] refactor: migrate workflow queries to contracts (#35799) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/controllers/console/app/app.py | 38 ++- .../console/app/test_app_response_models.py | 97 ++++++ eslint-suppressions.json | 26 -- .../apps/app-list-browsing-flow.test.tsx | 35 +- web/__tests__/apps/create-app-flow.test.tsx | 35 +- .../(commonLayout)/account-page/index.tsx | 16 +- .../components/apps/__tests__/list.spec.tsx | 92 +++++- .../apps/hooks/use-workflow-online-users.ts | 49 +++ web/app/components/apps/list.tsx | 118 +++---- .../header/app-nav/__tests__/index.spec.tsx | 53 ++- web/app/components/header/app-nav/index.tsx | 98 +++--- .../app-selector/__tests__/index.spec.tsx | 54 +++- .../app-selector/index.tsx | 27 +- .../workflow/comment/comment-icon.spec.tsx | 2 +- .../workflow/comment/comment-icon.tsx | 2 +- .../workflow/comment/comment-preview.spec.tsx | 4 +- .../workflow/comment/comment-preview.tsx | 5 +- .../workflow/comment/mention-input.spec.tsx | 49 ++- .../workflow/comment/mention-input.tsx | 33 +- .../workflow/comment/thread.spec.tsx | 2 +- .../components/workflow/comment/thread.tsx | 2 +- .../__tests__/use-workflow-comment.spec.ts | 303 +++++++++++++++--- .../workflow/hooks/use-workflow-comment.ts | 120 ++++--- .../__tests__/input-var-list.spec.tsx | 29 +- .../comments-panel/__tests__/index.spec.tsx | 25 +- .../workflow/panel/comments-panel/index.tsx | 21 +- .../workflow/store/workflow/comment-slice.ts | 2 +- web/contract/console/apps.ts | 22 +- web/contract/console/workflow-comment.ts | 40 +-- web/contract/router.ts | 3 +- web/service/apps.ts | 29 +- web/service/use-apps.ts | 72 +---- web/service/workflow-comment.ts | 103 ------ 33 files changed, 1013 insertions(+), 593 deletions(-) create mode 100644 web/app/components/apps/hooks/use-workflow-online-users.ts delete mode 100644 web/service/workflow-comment.ts diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index a736fc8bc8..c8334bfd18 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -1,4 +1,5 @@ import logging +import re import uuid from datetime import datetime from typing import Any, Literal @@ -8,6 +9,7 @@ from flask_restx import Resource from pydantic import AliasChoices, BaseModel, Field, computed_field, field_validator from sqlalchemy import select from sqlalchemy.orm import Session +from werkzeug.datastructures import MultiDict from werkzeug.exceptions import BadRequest from controllers.common.helpers import FileInfo @@ -57,6 +59,7 @@ ALLOW_CREATE_APP_MODES = ["chat", "agent-chat", "advanced-chat", "workflow", "co register_enum_models(console_ns, IconType) _logger = logging.getLogger(__name__) +_TAG_IDS_BRACKET_PATTERN = re.compile(r"^tag_ids\[(\d+)\]$") class AppListQuery(BaseModel): @@ -66,22 +69,19 @@ class AppListQuery(BaseModel): default="all", description="App mode filter" ) name: str | None = Field(default=None, description="Filter by app name") - tag_ids: list[str] | None = Field(default=None, description="Comma-separated tag IDs") + tag_ids: list[str] | None = Field(default=None, description="Filter by tag IDs") is_created_by_me: bool | None = Field(default=None, description="Filter by creator") @field_validator("tag_ids", mode="before") @classmethod - def validate_tag_ids(cls, value: str | list[str] | None) -> list[str] | None: + def validate_tag_ids(cls, value: list[str] | None) -> list[str] | None: if not value: return None - if isinstance(value, str): - items = [item.strip() for item in value.split(",") if item.strip()] - elif isinstance(value, list): - items = [str(item).strip() for item in value if item and str(item).strip()] - else: - raise TypeError("Unsupported tag_ids type.") + if not isinstance(value, list): + raise ValueError("Unsupported tag_ids type.") + items = [str(item).strip() for item in value if item and str(item).strip()] if not items: return None @@ -91,6 +91,26 @@ class AppListQuery(BaseModel): raise ValueError("Invalid UUID format in tag_ids.") from exc +def _normalize_app_list_query_args(query_args: MultiDict[str, str]) -> dict[str, str | list[str]]: + normalized: dict[str, str | list[str]] = {} + indexed_tag_ids: list[tuple[int, str]] = [] + + for key in query_args: + match = _TAG_IDS_BRACKET_PATTERN.fullmatch(key) + if match: + indexed_tag_ids.extend((int(match.group(1)), value) for value in query_args.getlist(key)) + continue + + value = query_args.get(key) + if value is not None: + normalized[key] = value + + if indexed_tag_ids: + normalized["tag_ids"] = [value for _, value in sorted(indexed_tag_ids)] + + return normalized + + class CreateAppPayload(BaseModel): name: str = Field(..., min_length=1, description="App name") description: str | None = Field(default=None, description="App description (max 400 chars)", max_length=400) @@ -455,7 +475,7 @@ class AppListApi(Resource): """Get app list""" current_user, current_tenant_id = current_account_with_tenant() - args = AppListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = AppListQuery.model_validate(_normalize_app_list_query_args(request.args)) args_dict = args.model_dump() # get app list diff --git a/api/tests/unit_tests/controllers/console/app/test_app_response_models.py b/api/tests/unit_tests/controllers/console/app/test_app_response_models.py index 35d07a987d..80e7c41a9e 100644 --- a/api/tests/unit_tests/controllers/console/app/test_app_response_models.py +++ b/api/tests/unit_tests/controllers/console/app/test_app_response_models.py @@ -10,6 +10,8 @@ from typing import Any import pytest from flask.views import MethodView +from pydantic import ValidationError +from werkzeug.datastructures import MultiDict # kombu references MethodView as a global when importing celery/kombu pools. if not hasattr(builtins, "MethodView"): @@ -174,6 +176,101 @@ def _dummy_workflow(): ) +def test_app_list_query_normalizes_orpc_bracket_tag_ids(app_module): + first_tag_id = "8c4ef3d1-58a1-4d94-8a1c-1c171d889e08" + second_tag_id = "3c39395b-6d1f-4030-8b17-eaa7cc85221c" + query_args = MultiDict( + [ + ("page", "1"), + ("limit", "30"), + ("tag_ids[1]", second_tag_id), + ("tag_ids[0]", first_tag_id), + ] + ) + + normalized = app_module._normalize_app_list_query_args(query_args) + query = app_module.AppListQuery.model_validate(normalized) + + assert query.tag_ids == [first_tag_id, second_tag_id] + + +def test_app_list_query_preserves_regular_query_params(app_module): + query_args = MultiDict( + [ + ("page", "2"), + ("limit", "50"), + ("mode", "chat"), + ("name", "Sales Copilot"), + ("is_created_by_me", "true"), + ] + ) + + normalized = app_module._normalize_app_list_query_args(query_args) + query = app_module.AppListQuery.model_validate(normalized) + + assert normalized == { + "page": "2", + "limit": "50", + "mode": "chat", + "name": "Sales Copilot", + "is_created_by_me": "true", + } + assert query.page == 2 + assert query.limit == 50 + assert query.mode == "chat" + assert query.name == "Sales Copilot" + assert query.is_created_by_me is True + assert query.tag_ids is None + + +def test_app_list_query_normalizes_empty_bracket_tag_ids_to_none(app_module): + query_args = MultiDict( + [ + ("tag_ids[0]", ""), + ("tag_ids[1]", " "), + ] + ) + + normalized = app_module._normalize_app_list_query_args(query_args) + query = app_module.AppListQuery.model_validate(normalized) + + assert normalized == {"tag_ids": ["", " "]} + assert query.tag_ids is None + + +def test_app_list_query_rejects_invalid_bracket_tag_id(app_module): + normalized = app_module._normalize_app_list_query_args(MultiDict([("tag_ids[0]", "not-a-uuid")])) + + with pytest.raises(ValidationError): + app_module.AppListQuery.model_validate(normalized) + + +def test_app_list_query_sorts_bracket_tag_ids_by_index(app_module): + first_tag_id = "8c4ef3d1-58a1-4d94-8a1c-1c171d889e08" + second_tag_id = "3c39395b-6d1f-4030-8b17-eaa7cc85221c" + third_tag_id = "9d5ec0f7-4f2b-4e7f-9c13-1e7a034d0eb1" + query_args = MultiDict( + [ + ("tag_ids[2]", third_tag_id), + ("tag_ids[1]", second_tag_id), + ("tag_ids[0]", first_tag_id), + ] + ) + + normalized = app_module._normalize_app_list_query_args(query_args) + query = app_module.AppListQuery.model_validate(normalized) + + assert query.tag_ids == [first_tag_id, second_tag_id, third_tag_id] + + +def test_app_list_query_rejects_flat_tag_ids(app_module): + tag_id = "8c4ef3d1-58a1-4d94-8a1c-1c171d889e08" + normalized = app_module._normalize_app_list_query_args(MultiDict([("tag_ids", tag_id)])) + + with pytest.raises(ValidationError): + app_module.AppListQuery.model_validate(normalized) + + def test_app_partial_serialization_uses_aliases(app_models): AppPartial = app_models.AppPartial created_at = _ts() diff --git a/eslint-suppressions.json b/eslint-suppressions.json index bbb5cd5af9..3c86fb2b7c 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -162,11 +162,6 @@ "count": 5 } }, - "web/app/account/(commonLayout)/account-page/index.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "web/app/account/(commonLayout)/delete-account/components/feed-back.tsx": { "no-restricted-imports": { "count": 1 @@ -653,14 +648,6 @@ "count": 2 } }, - "web/app/components/apps/list.tsx": { - "react-hooks/exhaustive-deps": { - "count": 1 - }, - "react/unsupported-syntax": { - "count": 2 - } - }, "web/app/components/apps/new-app-card.tsx": { "react-hooks-extra/no-direct-set-state-in-use-effect": { "count": 1 @@ -2824,14 +2811,6 @@ "count": 4 } }, - "web/app/components/header/app-nav/index.tsx": { - "react/set-state-in-effect": { - "count": 2 - }, - "ts/no-explicit-any": { - "count": 1 - } - }, "web/app/components/header/header-wrapper.tsx": { "ts/no-explicit-any": { "count": 1 @@ -5480,11 +5459,6 @@ "count": 2 } }, - "web/service/use-apps.ts": { - "ts/no-explicit-any": { - "count": 1 - } - }, "web/service/use-common.ts": { "ts/no-empty-object-type": { "count": 1 diff --git a/web/__tests__/apps/app-list-browsing-flow.test.tsx b/web/__tests__/apps/app-list-browsing-flow.test.tsx index 768420f00d..e6b83bd69d 100644 --- a/web/__tests__/apps/app-list-browsing-flow.test.tsx +++ b/web/__tests__/apps/app-list-browsing-flow.test.tsx @@ -88,27 +88,36 @@ vi.mock('@/service/tag', () => ({ fetchTagList: vi.fn().mockResolvedValue([]), })) -vi.mock('@/service/apps', () => ({ - fetchWorkflowOnlineUsers: vi.fn().mockResolvedValue({}), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useInfiniteQuery: () => ({ + data: { pages: mockPages }, + isLoading: mockIsLoading, + isFetching: mockIsFetching, + isFetchingNextPage: mockIsFetchingNextPage, + fetchNextPage: mockFetchNextPage, + hasNextPage: mockHasNextPage, + error: mockError, + refetch: mockRefetch, + }), + } +}) vi.mock('@/service/use-apps', () => ({ - useInfiniteAppList: () => ({ - data: { pages: mockPages }, - isLoading: mockIsLoading, - isFetching: mockIsFetching, - isFetchingNextPage: mockIsFetchingNextPage, - fetchNextPage: mockFetchNextPage, - hasNextPage: mockHasNextPage, - error: mockError, - refetch: mockRefetch, - }), useDeleteAppMutation: () => ({ mutateAsync: vi.fn(), isPending: false, }), })) +vi.mock('@/app/components/apps/hooks/use-workflow-online-users', () => ({ + useWorkflowOnlineUsers: () => ({ + onlineUsersMap: {}, + }), +})) + vi.mock('@/hooks/use-pay', () => ({ CheckModal: () => null, })) diff --git a/web/__tests__/apps/create-app-flow.test.tsx b/web/__tests__/apps/create-app-flow.test.tsx index e480db06ea..079ea9949a 100644 --- a/web/__tests__/apps/create-app-flow.test.tsx +++ b/web/__tests__/apps/create-app-flow.test.tsx @@ -75,27 +75,36 @@ vi.mock('@/service/tag', () => ({ fetchTagList: vi.fn().mockResolvedValue([]), })) -vi.mock('@/service/apps', () => ({ - fetchWorkflowOnlineUsers: vi.fn().mockResolvedValue({}), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useInfiniteQuery: () => ({ + data: { pages: mockPages }, + isLoading: mockIsLoading, + isFetching: mockIsFetching, + isFetchingNextPage: false, + fetchNextPage: mockFetchNextPage, + hasNextPage: false, + error: null, + refetch: mockRefetch, + }), + } +}) vi.mock('@/service/use-apps', () => ({ - useInfiniteAppList: () => ({ - data: { pages: mockPages }, - isLoading: mockIsLoading, - isFetching: mockIsFetching, - isFetchingNextPage: false, - fetchNextPage: mockFetchNextPage, - hasNextPage: false, - error: null, - refetch: mockRefetch, - }), useDeleteAppMutation: () => ({ mutateAsync: vi.fn(), isPending: false, }), })) +vi.mock('@/app/components/apps/hooks/use-workflow-online-users', () => ({ + useWorkflowOnlineUsers: () => ({ + onlineUsersMap: {}, + }), +})) + vi.mock('@/hooks/use-pay', () => ({ CheckModal: () => null, })) diff --git a/web/app/account/(commonLayout)/account-page/index.tsx b/web/app/account/(commonLayout)/account-page/index.tsx index 09c083b60b..75d4e5afa8 100644 --- a/web/app/account/(commonLayout)/account-page/index.tsx +++ b/web/app/account/(commonLayout)/account-page/index.tsx @@ -7,7 +7,7 @@ import { toast } from '@langgenius/dify-ui/toast' import { RiGraduationCapFill, } from '@remixicon/react' -import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query' +import { useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query' import { useState } from 'react' import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' @@ -16,9 +16,9 @@ import PremiumBadge from '@/app/components/base/premium-badge' import Collapse from '@/app/components/header/account-setting/collapse' import { IS_CE_EDITION, validPassword } from '@/config' import { useProviderContext } from '@/context/provider-context' +import { consoleQuery } from '@/service/client' import { updateUserProfile } from '@/service/common' import { systemFeaturesQueryOptions } from '@/service/system-features' -import { useAppList } from '@/service/use-apps' import { commonQueryKeys, userProfileQueryOptions } from '@/service/use-common' import DeleteAccount from '../delete-account' @@ -35,7 +35,15 @@ const descriptionClassName = ` export default function AccountPage() { const { t } = useTranslation() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) - const { data: appList } = useAppList({ page: 1, limit: 100, name: '' }) + const { data: appList } = useQuery(consoleQuery.apps.list.queryOptions({ + input: { + query: { + page: 1, + limit: 100, + name: '', + }, + }, + })) const apps = appList?.data || [] const queryClient = useQueryClient() // Cache is warmed by AppContextProvider's useSuspenseQuery; this hits cache synchronously. @@ -129,7 +137,7 @@ export default function AccountPage() { } const renderAppItem = (item: IItem) => { - const { icon, icon_background, icon_type, icon_url } = item as any + const { icon, icon_background, icon_type, icon_url } = item as IItem & Pick return (
diff --git a/web/app/components/apps/__tests__/list.spec.tsx b/web/app/components/apps/__tests__/list.spec.tsx index c3ce96255a..9d1b39ef06 100644 --- a/web/app/components/apps/__tests__/list.spec.tsx +++ b/web/app/components/apps/__tests__/list.spec.tsx @@ -7,6 +7,11 @@ import { AppModeEnum } from '@/types/app' import List from '../list' +const mockAppListInfiniteOptions = vi.hoisted(() => vi.fn((options: unknown) => options)) +const mockUseWorkflowOnlineUsers = vi.hoisted(() => vi.fn((_options: unknown) => ({ + onlineUsersMap: {}, +}))) + const mockReplace = vi.fn() const mockRouter = { replace: mockReplace } vi.mock('@/next/navigation', () => ({ @@ -14,6 +19,22 @@ vi.mock('@/next/navigation', () => ({ useSearchParams: () => new URLSearchParams(''), })) +vi.mock('@/service/client', () => ({ + consoleClient: { + systemFeatures: vi.fn(), + }, + consoleQuery: { + apps: { + list: { + infiniteOptions: (options: unknown) => mockAppListInfiniteOptions(options), + }, + }, + systemFeatures: { + queryKey: () => ['console', 'systemFeatures'], + }, + }, +})) + const mockIsCurrentWorkspaceEditor = vi.fn(() => true) const mockIsCurrentWorkspaceDatasetOperator = vi.fn(() => false) vi.mock('@/context/app-context', () => ({ @@ -45,12 +66,17 @@ vi.mock('../hooks/use-dsl-drag-drop', () => ({ }, })) +vi.mock('../hooks/use-workflow-online-users', () => ({ + useWorkflowOnlineUsers: (options: unknown) => mockUseWorkflowOnlineUsers(options), +})) + const mockRefetch = vi.fn() const mockFetchNextPage = vi.fn() const mockServiceState = { error: null as Error | null, hasNextPage: false, + isFetching: false, isLoading: false, isFetchingNextPage: false, } @@ -89,16 +115,24 @@ const defaultAppData = { }], } +vi.mock('@tanstack/react-query', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useInfiniteQuery: () => ({ + data: defaultAppData, + isLoading: mockServiceState.isLoading, + isFetching: mockServiceState.isFetching, + isFetchingNextPage: mockServiceState.isFetchingNextPage, + fetchNextPage: mockFetchNextPage, + hasNextPage: mockServiceState.hasNextPage, + error: mockServiceState.error, + refetch: mockRefetch, + }), + } +}) + vi.mock('@/service/use-apps', () => ({ - useInfiniteAppList: () => ({ - data: defaultAppData, - isLoading: mockServiceState.isLoading, - isFetchingNextPage: mockServiceState.isFetchingNextPage, - fetchNextPage: mockFetchNextPage, - hasNextPage: mockServiceState.hasNextPage, - error: mockServiceState.error, - refetch: mockRefetch, - }), useDeleteAppMutation: () => ({ mutateAsync: vi.fn(), isPending: false, @@ -194,6 +228,11 @@ const renderList = (searchParams = '') => { return renderWithNuqs(, { searchParams }) } +type AppListInfiniteOptions = { + input: (pageParam: number) => { query: Record } + getNextPageParam: (lastPage: { has_more: boolean, page: number }) => number | undefined +} + describe('List', () => { beforeEach(() => { vi.clearAllMocks() @@ -212,6 +251,7 @@ describe('List', () => { mockQueryState.tagIDs = [] mockQueryState.keywords = '' mockQueryState.isCreatedByMe = false + mockUseWorkflowOnlineUsers.mockClear() intersectionCallback = null localStorage.clear() }) @@ -269,6 +309,15 @@ describe('List', () => { renderList() expect(screen.getByText('app.newApp.dropDSLToCreateApp'))!.toBeInTheDocument() }) + + it('should pass workflow app ids to online users hook', () => { + renderList() + + expect(mockUseWorkflowOnlineUsers).toHaveBeenCalledWith({ + appIds: ['app-2'], + enabled: expect.any(Boolean), + }) + }) }) describe('Tab Navigation', () => { @@ -323,6 +372,31 @@ describe('List', () => { }) }) + describe('App List Query', () => { + it('should build paged query input from active filters', () => { + mockQueryState.tagIDs = ['tag-1'] + mockQueryState.keywords = 'sales' + mockQueryState.isCreatedByMe = true + + renderList('?category=workflow') + + const options = mockAppListInfiniteOptions.mock.calls.at(-1)?.[0] as AppListInfiniteOptions + + expect(options.input(2)).toEqual({ + query: { + page: 2, + limit: 30, + name: 'sales', + tag_ids: ['tag-1'], + is_created_by_me: true, + mode: AppModeEnum.WORKFLOW, + }, + }) + expect(options.getNextPageParam({ has_more: true, page: 2 })).toBe(3) + expect(options.getNextPageParam({ has_more: false, page: 2 })).toBeUndefined() + }) + }) + describe('Tag Filter', () => { it('should render tag filter component', () => { renderList() diff --git a/web/app/components/apps/hooks/use-workflow-online-users.ts b/web/app/components/apps/hooks/use-workflow-online-users.ts new file mode 100644 index 0000000000..a1778306f3 --- /dev/null +++ b/web/app/components/apps/hooks/use-workflow-online-users.ts @@ -0,0 +1,49 @@ +import type { WorkflowOnlineUser, WorkflowOnlineUsersResponse } from '@/models/app' +import { skipToken, useQuery } from '@tanstack/react-query' +import { consoleQuery } from '@/service/client' + +type WorkflowOnlineUsersMap = Record + +type UseWorkflowOnlineUsersParams = { + appIds: string[] + enabled: boolean +} + +const normalizeWorkflowOnlineUsers = (response?: WorkflowOnlineUsersResponse): WorkflowOnlineUsersMap => { + const data = response?.data + + if (!data) + return {} + + if (Array.isArray(data)) { + return data.reduce((acc, item) => { + if (item?.app_id) + acc[item.app_id] = item.users || [] + return acc + }, {}) + } + + return Object.entries(data).reduce((acc, [appId, users]) => { + if (appId) + acc[appId] = users || [] + return acc + }, {}) +} + +export const useWorkflowOnlineUsers = ({ + appIds, + enabled, +}: UseWorkflowOnlineUsersParams) => { + const shouldFetch = enabled && appIds.length > 0 + const { data: onlineUsersMap = {} } = useQuery(consoleQuery.apps.workflowOnlineUsers.queryOptions({ + input: shouldFetch + ? { body: { app_ids: appIds } } + : skipToken, + select: normalizeWorkflowOnlineUsers, + refetchInterval: shouldFetch ? 10000 : false, + })) + + return { + onlineUsersMap, + } +} diff --git a/web/app/components/apps/list.tsx b/web/app/components/apps/list.tsx index b744fe77aa..728ef38ba5 100644 --- a/web/app/components/apps/list.tsx +++ b/web/app/components/apps/list.tsx @@ -1,9 +1,9 @@ 'use client' import type { FC } from 'react' -import type { WorkflowOnlineUser } from '@/models/app' +import type { AppListQuery } from '@/contract/console/apps' import { cn } from '@langgenius/dify-ui/cn' -import { useSuspenseQuery } from '@tanstack/react-query' +import { keepPreviousData, useInfiniteQuery, useSuspenseQuery } from '@tanstack/react-query' import { useDebounceFn } from 'ahooks' import { parseAsStringLiteral, useQueryState } from 'nuqs' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -17,9 +17,8 @@ import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { useAppContext } from '@/context/app-context' import { CheckModal } from '@/hooks/use-pay' import dynamic from '@/next/dynamic' -import { fetchWorkflowOnlineUsers } from '@/service/apps' +import { consoleQuery } from '@/service/client' import { systemFeaturesQueryOptions } from '@/service/system-features' -import { useInfiniteAppList } from '@/service/use-apps' import { AppModeEnum, AppModes } from '@/types/app' import AppCard from './app-card' import { AppCardSkeleton } from './app-card-skeleton' @@ -27,6 +26,7 @@ import Empty from './empty' import Footer from './footer' import useAppsQueryState from './hooks/use-apps-query-state' import { useDSLDragDrop } from './hooks/use-dsl-drag-drop' +import { useWorkflowOnlineUsers } from './hooks/use-workflow-online-users' import NewAppCard from './new-app-card' const TagManagementModal = dynamic(() => import('@/app/components/base/tag-management'), { @@ -71,7 +71,6 @@ const List: FC = ({ const containerRef = useRef(null) const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false) const [droppedDSLFile, setDroppedDSLFile] = useState() - const [workflowOnlineUsersMap, setWorkflowOnlineUsersMap] = useState>({}) const setKeywords = useCallback((keywords: string) => { setQuery(prev => ({ ...prev, keywords })) }, [setQuery]) @@ -90,14 +89,14 @@ const List: FC = ({ enabled: isCurrentWorkspaceEditor, }) - const appListQueryParams = { + const appListQuery = useMemo(() => ({ page: 1, limit: 30, name: searchKeywords, - tag_ids: tagIDs, - is_created_by_me: isCreatedByMe, + ...(tagIDs.length ? { tag_ids: tagIDs } : {}), + ...(isCreatedByMe ? { is_created_by_me: isCreatedByMe } : {}), ...(activeTab !== 'all' ? { mode: activeTab } : {}), - } + }), [activeTab, isCreatedByMe, searchKeywords, tagIDs]) const { data, @@ -108,14 +107,27 @@ const List: FC = ({ hasNextPage, error, refetch, - } = useInfiniteAppList(appListQueryParams, { enabled: !isCurrentWorkspaceDatasetOperator }) + } = useInfiniteQuery({ + ...consoleQuery.apps.list.infiniteOptions({ + input: pageParam => ({ + query: { + ...appListQuery, + page: Number(pageParam), + }, + }), + getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined, + initialPageParam: 1, + placeholderData: keepPreviousData, + }), + enabled: !isCurrentWorkspaceDatasetOperator, + refetchInterval: systemFeatures.enable_collaboration_mode ? 10000 : false, + }) useEffect(() => { if (controlRefreshList > 0) { refetch() } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [controlRefreshList]) + }, [controlRefreshList, refetch]) const anchorRef = useRef(null) const options = [ @@ -187,53 +199,23 @@ const List: FC = ({ }, [isCreatedByMe, setQuery]) const pages = useMemo(() => data?.pages ?? [], [data?.pages]) + const apps = useMemo(() => pages.flatMap(({ data: pageApps }) => pageApps), [pages]) const workflowOnlineUserAppIds = useMemo(() => { const appIds = new Set() - pages.forEach(({ data: apps }) => { - apps.forEach((app) => { - if (app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT) - appIds.add(app.id) - }) + apps.forEach((app) => { + if (app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT) + appIds.add(app.id) }) return Array.from(appIds) - }, [pages]) + }, [apps]) - const refreshWorkflowOnlineUsers = useCallback(async () => { - if (!systemFeatures.enable_collaboration_mode) { - setWorkflowOnlineUsersMap({}) - return - } - - if (!workflowOnlineUserAppIds.length) { - setWorkflowOnlineUsersMap({}) - return - } - - try { - const onlineUsersMap = await fetchWorkflowOnlineUsers({ appIds: workflowOnlineUserAppIds }) - setWorkflowOnlineUsersMap(onlineUsersMap) - } - catch { - setWorkflowOnlineUsersMap({}) - } - }, [systemFeatures.enable_collaboration_mode, workflowOnlineUserAppIds]) - - useEffect(() => { - void refreshWorkflowOnlineUsers() - }, [refreshWorkflowOnlineUsers]) - - useEffect(() => { - if (!systemFeatures.enable_collaboration_mode) - return - - const timer = window.setInterval(() => { - void refetch() - void refreshWorkflowOnlineUsers() - }, 10000) - - return () => window.clearInterval(timer) - }, [refetch, refreshWorkflowOnlineUsers, systemFeatures.enable_collaboration_mode]) + const { + onlineUsersMap: workflowOnlineUsersMap, + } = useWorkflowOnlineUsers({ + appIds: workflowOnlineUserAppIds, + enabled: systemFeatures.enable_collaboration_mode, + }) const hasAnyApp = (pages[0]?.total ?? 0) > 0 // Show skeleton during initial load or when refetching with no previous data @@ -288,24 +270,18 @@ const List: FC = ({ className={cn(!hasAnyApp && 'z-10')} /> )} - {(() => { - if (showSkeleton) - return - - if (hasAnyApp) { - return pages.flatMap(({ data: apps }) => apps).map(app => ( - - )) - } - - // No apps - show empty state - return - })()} + {showSkeleton + ? + : hasAnyApp + ? apps.map(app => ( + + )) + : } {isFetchingNextPage && ( )} diff --git a/web/app/components/header/app-nav/__tests__/index.spec.tsx b/web/app/components/header/app-nav/__tests__/index.spec.tsx index 03f8edfacf..c2b0207b83 100644 --- a/web/app/components/header/app-nav/__tests__/index.spec.tsx +++ b/web/app/components/header/app-nav/__tests__/index.spec.tsx @@ -1,12 +1,14 @@ +import { useInfiniteQuery } from '@tanstack/react-query' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useStore as useAppStore } from '@/app/components/app/store' import { useAppContext } from '@/context/app-context' import { useParams } from '@/next/navigation' -import { useInfiniteAppList } from '@/service/use-apps' import { AppModeEnum } from '@/types/app' import AppNav from '../index' +const mockAppListInfiniteOptions = vi.hoisted(() => vi.fn((options: unknown) => options)) + vi.mock('@/next/navigation', () => ({ useParams: vi.fn(), })) @@ -25,10 +27,24 @@ vi.mock('@/app/components/app/store', () => ({ useStore: vi.fn(), })) -vi.mock('@/service/use-apps', () => ({ - useInfiniteAppList: vi.fn(), +vi.mock('@/service/client', () => ({ + consoleQuery: { + apps: { + list: { + infiniteOptions: (options: unknown) => mockAppListInfiniteOptions(options), + }, + }, + }, })) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useInfiniteQuery: vi.fn(), + } +}) + vi.mock('@/app/components/app/create-app-dialog', () => ({ default: ({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess: () => void }) => show @@ -130,8 +146,12 @@ const mockAppData = [ const mockUseParams = vi.mocked(useParams) const mockUseAppContext = vi.mocked(useAppContext) const mockUseAppStore = vi.mocked(useAppStore) -const mockUseInfiniteAppList = vi.mocked(useInfiniteAppList) +const mockUseInfiniteQuery = vi.mocked(useInfiniteQuery) let mockAppDetail: { id: string, name: string } | null = null +type AppListInfiniteOptions = { + input: (pageParam: number) => { query: { page: number, limit: number, name: string } } + getNextPageParam: (lastPage: { has_more: boolean, page: number }) => number | undefined +} const setupDefaultMocks = (options?: { hasNextPage?: boolean @@ -146,13 +166,13 @@ const setupDefaultMocks = (options?: { mockUseParams.mockReturnValue({ appId: 'app-1' } as ReturnType) mockUseAppContext.mockReturnValue({ isCurrentWorkspaceEditor: options?.isEditor ?? false } as ReturnType) mockUseAppStore.mockImplementation((selector: unknown) => (selector as (state: { appDetail: { id: string, name: string } | null }) => unknown)({ appDetail: mockAppDetail })) - mockUseInfiniteAppList.mockReturnValue({ + mockUseInfiniteQuery.mockReturnValue({ data: { pages: [{ data: options?.appData ?? mockAppData }] }, fetchNextPage, hasNextPage: options?.hasNextPage ?? false, isFetchingNextPage: false, refetch, - } as ReturnType) + } as ReturnType) return { refetch, fetchNextPage } } @@ -164,6 +184,23 @@ describe('AppNav', () => { setupDefaultMocks() }) + it('should configure paged app list query options', () => { + setupDefaultMocks() + render() + + const options = mockAppListInfiniteOptions.mock.calls.at(-1)?.[0] as AppListInfiniteOptions + + expect(options.input(3)).toEqual({ + query: { + page: 3, + limit: 30, + name: '', + }, + }) + expect(options.getNextPageParam({ has_more: true, page: 3 })).toBe(4) + expect(options.getNextPageParam({ has_more: false, page: 3 })).toBeUndefined() + }) + it('should build editor links and update app name when app detail changes', async () => { setupDefaultMocks({ isEditor: true, @@ -282,13 +319,13 @@ describe('AppNav', () => { // Arrange setupDefaultMocks() mockUseParams.mockReturnValue({} as ReturnType) - mockUseInfiniteAppList.mockReturnValue({ + mockUseInfiniteQuery.mockReturnValue({ data: undefined, fetchNextPage: vi.fn(), hasNextPage: false, isFetchingNextPage: false, refetch: vi.fn(), - } as unknown as ReturnType) + } as unknown as ReturnType) // Act render() diff --git a/web/app/components/header/app-nav/index.tsx b/web/app/components/header/app-nav/index.tsx index 2e7a77a891..a65477e4df 100644 --- a/web/app/components/header/app-nav/index.tsx +++ b/web/app/components/header/app-nav/index.tsx @@ -1,19 +1,19 @@ 'use client' import type { NavItem } from '../nav/nav-selector' +import type { AppListQuery } from '@/contract/console/apps' import { RiRobot2Fill, RiRobot2Line, } from '@remixicon/react' -import { flatten } from 'es-toolkit/compat' -import { produce } from 'immer' -import { useCallback, useEffect, useState } from 'react' +import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query' +import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useStore as useAppStore } from '@/app/components/app/store' import { useAppContext } from '@/context/app-context' import dynamic from '@/next/dynamic' import { useParams } from '@/next/navigation' -import { useInfiniteAppList } from '@/service/use-apps' +import { consoleQuery } from '@/service/client' import { AppModeEnum } from '@/types/app' import Nav from '../nav' @@ -21,6 +21,22 @@ const CreateAppTemplateDialog = dynamic(() => import('@/app/components/app/creat const CreateAppModal = dynamic(() => import('@/app/components/app/create-app-modal'), { ssr: false }) const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-from-dsl-modal'), { ssr: false }) +const appNavListQuery = { + page: 1, + limit: 30, + name: '', +} satisfies AppListQuery + +const getAppLink = (isCurrentWorkspaceEditor: boolean, appId: string, appMode: AppModeEnum) => { + if (!isCurrentWorkspaceEditor) + return `/app/${appId}/overview` + + if (appMode === AppModeEnum.WORKFLOW || appMode === AppModeEnum.ADVANCED_CHAT) + return `/app/${appId}/workflow` + + return `/app/${appId}/configuration` +} + const AppNav = () => { const { t } = useTranslation() const { appId } = useParams() @@ -29,7 +45,6 @@ const AppNav = () => { const [showNewAppDialog, setShowNewAppDialog] = useState(false) const [showNewAppTemplateDialog, setShowNewAppTemplateDialog] = useState(false) const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false) - const [navItems, setNavItems] = useState([]) const { data: appsData, @@ -37,11 +52,20 @@ const AppNav = () => { hasNextPage, isFetchingNextPage, refetch, - } = useInfiniteAppList({ - page: 1, - limit: 30, - name: '', - }, { enabled: !!appId }) + } = useInfiniteQuery({ + ...consoleQuery.apps.list.infiniteOptions({ + input: pageParam => ({ + query: { + ...appNavListQuery, + page: Number(pageParam), + }, + }), + getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined, + initialPageParam: 1, + placeholderData: keepPreviousData, + }), + enabled: !!appId, + }) const handleLoadMore = useCallback(() => { if (hasNextPage) @@ -57,48 +81,20 @@ const AppNav = () => { setShowCreateFromDSLModal(true) } - useEffect(() => { - if (appsData) { - const appItems = flatten((appsData.pages ?? []).map(appData => appData.data)) - const navItems = appItems.map((app) => { - const link = ((isCurrentWorkspaceEditor, app) => { - if (!isCurrentWorkspaceEditor) { - return `/app/${app.id}/overview` - } - else { - if (app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT) - return `/app/${app.id}/workflow` - else - return `/app/${app.id}/configuration` - } - })(isCurrentWorkspaceEditor, app) - return { - id: app.id, - icon_type: app.icon_type, - icon: app.icon, - icon_background: app.icon_background, - icon_url: app.icon_url, - name: app.name, - mode: app.mode, - link, - } - }) - setNavItems(navItems as any) - } - }, [appsData, isCurrentWorkspaceEditor, setNavItems]) + const navItems = useMemo(() => { + const appItems = appsData?.pages.flatMap(appData => appData.data) ?? [] - // update current app name - useEffect(() => { - if (appDetail) { - const newNavItems = produce(navItems, (draft: NavItem[]) => { - navItems.forEach((app, index) => { - if (app.id === appDetail.id) - draft[index]!.name = appDetail.name - }) - }) - setNavItems(newNavItems) - } - }, [appDetail, navItems]) + return appItems.map(app => ({ + id: app.id, + icon_type: app.icon_type, + icon: app.icon, + icon_background: app.icon_background, + icon_url: app.icon_url, + name: appDetail?.id === app.id ? appDetail.name : app.name, + mode: app.mode, + link: getAppLink(isCurrentWorkspaceEditor, app.id, app.mode), + })) + }, [appDetail?.id, appDetail?.name, appsData?.pages, isCurrentWorkspaceEditor]) return ( <> diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx index d67be38ab4..38b5324fa7 100644 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx @@ -15,6 +15,8 @@ import AppSelector from '../index' // ==================== Mock Setup ==================== +const mockAppListInfiniteOptions = vi.hoisted(() => vi.fn((options: unknown) => options)) + // Mock IntersectionObserver globally using class syntax let intersectionObserverCallback: IntersectionObserverCallback | null = null const mockIntersectionObserver = { @@ -163,19 +165,36 @@ const getAppDetailData = (appId: string) => { } vi.mock('@/service/use-apps', () => ({ - useInfiniteAppList: () => ({ - data: mockAppListData, - isLoading: mockIsLoading, - isFetchingNextPage: mockIsFetchingNextPage, - fetchNextPage: mockFetchNextPage, - hasNextPage: mockHasNextPage, - }), useAppDetail: (appId: string) => ({ data: getAppDetailData(appId), isFetching: mockAppDetailLoading, }), })) +vi.mock('@/service/client', () => ({ + consoleQuery: { + apps: { + list: { + infiniteOptions: (options: unknown) => mockAppListInfiniteOptions(options), + }, + }, + }, +})) + +vi.mock('@tanstack/react-query', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useInfiniteQuery: () => ({ + data: mockAppListData, + isLoading: mockIsLoading, + isFetchingNextPage: mockIsFetchingNextPage, + fetchNextPage: mockFetchNextPage, + hasNextPage: mockHasNextPage, + }), + } +}) + // Allow configurable mock data for useAppWorkflow let mockWorkflowData: Record | undefined | null let mockWorkflowLoading = false @@ -323,6 +342,11 @@ const renderWithQueryClient = (ui: React.ReactElement) => { ) } +type AppSelectorInfiniteOptions = { + input: (pageParam: number) => { query: Record } + getNextPageParam: (lastPage: { has_more: boolean, page: number }) => number | undefined +} + // Mock data factories const createMockApp = (overrides: Record = {}): App => ({ id: 'app-1', @@ -1539,6 +1563,22 @@ describe('AppSelector', () => { expect(screen.getByText('app.appSelector.placeholder'))!.toBeInTheDocument() }) + it('should configure paged app list query options', () => { + renderWithQueryClient() + + const options = mockAppListInfiniteOptions.mock.calls.at(-1)?.[0] as AppSelectorInfiniteOptions + + expect(options.input(4)).toEqual({ + query: { + page: 4, + limit: 20, + name: '', + }, + }) + expect(options.getNextPageParam({ has_more: true, page: 4 })).toBe(5) + expect(options.getNextPageParam({ has_more: false, page: 4 })).toBeUndefined() + }) + it('should show selected app info when value is provided', () => { renderWithQueryClient( = ({ const [isShow, setIsShow] = useState(false) const [searchText, setSearchText] = useState('') + const appListQuery = useMemo(() => ({ + page: 1, + limit: PAGE_SIZE, + name: searchText, + }), [searchText]) + const { data, isLoading, isFetchingNextPage, fetchNextPage, hasNextPage, - } = useInfiniteAppList({ - page: 1, - limit: PAGE_SIZE, - name: searchText, + } = useInfiniteQuery({ + ...consoleQuery.apps.list.infiniteOptions({ + input: pageParam => ({ + query: { + ...appListQuery, + page: Number(pageParam), + }, + }), + getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined, + initialPageParam: 1, + placeholderData: keepPreviousData, + }), }) const displayedApps = useMemo(() => { diff --git a/web/app/components/workflow/comment/comment-icon.spec.tsx b/web/app/components/workflow/comment/comment-icon.spec.tsx index aee8c64fa3..eeca5bb4e6 100644 --- a/web/app/components/workflow/comment/comment-icon.spec.tsx +++ b/web/app/components/workflow/comment/comment-icon.spec.tsx @@ -1,4 +1,4 @@ -import type { WorkflowCommentList } from '@/service/workflow-comment' +import type { WorkflowCommentList } from '@/contract/console/workflow-comment' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { CommentIcon } from './comment-icon' diff --git a/web/app/components/workflow/comment/comment-icon.tsx b/web/app/components/workflow/comment/comment-icon.tsx index 7f005f3465..7270cd3ac9 100644 --- a/web/app/components/workflow/comment/comment-icon.tsx +++ b/web/app/components/workflow/comment/comment-icon.tsx @@ -1,7 +1,7 @@ 'use client' import type { FC, PointerEvent as ReactPointerEvent } from 'react' -import type { WorkflowCommentList } from '@/service/workflow-comment' +import type { WorkflowCommentList } from '@/contract/console/workflow-comment' import { memo, useCallback, useMemo, useRef, useState } from 'react' import { useReactFlow, useViewport } from 'reactflow' import { UserAvatarList } from '@/app/components/base/user-avatar-list' diff --git a/web/app/components/workflow/comment/comment-preview.spec.tsx b/web/app/components/workflow/comment/comment-preview.spec.tsx index d411c67ecd..a83303cb0d 100644 --- a/web/app/components/workflow/comment/comment-preview.spec.tsx +++ b/web/app/components/workflow/comment/comment-preview.spec.tsx @@ -1,9 +1,9 @@ -import type { WorkflowCommentList } from '@/service/workflow-comment' +import type { WorkflowCommentList } from '@/contract/console/workflow-comment' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import CommentPreview from './comment-preview' -type UserProfile = WorkflowCommentList['created_by_account'] +type UserProfile = NonNullable const mockSetHovering = vi.fn() let capturedUsers: UserProfile[] = [] diff --git a/web/app/components/workflow/comment/comment-preview.tsx b/web/app/components/workflow/comment/comment-preview.tsx index 5985ed848b..6aadb522a9 100644 --- a/web/app/components/workflow/comment/comment-preview.tsx +++ b/web/app/components/workflow/comment/comment-preview.tsx @@ -1,7 +1,7 @@ 'use client' import type { FC } from 'react' -import type { WorkflowCommentList } from '@/service/workflow-comment' +import type { WorkflowCommentList } from '@/contract/console/workflow-comment' import { memo, useEffect, useMemo } from 'react' import { UserAvatarList } from '@/app/components/base/user-avatar-list' import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' @@ -15,6 +15,7 @@ type CommentPreviewProps = { const CommentPreview: FC = ({ comment, onClick }) => { const { formatTimeFromNow } = useFormatTimeFromNow() const setCommentPreviewHovering = useStore(s => s.setCommentPreviewHovering) + const authorName = comment.created_by_account?.name ?? '' const participants = useMemo(() => { const list = comment.participants ?? [] const author = comment.created_by_account @@ -44,7 +45,7 @@ const CommentPreview: FC = ({ comment, onClick }) => {
-
{comment.created_by_account.name}
+
{authorName}
{formatTimeFromNow(comment.updated_at * 1000)}
diff --git a/web/app/components/workflow/comment/mention-input.spec.tsx b/web/app/components/workflow/comment/mention-input.spec.tsx index 49fc7b6e87..ce53880595 100644 --- a/web/app/components/workflow/comment/mention-input.spec.tsx +++ b/web/app/components/workflow/comment/mention-input.spec.tsx @@ -1,5 +1,5 @@ -import type { UserProfile } from '@/service/workflow-comment' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import type { UserProfile } from '@/contract/console/workflow-comment' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { useState } from 'react' import { MentionInput } from './mention-input' @@ -30,8 +30,12 @@ vi.mock('@/next/navigation', () => ({ useParams: () => ({ appId: 'app-1' }), })) -vi.mock('@/service/workflow-comment', () => ({ - fetchMentionableUsers: (...args: unknown[]) => mockFetchMentionableUsers(...args), +vi.mock('@/service/client', () => ({ + consoleClient: { + workflowComments: { + mentionUsers: (...args: unknown[]) => mockFetchMentionableUsers(...args), + }, + }, })) vi.mock('../store', () => ({ @@ -80,7 +84,7 @@ describe('MentionInput', () => { vi.clearAllMocks() mentionStoreState.mentionableUsersCache = {} mentionStoreState.mentionableUsersLoading = {} - mockFetchMentionableUsers.mockResolvedValue(mentionUsers) + mockFetchMentionableUsers.mockResolvedValue({ users: mentionUsers }) }) it('loads mentionable users when cache is empty', async () => { @@ -93,7 +97,9 @@ describe('MentionInput', () => { ) await waitFor(() => { - expect(mockFetchMentionableUsers).toHaveBeenCalledWith('app-1') + expect(mockFetchMentionableUsers).toHaveBeenCalledWith({ + params: { appId: 'app-1' }, + }) }) expect(mockSetMentionableUsersLoading).toHaveBeenCalledWith('app-1', true) @@ -148,4 +154,35 @@ describe('MentionInput', () => { expect(onSubmit).toHaveBeenCalledWith('updated reply', []) }) }) + + it('focuses the textarea at the end when autoFocus is enabled', () => { + vi.useFakeTimers() + try { + mentionStoreState.mentionableUsersCache['app-1'] = mentionUsers + + const { unmount } = render( + , + ) + + const textarea = screen.getByPlaceholderText('workflow.comments.placeholder.add') as HTMLTextAreaElement + + act(() => { + vi.runOnlyPendingTimers() + }) + + expect(document.activeElement).toBe(textarea) + expect(textarea.selectionStart).toBe(5) + expect(textarea.selectionEnd).toBe(5) + + unmount() + } + finally { + vi.useRealTimers() + } + }) }) diff --git a/web/app/components/workflow/comment/mention-input.tsx b/web/app/components/workflow/comment/mention-input.tsx index b6a7caa055..59f3bf1249 100644 --- a/web/app/components/workflow/comment/mention-input.tsx +++ b/web/app/components/workflow/comment/mention-input.tsx @@ -1,7 +1,7 @@ 'use client' import type { ReactNode } from 'react' -import type { UserProfile } from '@/service/workflow-comment' +import type { UserProfile } from '@/contract/console/workflow-comment' import { Avatar } from '@langgenius/dify-ui/avatar' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' @@ -22,7 +22,7 @@ import { useTranslation } from 'react-i18next' import Textarea from 'react-textarea-autosize' import EnterKey from '@/app/components/base/icons/src/public/common/EnterKey' import { useParams } from '@/next/navigation' -import { fetchMentionableUsers } from '@/service/workflow-comment' +import { consoleClient } from '@/service/client' import { useStore, useWorkflowStore } from '../store' type MentionInputProps = { @@ -38,6 +38,8 @@ type MentionInputProps = { autoFocus?: boolean } +const EMPTY_USERS: UserProfile[] = [] + const MentionInputInner = forwardRef(({ value, onChange, @@ -66,7 +68,7 @@ const MentionInputInner = forwardRef(({ const mentionUsersFromStore = useStore(state => ( appId ? state.mentionableUsersCache[appId] : undefined )) - const mentionUsers = mentionUsersFromStore ?? [] + const mentionUsers = useMemo(() => mentionUsersFromStore ?? EMPTY_USERS, [mentionUsersFromStore]) const [showMentionDropdown, setShowMentionDropdown] = useState(false) const [mentionQuery, setMentionQuery] = useState('') @@ -163,8 +165,10 @@ const MentionInputInner = forwardRef(({ state.setMentionableUsersLoading(appId, true) try { - const users = await fetchMentionableUsers(appId) - workflowStore.getState().setMentionableUsersCache(appId, users) + const response = await consoleClient.workflowComments.mentionUsers({ + params: { appId }, + }) + workflowStore.getState().setMentionableUsersCache(appId, response.users) } catch (error) { console.error('Failed to load mentionable users:', error) @@ -495,14 +499,17 @@ const MentionInputInner = forwardRef(({ }, [value, resetMentionState]) useEffect(() => { - if (autoFocus && textareaRef.current) { - const textarea = textareaRef.current - setTimeout(() => { - textarea.focus() - const length = textarea.value.length - textarea.setSelectionRange(length, length) - }, 0) - } + if (!autoFocus || !textareaRef.current) + return + + const textarea = textareaRef.current + const timeout = window.setTimeout(() => { + textarea.focus() + const length = textarea.value.length + textarea.setSelectionRange(length, length) + }, 0) + + return () => window.clearTimeout(timeout) }, [autoFocus]) return ( diff --git a/web/app/components/workflow/comment/thread.spec.tsx b/web/app/components/workflow/comment/thread.spec.tsx index 58bb99cff0..2aa36142e9 100644 --- a/web/app/components/workflow/comment/thread.spec.tsx +++ b/web/app/components/workflow/comment/thread.spec.tsx @@ -1,4 +1,4 @@ -import type { WorkflowCommentDetail } from '@/service/workflow-comment' +import type { WorkflowCommentDetail } from '@/contract/console/workflow-comment' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { CommentThread } from './thread' diff --git a/web/app/components/workflow/comment/thread.tsx b/web/app/components/workflow/comment/thread.tsx index 34e0092372..071cc48462 100644 --- a/web/app/components/workflow/comment/thread.tsx +++ b/web/app/components/workflow/comment/thread.tsx @@ -1,7 +1,7 @@ 'use client' import type { FC, ReactNode } from 'react' -import type { WorkflowCommentDetail, WorkflowCommentDetailReply } from '@/service/workflow-comment' +import type { WorkflowCommentDetail, WorkflowCommentDetailReply } from '@/contract/console/workflow-comment' import { Avatar, AvatarFallback, AvatarImage, AvatarRoot } from '@langgenius/dify-ui/avatar' import { cn } from '@langgenius/dify-ui/cn' import { diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-comment.spec.ts b/web/app/components/workflow/hooks/__tests__/use-workflow-comment.spec.ts index b2edfa5234..19eb1ad0e8 100644 --- a/web/app/components/workflow/hooks/__tests__/use-workflow-comment.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-workflow-comment.spec.ts @@ -1,4 +1,4 @@ -import type { WorkflowCommentDetail, WorkflowCommentList } from '@/service/workflow-comment' +import type { WorkflowCommentDetail, WorkflowCommentList } from '@/contract/console/workflow-comment' import { act, waitFor } from '@testing-library/react' import { createTestQueryClient, seedSystemFeatures } from '@/__tests__/utils/mock-system-features' import { renderWorkflowHook } from '../../__tests__/workflow-test-env' @@ -52,16 +52,30 @@ vi.mock('@/context/app-context', () => ({ }), })) -vi.mock('@/service/workflow-comment', () => ({ - createWorkflowComment: (...args: unknown[]) => mockCreateWorkflowComment(...args), - createWorkflowCommentReply: (...args: unknown[]) => mockCreateWorkflowCommentReply(...args), - deleteWorkflowComment: (...args: unknown[]) => mockDeleteWorkflowComment(...args), - deleteWorkflowCommentReply: (...args: unknown[]) => mockDeleteWorkflowCommentReply(...args), - fetchWorkflowComment: (...args: unknown[]) => mockFetchWorkflowComment(...args), - fetchWorkflowComments: (...args: unknown[]) => mockFetchWorkflowComments(...args), - resolveWorkflowComment: (...args: unknown[]) => mockResolveWorkflowComment(...args), - updateWorkflowComment: (...args: unknown[]) => mockUpdateWorkflowComment(...args), - updateWorkflowCommentReply: (...args: unknown[]) => mockUpdateWorkflowCommentReply(...args), +vi.mock('@/service/client', () => ({ + consoleClient: { + systemFeatures: () => ({ + enable_collaboration_mode: globalFeatureState.enableCollaboration, + }), + workflowComments: { + create: (...args: unknown[]) => mockCreateWorkflowComment(...args), + delete: (...args: unknown[]) => mockDeleteWorkflowComment(...args), + detail: (...args: unknown[]) => mockFetchWorkflowComment(...args), + list: (...args: unknown[]) => mockFetchWorkflowComments(...args), + resolve: (...args: unknown[]) => mockResolveWorkflowComment(...args), + update: (...args: unknown[]) => mockUpdateWorkflowComment(...args), + replies: { + create: (...args: unknown[]) => mockCreateWorkflowCommentReply(...args), + delete: (...args: unknown[]) => mockDeleteWorkflowCommentReply(...args), + update: (...args: unknown[]) => mockUpdateWorkflowCommentReply(...args), + }, + }, + }, + consoleQuery: { + systemFeatures: { + queryKey: () => ['console', 'systemFeatures'], + }, + }, })) vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({ @@ -127,25 +141,27 @@ describe('useWorkflowComment', () => { commentsUpdateState.handler = undefined globalFeatureState.enableCollaboration = true - mockFetchWorkflowComments.mockResolvedValue([]) + mockFetchWorkflowComments.mockResolvedValue({ data: [] }) mockFetchWorkflowComment.mockResolvedValue(baseCommentDetail()) mockCreateWorkflowComment.mockResolvedValue({ id: 'comment-2', - created_at: '1700000000', + created_at: 1700000000, }) mockUpdateWorkflowComment.mockResolvedValue({}) }) it('loads comment list on mount when collaboration is enabled', async () => { const comment = baseComment() - mockFetchWorkflowComments.mockResolvedValue([comment]) + mockFetchWorkflowComments.mockResolvedValue({ data: [comment] }) const { store } = renderWorkflowHook(() => useWorkflowComment(), { queryClient: createSeededQueryClient(), }) await waitFor(() => { - expect(mockFetchWorkflowComments).toHaveBeenCalledWith('app-1') + expect(mockFetchWorkflowComments).toHaveBeenCalledWith({ + params: { appId: 'app-1' }, + }) }) expect(store.getState().comments).toEqual([comment]) @@ -186,11 +202,14 @@ describe('useWorkflowComment', () => { await result.current.handleCommentSubmit('new message', ['user-2']) }) - expect(mockCreateWorkflowComment).toHaveBeenCalledWith('app-1', { - position_x: 10, - position_y: 20, - content: 'new message', - mentioned_user_ids: ['user-2'], + expect(mockCreateWorkflowComment).toHaveBeenCalledWith({ + params: { appId: 'app-1' }, + body: { + position_x: 10, + position_y: 20, + content: 'new message', + mentioned_user_ids: ['user-2'], + }, }) expect(mockEmitCommentsUpdate).toHaveBeenCalledWith('app-1') @@ -214,6 +233,79 @@ describe('useWorkflowComment', () => { expect(store.getState().isCommentQuickAdd).toBe(false) }) + it('normalizes numeric string timestamps when creating a comment', async () => { + mockCreateWorkflowComment.mockResolvedValue({ + id: 'comment-string-time', + created_at: '1700001234', + }) + + const { result, store } = renderWorkflowHook(() => useWorkflowComment(), { + queryClient: createSeededQueryClient(), + initialStoreState: { + comments: [], + pendingComment: { pageX: 100, pageY: 200, elementX: 10, elementY: 20 }, + isCommentQuickAdd: true, + }, + }) + + await act(async () => { + await result.current.handleCommentSubmit('new message') + }) + + expect(store.getState().comments[0]).toMatchObject({ + id: 'comment-string-time', + created_at: 1700001234, + updated_at: 1700001234, + }) + }) + + it('normalizes ISO timestamps and keeps unresolved mentions as null', async () => { + const createdAt = '2024-01-02T03:04:05.000Z' + mockCreateWorkflowComment.mockResolvedValue({ + id: 'comment-date-time', + created_at: createdAt, + }) + + const { result, store } = renderWorkflowHook(() => useWorkflowComment(), { + queryClient: createSeededQueryClient(), + initialStoreState: { + comments: [], + pendingComment: { pageX: 100, pageY: 200, elementX: 10, elementY: 20 }, + isCommentQuickAdd: true, + mentionableUsersCache: { + 'app-1': [{ + id: 'user-2', + name: 'Bob', + email: 'bob@example.com', + avatar_url: 'bob.png', + }], + }, + }, + }) + + await act(async () => { + await result.current.handleCommentSubmit('new message', ['missing-user']) + }) + + const expectedCreatedAt = Math.floor(Date.parse(createdAt) / 1000) + expect(store.getState().comments[0]).toMatchObject({ + id: 'comment-date-time', + created_at: expectedCreatedAt, + updated_at: expectedCreatedAt, + participants: [{ + id: 'user-1', + name: 'Alice', + email: 'alice@example.com', + avatar_url: 'alice.png', + }], + }) + expect(store.getState().commentDetailCache['comment-date-time']?.mentions).toEqual([{ + mentioned_user_id: 'missing-user', + mentioned_user_account: null, + reply_id: null, + }]) + }) + it('rolls back optimistic position update when API update fails', async () => { const comment = baseComment() const commentDetail = baseCommentDetail() @@ -235,10 +327,13 @@ describe('useWorkflowComment', () => { await result.current.handleCommentPositionUpdate(comment.id, { x: 300, y: 400 }) }) - expect(mockUpdateWorkflowComment).toHaveBeenCalledWith('app-1', comment.id, { - content: 'hello', - position_x: 300, - position_y: 400, + expect(mockUpdateWorkflowComment).toHaveBeenCalledWith({ + params: { appId: 'app-1', commentId: comment.id }, + body: { + content: 'hello', + position_x: 300, + position_y: 400, + }, }) expect(mockEmitCommentsUpdate).not.toHaveBeenCalled() expect(store.getState().comments[0]).toMatchObject({ @@ -257,8 +352,8 @@ describe('useWorkflowComment', () => { ...baseCommentDetail(), content: 'updated by another user', } - mockFetchWorkflowComments.mockResolvedValue([comment]) - mockFetchWorkflowComment.mockResolvedValue({ data: detail }) + mockFetchWorkflowComments.mockResolvedValue({ data: [comment] }) + mockFetchWorkflowComment.mockResolvedValue(detail) const { unmount } = renderWorkflowHook(() => useWorkflowComment(), { queryClient: createSeededQueryClient(), @@ -276,7 +371,9 @@ describe('useWorkflowComment', () => { }) await waitFor(() => { - expect(mockFetchWorkflowComment).toHaveBeenCalledWith('app-1', comment.id) + expect(mockFetchWorkflowComment).toHaveBeenCalledWith({ + params: { appId: 'app-1', commentId: comment.id }, + }) }) expect(mockFetchWorkflowComments).toHaveBeenCalledTimes(2) @@ -362,7 +459,7 @@ describe('useWorkflowComment', () => { position_x: 33, position_y: 55, } - mockFetchWorkflowComments.mockResolvedValue([commentB]) + mockFetchWorkflowComments.mockResolvedValue({ data: [commentB] }) mockFetchWorkflowComment.mockResolvedValue({ ...baseCommentDetail(), id: commentB.id, @@ -383,7 +480,9 @@ describe('useWorkflowComment', () => { await result.current.handleCommentResolve(commentA.id) }) - expect(mockResolveWorkflowComment).toHaveBeenCalledWith('app-1', commentA.id) + expect(mockResolveWorkflowComment).toHaveBeenCalledWith({ + params: { appId: 'app-1', commentId: commentA.id }, + }) await act(async () => { await result.current.handleCommentReply(commentA.id, ' new reply ', ['user-2']) @@ -391,24 +490,154 @@ describe('useWorkflowComment', () => { await result.current.handleCommentReplyDelete(commentA.id, 'reply-1') }) - expect(mockCreateWorkflowCommentReply).toHaveBeenCalledWith('app-1', commentA.id, { - content: 'new reply', - mentioned_user_ids: ['user-2'], + expect(mockCreateWorkflowCommentReply).toHaveBeenCalledWith({ + params: { appId: 'app-1', commentId: commentA.id }, + body: { + content: 'new reply', + mentioned_user_ids: ['user-2'], + }, }) - expect(mockUpdateWorkflowCommentReply).toHaveBeenCalledWith('app-1', commentA.id, 'reply-1', { - content: 'edited reply', - mentioned_user_ids: ['user-2'], + expect(mockUpdateWorkflowCommentReply).toHaveBeenCalledWith({ + params: { appId: 'app-1', commentId: commentA.id, replyId: 'reply-1' }, + body: { + content: 'edited reply', + mentioned_user_ids: ['user-2'], + }, + }) + expect(mockDeleteWorkflowCommentReply).toHaveBeenCalledWith({ + params: { appId: 'app-1', commentId: commentA.id, replyId: 'reply-1' }, }) - expect(mockDeleteWorkflowCommentReply).toHaveBeenCalledWith('app-1', commentA.id, 'reply-1') await act(async () => { await result.current.handleCommentDelete(commentA.id) }) - expect(mockDeleteWorkflowComment).toHaveBeenCalledWith('app-1', commentA.id) + expect(mockDeleteWorkflowComment).toHaveBeenCalledWith({ + params: { appId: 'app-1', commentId: commentA.id }, + }) await waitFor(() => { expect(store.getState().activeCommentId).toBe(commentB.id) }) expect(mockEmitCommentsUpdate).toHaveBeenCalled() }) + + it('does not update a reply when the content is blank', async () => { + const { result } = renderWorkflowHook(() => useWorkflowComment(), { + queryClient: createSeededQueryClient(), + }) + + await act(async () => { + await result.current.handleCommentReplyUpdate('comment-1', 'reply-1', ' ') + }) + + expect(mockUpdateWorkflowCommentReply).not.toHaveBeenCalled() + }) + + it('resets reply submit loading when creation fails', async () => { + mockCreateWorkflowCommentReply.mockRejectedValueOnce(new Error('create reply failed')) + + const { result, store } = renderWorkflowHook(() => useWorkflowComment(), { + queryClient: createSeededQueryClient(), + initialStoreState: { + replySubmitting: false, + }, + }) + + await act(async () => { + await result.current.handleCommentReply('comment-1', 'new reply') + }) + + expect(mockCreateWorkflowCommentReply).toHaveBeenCalledWith({ + params: { appId: 'app-1', commentId: 'comment-1' }, + body: { + content: 'new reply', + mentioned_user_ids: [], + }, + }) + expect(store.getState().replySubmitting).toBe(false) + }) + + it('resets reply update loading when update fails', async () => { + mockUpdateWorkflowCommentReply.mockRejectedValueOnce(new Error('update failed')) + + const { result, store } = renderWorkflowHook(() => useWorkflowComment(), { + queryClient: createSeededQueryClient(), + initialStoreState: { + replyUpdating: false, + }, + }) + + await act(async () => { + await result.current.handleCommentReplyUpdate('comment-1', 'reply-1', 'updated reply') + }) + + expect(mockUpdateWorkflowCommentReply).toHaveBeenCalledWith({ + params: { appId: 'app-1', commentId: 'comment-1', replyId: 'reply-1' }, + body: { + content: 'updated reply', + mentioned_user_ids: [], + }, + }) + expect(store.getState().replyUpdating).toBe(false) + }) + + it('resets reply delete loading when deletion fails', async () => { + mockDeleteWorkflowCommentReply.mockRejectedValueOnce(new Error('delete failed')) + + const { result, store } = renderWorkflowHook(() => useWorkflowComment(), { + queryClient: createSeededQueryClient(), + initialStoreState: { + activeCommentDetailLoading: false, + }, + }) + + await act(async () => { + await result.current.handleCommentReplyDelete('comment-1', 'reply-1') + }) + + expect(mockDeleteWorkflowCommentReply).toHaveBeenCalledWith({ + params: { appId: 'app-1', commentId: 'comment-1', replyId: 'reply-1' }, + }) + expect(store.getState().activeCommentDetailLoading).toBe(false) + }) + + it('ignores navigation when no active comment or active comment is absent from the list', () => { + const { result } = renderWorkflowHook(() => useWorkflowComment(), { + queryClient: createSeededQueryClient(), + initialStoreState: { + comments: [baseComment()], + }, + }) + + act(() => { + result.current.handleCommentNavigate('next') + }) + + expect(mockSetCenter).not.toHaveBeenCalled() + + act(() => { + result.current.handleCommentIconClick({ ...baseComment(), id: 'missing-comment' }) + }) + + mockSetCenter.mockClear() + + act(() => { + result.current.handleCommentNavigate('next') + }) + + expect(mockSetCenter).not.toHaveBeenCalled() + }) + + it('clears a pending comment when comment mode is left outside quick add', () => { + const { store } = renderWorkflowHook(() => useWorkflowComment(), { + queryClient: createSeededQueryClient(), + initialStoreState: { + controlMode: ControlMode.Pointer, + isCommentQuickAdd: false, + pendingComment: { pageX: 1, pageY: 2, elementX: 3, elementY: 4 }, + }, + }) + + expect(store.getState().pendingComment).toBeNull() + }) }) diff --git a/web/app/components/workflow/hooks/use-workflow-comment.ts b/web/app/components/workflow/hooks/use-workflow-comment.ts index cdd14ceef1..54be5325e5 100644 --- a/web/app/components/workflow/hooks/use-workflow-comment.ts +++ b/web/app/components/workflow/hooks/use-workflow-comment.ts @@ -1,24 +1,34 @@ -import type { UserProfile, WorkflowCommentDetail, WorkflowCommentList } from '@/service/workflow-comment' +import type { UserProfile, WorkflowCommentDetail, WorkflowCommentList } from '@/contract/console/workflow-comment' import { useSuspenseQuery } from '@tanstack/react-query' -import { useCallback, useEffect, useRef } from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' import { useReactFlow } from 'reactflow' import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager' import { useAppContext } from '@/context/app-context' import { useParams } from '@/next/navigation' +import { consoleClient } from '@/service/client' import { systemFeaturesQueryOptions } from '@/service/system-features' -import { createWorkflowComment, createWorkflowCommentReply, deleteWorkflowComment, deleteWorkflowCommentReply, fetchWorkflowComment, fetchWorkflowComments, resolveWorkflowComment, updateWorkflowComment, updateWorkflowCommentReply } from '@/service/workflow-comment' import { useStore } from '../store' import { ControlMode } from '../types' const EMPTY_USERS: UserProfile[] = [] -type CommentDetailResponse = WorkflowCommentDetail | { data: WorkflowCommentDetail } -const getCommentDetail = (response: CommentDetailResponse): WorkflowCommentDetail => { - if ('data' in response) - return response.data - return response +const normalizeTimestamp = (value: number | string): number => { + if (typeof value === 'number') + return value + + const parsed = Number(value) + if (!Number.isNaN(parsed)) + return parsed + + return Math.floor(Date.parse(value) / 1000) } +const toCommentDetailPreview = (comment: WorkflowCommentList): WorkflowCommentDetail => ({ + ...comment, + replies: [], + mentions: [], +}) + export const useWorkflowComment = () => { const params = useParams() const appId = params.appId as string @@ -50,6 +60,10 @@ export const useWorkflowComment = () => { const mentionableUsers = useStore(state => ( appId ? state.mentionableUsersCache[appId] ?? EMPTY_USERS : EMPTY_USERS )) + const mentionableUserById = useMemo( + () => new Map(mentionableUsers.map(user => [user.id, user])), + [mentionableUsers], + ) const { userProfile } = useAppContext() const { data: isCollaborationEnabled } = useSuspenseQuery({ ...systemFeaturesQueryOptions(), @@ -70,8 +84,9 @@ export const useWorkflowComment = () => { if (!appId) return - const detailResponse = await fetchWorkflowComment(appId, commentId) as CommentDetailResponse - const detail = getCommentDetail(detailResponse) + const detail = await consoleClient.workflowComments.detail({ + params: { appId, commentId }, + }) commentDetailCacheRef.current = { ...commentDetailCacheRef.current, @@ -87,8 +102,10 @@ export const useWorkflowComment = () => { setCommentsLoading(true) try { - const commentsData = await fetchWorkflowComments(appId) - setComments(commentsData) + const response = await consoleClient.workflowComments.list({ + params: { appId }, + }) + setComments(response.data) } catch (error) { console.error('Failed to fetch comments:', error) @@ -133,17 +150,17 @@ export const useWorkflowComment = () => { y: pendingComment.pageY, }) - const newComment = await createWorkflowComment(appId, { - position_x: flowPosition.x, - position_y: flowPosition.y, - content, - mentioned_user_ids: mentionedUserIds, + const newComment = await consoleClient.workflowComments.create({ + params: { appId }, + body: { + position_x: flowPosition.x, + position_y: flowPosition.y, + content, + mentioned_user_ids: mentionedUserIds, + }, }) - const createdAt = Number(newComment.created_at) - const createdAtSeconds = Number.isNaN(createdAt) - ? Math.floor(Date.parse(newComment.created_at) / 1000) - : createdAt + const createdAtSeconds = normalizeTimestamp(newComment.created_at) const createdByAccount = { id: userProfile?.id ?? '', name: userProfile?.name ?? '', @@ -151,7 +168,7 @@ export const useWorkflowComment = () => { avatar_url: userProfile?.avatar_url || userProfile?.avatar || undefined, } const mentionedUsers = mentionedUserIds - .map(mentionedId => mentionableUsers.find(user => user.id === mentionedId)) + .map(mentionedId => mentionableUserById.get(mentionedId)) .filter((user): user is NonNullable => Boolean(user)) const uniqueParticipantsMap = new Map() if (createdByAccount.id) @@ -196,7 +213,7 @@ export const useWorkflowComment = () => { replies: [], mentions: mentionedUserIds.map(mentionedId => ({ mentioned_user_id: mentionedId, - mentioned_user_account: mentionableUsers.find(user => user.id === mentionedId) ?? null, + mentioned_user_account: mentionableUserById.get(mentionedId) ?? null, reply_id: null, })), } @@ -218,7 +235,7 @@ export const useWorkflowComment = () => { setPendingComment(null) setCommentQuickAdd(false) } - }, [appId, pendingComment, setPendingComment, setCommentQuickAdd, reactflow, comments, setComments, userProfile, setCommentDetailCache, mentionableUsers]) + }, [appId, pendingComment, setPendingComment, setCommentQuickAdd, reactflow, comments, setComments, userProfile, setCommentDetailCache, mentionableUserById]) const handleCommentCancel = useCallback(() => { setPendingComment(null) @@ -241,8 +258,8 @@ export const useWorkflowComment = () => { activeCommentIdRef.current = comment.id setActiveCommentId(comment.id) - const cachedDetail = commentDetailCacheRef.current[comment.id]! - setActiveComment(cachedDetail || comment) + const cachedDetail = commentDetailCacheRef.current[comment.id] + setActiveComment(cachedDetail ?? toCommentDetailPreview(comment)) const hasSelectedNode = reactflow.getNodes().some(node => node.data?.selected) const commentPanelWidth = controlMode === ControlMode.Comment ? 420 : 0 @@ -267,8 +284,9 @@ export const useWorkflowComment = () => { setActiveCommentLoading(!cachedDetail) try { - const detailResponse = await fetchWorkflowComment(appId, comment.id) as CommentDetailResponse - const detail = getCommentDetail(detailResponse) + const detail = await consoleClient.workflowComments.detail({ + params: { appId, commentId: comment.id }, + }) commentDetailCacheRef.current = { ...commentDetailCacheRef.current, @@ -304,7 +322,9 @@ export const useWorkflowComment = () => { setActiveCommentLoading(true) try { - await resolveWorkflowComment(appId, commentId) + await consoleClient.workflowComments.resolve({ + params: { appId, commentId }, + }) collaborationManager.emitCommentsUpdate(appId) @@ -325,7 +345,9 @@ export const useWorkflowComment = () => { setActiveCommentLoading(true) try { - await deleteWorkflowComment(appId, commentId) + await consoleClient.workflowComments.delete({ + params: { appId, commentId }, + }) collaborationManager.emitCommentsUpdate(appId) @@ -399,10 +421,13 @@ export const useWorkflowComment = () => { } try { - await updateWorkflowComment(appId, commentId, { - content: targetComment.content, - position_x: nextPosition.position_x, - position_y: nextPosition.position_y, + await consoleClient.workflowComments.update({ + params: { appId, commentId }, + body: { + content: targetComment.content, + position_x: nextPosition.position_x, + position_y: nextPosition.position_y, + }, }) collaborationManager.emitCommentsUpdate(appId) } @@ -443,11 +468,14 @@ export const useWorkflowComment = () => { return try { - await updateWorkflowComment(appId, commentId, { - content: trimmed, - position_x: positionX, - position_y: positionY, - mentioned_user_ids: mentionedUserIds, + await consoleClient.workflowComments.update({ + params: { appId, commentId }, + body: { + content: trimmed, + position_x: positionX, + position_y: positionY, + mentioned_user_ids: mentionedUserIds, + }, }) collaborationManager.emitCommentsUpdate(appId) @@ -469,7 +497,10 @@ export const useWorkflowComment = () => { setReplySubmitting(true) try { - await createWorkflowCommentReply(appId, commentId, { content: trimmed, mentioned_user_ids: mentionedUserIds }) + await consoleClient.workflowComments.replies.create({ + params: { appId, commentId }, + body: { content: trimmed, mentioned_user_ids: mentionedUserIds }, + }) collaborationManager.emitCommentsUpdate(appId) @@ -493,7 +524,10 @@ export const useWorkflowComment = () => { setReplyUpdating(true) try { - await updateWorkflowCommentReply(appId, commentId, replyId, { content: trimmed, mentioned_user_ids: mentionedUserIds }) + await consoleClient.workflowComments.replies.update({ + params: { appId, commentId, replyId }, + body: { content: trimmed, mentioned_user_ids: mentionedUserIds }, + }) collaborationManager.emitCommentsUpdate(appId) @@ -514,7 +548,9 @@ export const useWorkflowComment = () => { setActiveCommentLoading(true) try { - await deleteWorkflowCommentReply(appId, commentId, replyId) + await consoleClient.workflowComments.replies.delete({ + params: { appId, commentId, replyId }, + }) collaborationManager.emitCommentsUpdate(appId) diff --git a/web/app/components/workflow/nodes/tool/components/__tests__/input-var-list.spec.tsx b/web/app/components/workflow/nodes/tool/components/__tests__/input-var-list.spec.tsx index 990153d308..4ccf2b1061 100644 --- a/web/app/components/workflow/nodes/tool/components/__tests__/input-var-list.spec.tsx +++ b/web/app/components/workflow/nodes/tool/components/__tests__/input-var-list.spec.tsx @@ -116,23 +116,30 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () })) vi.mock('@/service/use-apps', () => ({ - useInfiniteAppList: () => ({ - data: { - pages: [{ - data: mockApps, - }], - }, - isLoading: false, - isFetchingNextPage: false, - fetchNextPage: mockFetchNextPage, - hasNextPage: false, - }), useAppDetail: (appId: string) => ({ data: mockApps.find(app => app.id === appId), isFetching: false, }), })) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useInfiniteQuery: () => ({ + data: { + pages: [{ + data: mockApps, + }], + }, + isLoading: false, + isFetchingNextPage: false, + fetchNextPage: mockFetchNextPage, + hasNextPage: false, + }), + } +}) + vi.mock('@/service/use-workflow', () => ({ useAppWorkflow: () => ({ data: undefined, diff --git a/web/app/components/workflow/panel/comments-panel/__tests__/index.spec.tsx b/web/app/components/workflow/panel/comments-panel/__tests__/index.spec.tsx index 49ae2a11a8..fd3f570f02 100644 --- a/web/app/components/workflow/panel/comments-panel/__tests__/index.spec.tsx +++ b/web/app/components/workflow/panel/comments-panel/__tests__/index.spec.tsx @@ -1,14 +1,12 @@ -import type { WorkflowCommentList } from '@/service/workflow-comment' +import type { WorkflowCommentList } from '@/contract/console/workflow-comment' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import CommentsPanel from '../index' const mockHandleCommentIconClick = vi.hoisted(() => vi.fn()) -const mockLoadComments = vi.hoisted(() => vi.fn()) +const mockHandleCommentResolve = vi.hoisted(() => vi.fn()) const mockSetActiveCommentId = vi.hoisted(() => vi.fn()) const mockSetControlMode = vi.hoisted(() => vi.fn()) const mockSetShowResolvedComments = vi.hoisted(() => vi.fn()) -const mockResolveWorkflowComment = vi.hoisted(() => vi.fn()) -const mockEmitCommentsUpdate = vi.hoisted(() => vi.fn()) const commentFixtures: WorkflowCommentList[] = [ { @@ -90,21 +88,11 @@ vi.mock('@/app/components/workflow/hooks/use-workflow-comment', () => ({ useWorkflowComment: () => ({ comments: commentFixtures, loading: false, - loadComments: (...args: unknown[]) => mockLoadComments(...args), handleCommentIconClick: (...args: unknown[]) => mockHandleCommentIconClick(...args), + handleCommentResolve: (...args: unknown[]) => mockHandleCommentResolve(...args), }), })) -vi.mock('@/service/workflow-comment', () => ({ - resolveWorkflowComment: (...args: unknown[]) => mockResolveWorkflowComment(...args), -})) - -vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({ - collaborationManager: { - emitCommentsUpdate: (...args: unknown[]) => mockEmitCommentsUpdate(...args), - }, -})) - vi.mock('@/app/components/base/user-avatar-list', () => ({ UserAvatarList: () =>
, })) @@ -122,8 +110,7 @@ describe('CommentsPanel', () => { vi.clearAllMocks() storeState.activeCommentId = null storeState.showResolvedComments = true - mockResolveWorkflowComment.mockResolvedValue({}) - mockLoadComments.mockResolvedValue(undefined) + mockHandleCommentResolve.mockResolvedValue(undefined) }) it('filters comments and selects a thread', () => { @@ -149,9 +136,7 @@ describe('CommentsPanel', () => { fireEvent.click(resolveIcons[0]!) await waitFor(() => { - expect(mockResolveWorkflowComment).toHaveBeenCalledWith('app-1', 'c-1') - expect(mockEmitCommentsUpdate).toHaveBeenCalledWith('app-1') - expect(mockLoadComments).toHaveBeenCalled() + expect(mockHandleCommentResolve).toHaveBeenCalledWith('c-1') expect(mockSetActiveCommentId).toHaveBeenCalledWith('c-1') }) }) diff --git a/web/app/components/workflow/panel/comments-panel/index.tsx b/web/app/components/workflow/panel/comments-panel/index.tsx index 05480abb2b..47f7c015ba 100644 --- a/web/app/components/workflow/panel/comments-panel/index.tsx +++ b/web/app/components/workflow/panel/comments-panel/index.tsx @@ -1,4 +1,4 @@ -import type { WorkflowCommentList } from '@/service/workflow-comment' +import type { WorkflowCommentList } from '@/contract/console/workflow-comment' import { cn } from '@langgenius/dify-ui/cn' import { Switch } from '@langgenius/dify-ui/switch' import { RiCheckboxCircleFill, RiCheckboxCircleLine, RiCheckLine, RiCloseLine, RiFilter3Line } from '@remixicon/react' @@ -6,14 +6,11 @@ import { memo, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import Divider from '@/app/components/base/divider' import { UserAvatarList } from '@/app/components/base/user-avatar-list' -import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager' import { useWorkflowComment } from '@/app/components/workflow/hooks/use-workflow-comment' import { useStore } from '@/app/components/workflow/store' import { ControlMode } from '@/app/components/workflow/types' import { useAppContext } from '@/context/app-context' import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' -import { useParams } from '@/next/navigation' -import { resolveWorkflowComment } from '@/service/workflow-comment' const CommentsPanel = () => { const { t } = useTranslation() @@ -22,9 +19,7 @@ const CommentsPanel = () => { const setControlMode = useStore(s => s.setControlMode) const showResolvedComments = useStore(s => s.showResolvedComments) const setShowResolvedComments = useStore(s => s.setShowResolvedComments) - const { comments, loading, loadComments, handleCommentIconClick } = useWorkflowComment() - const params = useParams() - const appId = params.appId as string + const { comments, loading, handleCommentIconClick, handleCommentResolve } = useWorkflowComment() const { formatTimeFromNow } = useFormatTimeFromNow() const [showOnlyMine, setShowOnlyMine] = useState(false) @@ -48,20 +43,14 @@ const CommentsPanel = () => { const handleResolve = useCallback(async (comment: WorkflowCommentList) => { if (comment.resolved) return - if (!appId) - return try { - await resolveWorkflowComment(appId, comment.id) - - collaborationManager.emitCommentsUpdate(appId) - - await loadComments() + await handleCommentResolve(comment.id) setActiveCommentId(comment.id) } catch (e) { console.error('Resolve comment failed', e) } - }, [appId, loadComments, setActiveCommentId]) + }, [handleCommentResolve, setActiveCommentId]) const hasActiveFilter = showOnlyMine || !showResolvedComments @@ -172,7 +161,7 @@ const CommentsPanel = () => { {/* Header row: creator + time */}
-
{c.created_by_account.name}
+
{c.created_by_account?.name ?? ''}
{formatTimeFromNow(c.updated_at * 1000)}
diff --git a/web/app/components/workflow/store/workflow/comment-slice.ts b/web/app/components/workflow/store/workflow/comment-slice.ts index cc7605c285..71d439fced 100644 --- a/web/app/components/workflow/store/workflow/comment-slice.ts +++ b/web/app/components/workflow/store/workflow/comment-slice.ts @@ -1,5 +1,5 @@ import type { StateCreator } from 'zustand' -import type { UserProfile, WorkflowCommentDetail, WorkflowCommentList } from '@/service/workflow-comment' +import type { UserProfile, WorkflowCommentDetail, WorkflowCommentList } from '@/contract/console/workflow-comment' export type CommentSliceShape = { comments: WorkflowCommentList[] diff --git a/web/contract/console/apps.ts b/web/contract/console/apps.ts index 2f5f16c25e..bd9e8ed06b 100644 --- a/web/contract/console/apps.ts +++ b/web/contract/console/apps.ts @@ -1,7 +1,27 @@ -import type { WorkflowOnlineUsersResponse } from '@/models/app' +import type { AppListResponse, WorkflowOnlineUsersResponse } from '@/models/app' +import type { AppModeEnum } from '@/types/app' import { type } from '@orpc/contract' import { base } from '../base' +export type AppListQuery = { + page?: number + limit?: number + name?: string + mode?: AppModeEnum + tag_ids?: string[] + is_created_by_me?: boolean +} + +export const appListContract = base + .route({ + path: '/apps', + method: 'GET', + }) + .input(type<{ + query?: AppListQuery + }>()) + .output(type()) + export const appDeleteContract = base .route({ path: '/apps/{appId}', diff --git a/web/contract/console/workflow-comment.ts b/web/contract/console/workflow-comment.ts index a4c55a46e0..216487f8c8 100644 --- a/web/contract/console/workflow-comment.ts +++ b/web/contract/console/workflow-comment.ts @@ -15,13 +15,13 @@ export type WorkflowCommentList = { position_y: number content: string created_by: string - created_by_account: UserProfile + created_by_account: UserProfile | null created_at: number updated_at: number resolved: boolean - resolved_by?: string - resolved_by_account?: UserProfile - resolved_at?: number + resolved_by?: string | null + resolved_by_account?: UserProfile | null + resolved_at?: number | null mention_count: number reply_count: number participants: UserProfile[] @@ -47,59 +47,59 @@ export type WorkflowCommentDetail = { position_y: number content: string created_by: string - created_by_account: UserProfile + created_by_account: UserProfile | null created_at: number updated_at: number resolved: boolean - resolved_by?: string - resolved_by_account?: UserProfile - resolved_at?: number + resolved_by?: string | null + resolved_by_account?: UserProfile | null + resolved_at?: number | null replies: WorkflowCommentDetailReply[] mentions: WorkflowCommentDetailMention[] } -export type WorkflowCommentCreateRes = { +type WorkflowCommentCreateRes = { id: string - created_at: string + created_at: number } -export type WorkflowCommentUpdateRes = { +type WorkflowCommentUpdateRes = { id: string - updated_at: string + updated_at: number } -export type WorkflowCommentResolveRes = { +type WorkflowCommentResolveRes = { id: string resolved: boolean resolved_by: string resolved_at: number } -export type WorkflowCommentReplyCreateRes = { +type WorkflowCommentReplyCreateRes = { id: string - created_at: string + created_at: number } -export type WorkflowCommentReplyUpdateRes = { +type WorkflowCommentReplyUpdateRes = { id: string - updated_at: string + updated_at: number } -export type CreateCommentParams = { +type CreateCommentParams = { position_x: number position_y: number content: string mentioned_user_ids?: string[] } -export type UpdateCommentParams = { +type UpdateCommentParams = { content: string position_x?: number position_y?: number mentioned_user_ids?: string[] } -export type CreateReplyParams = { +type CreateReplyParams = { content: string mentioned_user_ids?: string[] } diff --git a/web/contract/router.ts b/web/contract/router.ts index d45d3c000a..c1b4e1fa08 100644 --- a/web/contract/router.ts +++ b/web/contract/router.ts @@ -1,7 +1,7 @@ import type { InferContractRouterInputs } from '@orpc/contract' import { contract as enterpriseContract } from '@dify/contracts/enterprise/orpc.gen' import { accountAvatarContract } from './console/account' -import { appDeleteContract, workflowOnlineUsersContract } from './console/apps' +import { appDeleteContract, appListContract, workflowOnlineUsersContract } from './console/apps' import { bindPartnerStackContract, invoicesContract } from './console/billing' import { exploreAppDetailContract, @@ -61,6 +61,7 @@ export const consoleRouterContract = { }, systemFeatures: systemFeaturesContract, apps: { + list: appListContract, deleteApp: appDeleteContract, workflowOnlineUsers: workflowOnlineUsersContract, }, diff --git a/web/service/apps.ts b/web/service/apps.ts index 221e83cf39..d8e5e2136b 100644 --- a/web/service/apps.ts +++ b/web/service/apps.ts @@ -1,40 +1,13 @@ import type { TracingProvider } from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/type' -import type { AppDetailResponse, AppListResponse, CreateApiKeyResponse, DSLImportMode, DSLImportResponse, TracingConfig, TracingStatus, UpdateAppModelConfigResponse, UpdateAppSiteCodeResponse, WebhookTriggerResponse, WorkflowOnlineUser } from '@/models/app' +import type { AppDetailResponse, AppListResponse, CreateApiKeyResponse, DSLImportMode, DSLImportResponse, TracingConfig, TracingStatus, UpdateAppModelConfigResponse, UpdateAppSiteCodeResponse, WebhookTriggerResponse } from '@/models/app' import type { CommonResponse } from '@/models/common' import type { AppIconType, AppModeEnum, ModelConfig } from '@/types/app' import { del, get, patch, post, put } from './base' -import { consoleClient } from './client' export const fetchAppList = ({ url, params }: { url: string, params?: Record }): Promise => { return get(url, { params }) } -export const fetchWorkflowOnlineUsers = async ({ appIds }: { appIds: string[] }): Promise> => { - if (!appIds.length) - return {} - - const response = await consoleClient.apps.workflowOnlineUsers({ - body: { app_ids: appIds }, - }) - - if (!response?.data) - return {} - - if (Array.isArray(response.data)) { - return response.data.reduce>((acc, item) => { - if (item?.app_id) - acc[item.app_id] = item.users || [] - return acc - }, {}) - } - - return Object.entries(response.data).reduce>((acc, [appId, users]) => { - if (appId) - acc[appId] = users || [] - return acc - }, {}) -} - export const fetchAppDetail = ({ url, id }: { url: string, id: string }): Promise => { return get(`${url}/${id}`) } diff --git a/web/service/use-apps.ts b/web/service/use-apps.ts index f28df7bd4b..b09aa18f94 100644 --- a/web/service/use-apps.ts +++ b/web/service/use-apps.ts @@ -4,7 +4,6 @@ import type { AppDailyConversationsResponse, AppDailyEndUsersResponse, AppDailyMessagesResponse, - AppListResponse, AppStatisticsResponse, AppTokenCostsResponse, AppVoicesListResponse, @@ -12,66 +11,20 @@ import type { } from '@/models/app' import type { App } from '@/types/app' import { - keepPreviousData, - useInfiniteQuery, useMutation, useQuery, useQueryClient, } from '@tanstack/react-query' import { consoleClient, consoleQuery } from '@/service/client' -import { AppModeEnum } from '@/types/app' import { get, post } from './base' const NAME_SPACE = 'apps' -type AppListParams = { - page?: number - limit?: number - name?: string - mode?: AppModeEnum | 'all' - tag_ids?: string[] - is_created_by_me?: boolean -} - type DateRangeParams = { start?: string end?: string } -// Allowed app modes for filtering; defined at module scope to avoid re-creating on every call -const allowedModes = new Set([ - 'all', - AppModeEnum.WORKFLOW, - AppModeEnum.ADVANCED_CHAT, - AppModeEnum.CHAT, - AppModeEnum.AGENT_CHAT, - AppModeEnum.COMPLETION, -]) - -const normalizeAppListParams = (params: AppListParams) => { - const { - page = 1, - limit = 30, - name = '', - mode, - tag_ids, - is_created_by_me, - } = params - - const safeMode = allowedModes.has((mode as any)) ? mode : undefined - - return { - page, - limit, - name, - ...(safeMode && safeMode !== 'all' ? { mode: safeMode } : {}), - ...(tag_ids?.length ? { tag_ids } : {}), - ...(is_created_by_me ? { is_created_by_me } : {}), - } -} - -const appListKey = (params: AppListParams) => [NAME_SPACE, 'list', params] - const useAppFullListKey = [NAME_SPACE, 'full-list'] export const useGenerateRuleTemplate = (type: GeneratorType, disabled?: boolean) => { @@ -95,32 +48,11 @@ export const useAppDetail = (appID: string) => { }) } -export const useAppList = (params: AppListParams, options?: { enabled?: boolean }) => { - const normalizedParams = normalizeAppListParams(params) - return useQuery({ - queryKey: appListKey(normalizedParams), - queryFn: () => get('/apps', { params: normalizedParams }), - ...options, - }) -} - -export const useInfiniteAppList = (params: AppListParams, options?: { enabled?: boolean }) => { - const normalizedParams = normalizeAppListParams(params) - return useInfiniteQuery({ - queryKey: appListKey(normalizedParams), - queryFn: ({ pageParam = normalizedParams.page }) => get('/apps', { params: { ...normalizedParams, page: pageParam } }), - getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined, - initialPageParam: normalizedParams.page, - placeholderData: keepPreviousData, - ...options, - }) -} - export const useInvalidateAppList = () => { const queryClient = useQueryClient() return () => { queryClient.invalidateQueries({ - queryKey: [NAME_SPACE, 'list'], + queryKey: consoleQuery.apps.list.key(), }) } } @@ -138,7 +70,7 @@ export const useDeleteAppMutation = () => { onSuccess: async () => { await Promise.all([ queryClient.invalidateQueries({ - queryKey: [NAME_SPACE, 'list'], + queryKey: consoleQuery.apps.list.key(), }), queryClient.invalidateQueries({ queryKey: useAppFullListKey, diff --git a/web/service/workflow-comment.ts b/web/service/workflow-comment.ts deleted file mode 100644 index a8debbfd15..0000000000 --- a/web/service/workflow-comment.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { - CreateCommentParams as ContractCreateCommentParams, - CreateReplyParams as ContractCreateReplyParams, - UpdateCommentParams as ContractUpdateCommentParams, - UserProfile as ContractUserProfile, - WorkflowCommentCreateRes as ContractWorkflowCommentCreateRes, - WorkflowCommentDetail as ContractWorkflowCommentDetail, - WorkflowCommentDetailReply as ContractWorkflowCommentDetailReply, - WorkflowCommentList as ContractWorkflowCommentList, - WorkflowCommentReplyCreateRes as ContractWorkflowCommentReplyCreateRes, - WorkflowCommentReplyUpdateRes as ContractWorkflowCommentReplyUpdateRes, - WorkflowCommentResolveRes as ContractWorkflowCommentResolveRes, - WorkflowCommentUpdateRes as ContractWorkflowCommentUpdateRes, -} from '@/contract/console/workflow-comment' -import type { CommonResponse } from '@/models/common' -import { consoleClient } from './client' - -type CreateCommentParams = ContractCreateCommentParams -type CreateReplyParams = ContractCreateReplyParams -type UpdateCommentParams = ContractUpdateCommentParams -export type UserProfile = ContractUserProfile -type WorkflowCommentCreateRes = ContractWorkflowCommentCreateRes -export type WorkflowCommentDetail = ContractWorkflowCommentDetail -export type WorkflowCommentDetailReply = ContractWorkflowCommentDetailReply -export type WorkflowCommentList = ContractWorkflowCommentList -type WorkflowCommentReplyCreateRes = ContractWorkflowCommentReplyCreateRes -type WorkflowCommentReplyUpdateRes = ContractWorkflowCommentReplyUpdateRes -type WorkflowCommentResolveRes = ContractWorkflowCommentResolveRes -type WorkflowCommentUpdateRes = ContractWorkflowCommentUpdateRes - -export const fetchWorkflowComments = async (appId: string): Promise => { - const response = await consoleClient.workflowComments.list({ - params: { appId }, - }) - return response.data -} - -export const createWorkflowComment = async (appId: string, params: CreateCommentParams): Promise => { - return consoleClient.workflowComments.create({ - params: { appId }, - body: params, - }) -} - -export const fetchWorkflowComment = async (appId: string, commentId: string): Promise => { - return consoleClient.workflowComments.detail({ - params: { appId, commentId }, - }) -} - -export const updateWorkflowComment = async (appId: string, commentId: string, params: UpdateCommentParams): Promise => { - return consoleClient.workflowComments.update({ - params: { appId, commentId }, - body: params, - }) -} - -export const deleteWorkflowComment = async (appId: string, commentId: string): Promise => { - return consoleClient.workflowComments.delete({ - params: { appId, commentId }, - }) -} - -export const resolveWorkflowComment = async (appId: string, commentId: string): Promise => { - return consoleClient.workflowComments.resolve({ - params: { appId, commentId }, - }) -} - -export const createWorkflowCommentReply = async (appId: string, commentId: string, params: CreateReplyParams): Promise => { - return consoleClient.workflowComments.replies.create({ - params: { appId, commentId }, - body: params, - }) -} - -export const updateWorkflowCommentReply = async (appId: string, commentId: string, replyId: string, params: CreateReplyParams): Promise => { - return consoleClient.workflowComments.replies.update({ - params: { - appId, - commentId, - replyId, - }, - body: params, - }) -} - -export const deleteWorkflowCommentReply = async (appId: string, commentId: string, replyId: string): Promise => { - return consoleClient.workflowComments.replies.delete({ - params: { - appId, - commentId, - replyId, - }, - }) -} - -export const fetchMentionableUsers = async (appId: string) => { - const response = await consoleClient.workflowComments.mentionUsers({ - params: { appId }, - }) - return response.users -}