mirror of
https://github.com/langgenius/dify.git
synced 2026-05-09 12:59:18 +08:00
Merge branch 'main' into 4-27-app-deploy
This commit is contained in:
commit
58675e967f
4
.github/workflows/autofix.yml
vendored
4
.github/workflows/autofix.yml
vendored
@ -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: |
|
||||
|
||||
2
.github/workflows/translate-i18n-claude.yml
vendored
2
.github/workflows/translate-i18n-claude.yml
vendored
@ -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 }}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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__])
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
@ -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",
|
||||
@ -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",
|
||||
@ -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",
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 ====================
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
98
api/uv.lock
generated
98
api/uv.lock
generated
@ -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,29 +604,29 @@ 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]]
|
||||
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]
|
||||
@ -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" },
|
||||
@ -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" },
|
||||
@ -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" },
|
||||
@ -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" },
|
||||
@ -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]]
|
||||
@ -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]]
|
||||
@ -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]]
|
||||
@ -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]]
|
||||
|
||||
@ -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
|
||||
@ -197,21 +192,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 +336,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 +420,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 +430,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
|
||||
@ -693,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
|
||||
@ -1726,11 +1673,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
|
||||
@ -2014,11 +1956,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
|
||||
@ -2047,11 +1984,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 +2009,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 +2019,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 +2037,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 +2047,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 +2077,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 +2111,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 +2219,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 +2364,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 +2447,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 +2467,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/settings/summary-index-setting.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/develop/code.tsx": {
|
||||
"ts/no-empty-object-type": {
|
||||
"count": 1
|
||||
@ -2778,9 +2650,6 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/header/account-setting/key-validator/declarations.ts": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
@ -2942,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
|
||||
@ -3344,11 +3205,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
|
||||
@ -3935,11 +3791,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
|
||||
@ -3983,11 +3834,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
|
||||
@ -4049,9 +3895,6 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react/set-state-in-effect": {
|
||||
"count": 3
|
||||
},
|
||||
@ -4326,37 +4169,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
|
||||
@ -4381,11 +4193,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
|
||||
@ -4542,14 +4349,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
|
||||
@ -5423,21 +5222,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
|
||||
@ -5670,11 +5454,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
|
||||
|
||||
@ -2,7 +2,48 @@
|
||||
|
||||
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 +85,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 +95,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 +109,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 +122,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 +131,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 +139,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 +177,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 +189,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 +219,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 +253,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 +264,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 +275,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
|
||||
|
||||
@ -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/<model_id>", 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"})
|
||||
|
||||
|
||||
@ -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<typeof import('@tanstack/react-query')>()
|
||||
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,
|
||||
}))
|
||||
|
||||
@ -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<typeof import('@tanstack/react-query')>()
|
||||
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,
|
||||
}))
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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<App, 'icon' | 'icon_background' | 'icon_type' | 'icon_url'>
|
||||
return (
|
||||
<div className="flex px-3 py-1">
|
||||
<div className="mr-3">
|
||||
|
||||
@ -7,12 +7,6 @@ vi.mock('@/app/components/base/icons/src/vender/workflow', () => ({
|
||||
WindowCursor: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="webapp-icon" {...props} />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/tooltip', () => ({
|
||||
default: ({ popupContent }: { popupContent: React.ReactNode }) => (
|
||||
<div data-testid="tooltip">{popupContent}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../base/app-icon', () => ({
|
||||
default: ({ icon, background, innerIcon, className }: {
|
||||
icon?: string
|
||||
@ -75,13 +69,12 @@ describe('AppBasic', () => {
|
||||
|
||||
it('should show hover tip when provided', () => {
|
||||
render(<AppBasic name="My App" type="Chatbot" hoverTip="Some tip" />)
|
||||
expect(screen.getByTestId('tooltip')).toBeInTheDocument()
|
||||
expect(screen.getByText('Some tip')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Some tip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show hover tip when not provided', () => {
|
||||
render(<AppBasic name="My App" type="Chatbot" />)
|
||||
expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('Some tip')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ import {
|
||||
ApiAggregate,
|
||||
WindowCursor,
|
||||
} from '@/app/components/base/icons/src/vender/workflow'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import AppIcon from '../base/app-icon'
|
||||
|
||||
type IAppBasicProps = {
|
||||
@ -82,16 +82,9 @@ export default function AppBasic({ icon, icon_background, name, isExternal, type
|
||||
</div>
|
||||
{hoverTip
|
||||
&& (
|
||||
<Tooltip
|
||||
popupContent={(
|
||||
<div className="w-[240px]">
|
||||
{hoverTip}
|
||||
</div>
|
||||
)}
|
||||
popupClassName="ml-1"
|
||||
triggerClassName="w-4 h-4 ml-1"
|
||||
position="top"
|
||||
/>
|
||||
<Infotip aria-label={hoverTip} className="ml-1" popupClassName="w-[240px]">
|
||||
{hoverTip}
|
||||
</Infotip>
|
||||
)}
|
||||
</div>
|
||||
{!hideType && isExtraInLine && (
|
||||
|
||||
@ -1,20 +1,20 @@
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Tooltip from '../base/tooltip'
|
||||
import ShortcutsName from '../workflow/shortcuts-name'
|
||||
|
||||
type TooltipContentProps = {
|
||||
type ToggleTooltipContentProps = {
|
||||
expand: boolean
|
||||
}
|
||||
|
||||
const TOGGLE_SHORTCUT = ['ctrl', 'B']
|
||||
|
||||
const TooltipContent = ({
|
||||
const ToggleTooltipContent = ({
|
||||
expand,
|
||||
}: TooltipContentProps) => {
|
||||
}: ToggleTooltipContentProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
@ -37,22 +37,21 @@ const ToggleButton = ({
|
||||
className,
|
||||
}: ToggleButtonProps) => {
|
||||
return (
|
||||
<Tooltip
|
||||
popupContent={<TooltipContent expand={expand} />}
|
||||
popupClassName="p-1.5 rounded-lg"
|
||||
position="right"
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={handleToggle}
|
||||
className={cn('rounded-full px-1', className)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<Button
|
||||
size="small"
|
||||
onClick={handleToggle}
|
||||
className={cn('rounded-full px-1', className)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{
|
||||
expand
|
||||
? <RiArrowLeftSLine className="size-4" />
|
||||
: <RiArrowRightSLine className="size-4" />
|
||||
}
|
||||
</Button>
|
||||
{expand ? <RiArrowLeftSLine className="size-4" /> : <RiArrowRightSLine className="size-4" />}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent placement="right" className="rounded-lg p-1.5">
|
||||
<ToggleTooltipContent expand={expand} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@ import { useContext } from 'use-context-selector'
|
||||
// import { Resolution } from '@/types/app'
|
||||
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
import { Vision } from '@/app/components/base/icons/src/vender/features'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
// import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card'
|
||||
@ -70,13 +70,12 @@ const ConfigVision: FC = () => {
|
||||
</div>
|
||||
<div className="flex grow items-center">
|
||||
<div className="mr-1 system-sm-semibold text-text-secondary">{t('vision.name', { ns: 'appDebug' })}</div>
|
||||
<Tooltip
|
||||
popupContent={(
|
||||
<div className="w-[180px]">
|
||||
{t('vision.description', { ns: 'appDebug' })}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Infotip
|
||||
aria-label={t('vision.description', { ns: 'appDebug' })}
|
||||
popupClassName="w-[180px]"
|
||||
>
|
||||
{t('vision.description', { ns: 'appDebug' })}
|
||||
</Infotip>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center">
|
||||
{readonly
|
||||
@ -84,15 +83,14 @@ const ConfigVision: FC = () => {
|
||||
<>
|
||||
<div className="mr-2 flex items-center gap-0.5">
|
||||
<div className="system-xs-medium-uppercase text-text-tertiary">{t('vision.visionSettings.resolution', { ns: 'appDebug' })}</div>
|
||||
<Tooltip
|
||||
popupContent={(
|
||||
<div className="w-[180px]">
|
||||
{t('vision.visionSettings.resolutionTooltip', { ns: 'appDebug' }).split('\n').map(item => (
|
||||
<div key={item}>{item}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Infotip
|
||||
aria-label={t('vision.visionSettings.resolutionTooltip', { ns: 'appDebug' })}
|
||||
popupClassName="w-[180px]"
|
||||
>
|
||||
{t('vision.visionSettings.resolutionTooltip', { ns: 'appDebug' }).split('\n').map(item => (
|
||||
<div key={item}>{item}</div>
|
||||
))}
|
||||
</Infotip>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<OptionCard
|
||||
|
||||
@ -6,8 +6,8 @@ import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import ParamItem from '@/app/components/base/param-item'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card'
|
||||
import { Resolution, TransferMethod } from '@/types/app'
|
||||
|
||||
@ -47,15 +47,14 @@ const ParamConfigContent: FC = () => {
|
||||
<div>
|
||||
<div className="mb-2 flex items-center space-x-1">
|
||||
<div className="text-[13px] leading-[18px] font-semibold text-text-secondary">{t('vision.visionSettings.resolution', { ns: 'appDebug' })}</div>
|
||||
<Tooltip
|
||||
popupContent={(
|
||||
<div className="w-[180px]">
|
||||
{t('vision.visionSettings.resolutionTooltip', { ns: 'appDebug' }).split('\n').map(item => (
|
||||
<div key={item}>{item}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Infotip
|
||||
aria-label={t('vision.visionSettings.resolutionTooltip', { ns: 'appDebug' })}
|
||||
popupClassName="w-[180px]"
|
||||
>
|
||||
{t('vision.visionSettings.resolutionTooltip', { ns: 'appDebug' }).split('\n').map(item => (
|
||||
<div key={item}>{item}</div>
|
||||
))}
|
||||
</Infotip>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<OptionCard
|
||||
|
||||
@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
import { Microphone01 } from '@/app/components/base/icons/src/vender/features'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import ConfigContext from '@/context/debug-configuration'
|
||||
|
||||
@ -57,13 +57,12 @@ const ConfigAudio: FC = () => {
|
||||
</div>
|
||||
<div className="flex grow items-center">
|
||||
<div className="mr-1 system-sm-semibold text-text-secondary">{t('feature.audioUpload.title', { ns: 'appDebug' })}</div>
|
||||
<Tooltip
|
||||
popupContent={(
|
||||
<div className="w-[180px]">
|
||||
{t('feature.audioUpload.description', { ns: 'appDebug' })}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Infotip
|
||||
aria-label={t('feature.audioUpload.description', { ns: 'appDebug' })}
|
||||
popupClassName="w-[180px]"
|
||||
>
|
||||
{t('feature.audioUpload.description', { ns: 'appDebug' })}
|
||||
</Infotip>
|
||||
</div>
|
||||
{!readonly && (
|
||||
<div className="flex shrink-0 items-center">
|
||||
|
||||
@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
import { Document } from '@/app/components/base/icons/src/vender/features'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import ConfigContext from '@/context/debug-configuration'
|
||||
|
||||
@ -57,13 +57,12 @@ const ConfigDocument: FC = () => {
|
||||
</div>
|
||||
<div className="flex grow items-center">
|
||||
<div className="mr-1 system-sm-semibold text-text-secondary">{t('feature.documentUpload.title', { ns: 'appDebug' })}</div>
|
||||
<Tooltip
|
||||
popupContent={(
|
||||
<div className="w-[180px]">
|
||||
{t('feature.documentUpload.description', { ns: 'appDebug' })}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Infotip
|
||||
aria-label={t('feature.documentUpload.description', { ns: 'appDebug' })}
|
||||
popupClassName="w-[180px]"
|
||||
>
|
||||
{t('feature.documentUpload.description', { ns: 'appDebug' })}
|
||||
</Infotip>
|
||||
</div>
|
||||
{!readonly && (
|
||||
<div className="flex shrink-0 items-center">
|
||||
|
||||
@ -275,7 +275,7 @@ describe('ContextVar', () => {
|
||||
// Act
|
||||
render(<ContextVar {...props} />)
|
||||
|
||||
const varPickerTrigger = screen.getByTestId('popover-trigger')
|
||||
const varPickerTrigger = screen.getAllByTestId('popover-trigger').at(-1)!
|
||||
|
||||
await user.click(varPickerTrigger!)
|
||||
expect(screen.getByTestId('popover-content'))!.toBeInTheDocument()
|
||||
@ -296,7 +296,7 @@ describe('ContextVar', () => {
|
||||
// Act
|
||||
render(<ContextVar {...props} />)
|
||||
|
||||
const varPickerTrigger = screen.getByTestId('popover-trigger')
|
||||
const varPickerTrigger = screen.getAllByTestId('popover-trigger').at(-1)!
|
||||
|
||||
// Open dropdown
|
||||
await user.click(varPickerTrigger!)
|
||||
|
||||
@ -5,7 +5,7 @@ import { cn } from '@langgenius/dify-ui/cn'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { BracketsX } from '@/app/components/base/icons/src/vender/line/development'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import VarPicker from './var-picker'
|
||||
|
||||
const ContextVar: FC<Props> = (props) => {
|
||||
@ -20,13 +20,12 @@ const ContextVar: FC<Props> = (props) => {
|
||||
<BracketsX className="h-4 w-4 text-text-accent" />
|
||||
</div>
|
||||
<div className="mr-1 text-sm font-medium text-text-secondary">{t('feature.dataSet.queryVariable.title', { ns: 'appDebug' })}</div>
|
||||
<Tooltip
|
||||
popupContent={(
|
||||
<div className="w-[180px]">
|
||||
{t('feature.dataSet.queryVariable.tip', { ns: 'appDebug' })}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Infotip
|
||||
aria-label={t('feature.dataSet.queryVariable.tip', { ns: 'appDebug' })}
|
||||
popupClassName="w-[180px]"
|
||||
>
|
||||
{t('feature.dataSet.queryVariable.tip', { ns: 'appDebug' })}
|
||||
</Infotip>
|
||||
</div>
|
||||
|
||||
<VarPicker {...props} />
|
||||
|
||||
@ -15,9 +15,9 @@ import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { memo, useCallback, useEffect, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import ScoreThresholdItem from '@/app/components/base/param-item/score-threshold-item'
|
||||
import TopKItem from '@/app/components/base/param-item/top-k-item'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useCurrentProviderAndModel, useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
|
||||
@ -244,15 +244,14 @@ const ConfigContent: FC<Props> = ({
|
||||
onClick={() => handleRerankModeChange(option.value)}
|
||||
>
|
||||
<div className="truncate">{option.label}</div>
|
||||
<Tooltip
|
||||
popupContent={(
|
||||
<div className="w-[200px]">
|
||||
{option.tips}
|
||||
</div>
|
||||
)}
|
||||
popupClassName="ml-0.5"
|
||||
triggerClassName="ml-0.5 w-3.5 h-3.5"
|
||||
/>
|
||||
<Infotip
|
||||
aria-label={option.tips}
|
||||
className="ml-0.5 h-3.5 w-3.5"
|
||||
iconClassName="h-3.5 w-3.5"
|
||||
popupClassName="w-[200px]"
|
||||
>
|
||||
{option.tips}
|
||||
</Infotip>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
@ -273,15 +272,13 @@ const ConfigContent: FC<Props> = ({
|
||||
)
|
||||
}
|
||||
<div className="ml-1 system-sm-semibold leading-[32px] text-text-secondary">{t('modelProvider.rerankModel.key', { ns: 'common' })}</div>
|
||||
<Tooltip
|
||||
popupContent={(
|
||||
<div className="w-[200px]">
|
||||
{t('modelProvider.rerankModel.tip', { ns: 'common' })}
|
||||
</div>
|
||||
)}
|
||||
popupClassName="ml-1"
|
||||
triggerClassName="ml-1 w-4 h-4"
|
||||
/>
|
||||
<Infotip
|
||||
aria-label={t('modelProvider.rerankModel.tip', { ns: 'common' })}
|
||||
className="ml-1"
|
||||
popupClassName="w-[200px]"
|
||||
>
|
||||
{t('modelProvider.rerankModel.tip', { ns: 'common' })}
|
||||
</Infotip>
|
||||
</div>
|
||||
{
|
||||
showRerankModel && (
|
||||
@ -363,9 +360,9 @@ const ConfigContent: FC<Props> = ({
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center space-x-0.5">
|
||||
<div className="text-[13px] leading-[32px] font-medium text-text-primary">{t('modelProvider.systemReasoningModel.key', { ns: 'common' })}</div>
|
||||
<Tooltip
|
||||
popupContent={t('modelProvider.systemReasoningModel.tip', { ns: 'common' })}
|
||||
/>
|
||||
<Infotip aria-label={t('modelProvider.systemReasoningModel.tip', { ns: 'common' })}>
|
||||
{t('modelProvider.systemReasoningModel.tip', { ns: 'common' })}
|
||||
</Infotip>
|
||||
</div>
|
||||
<ModelParameterModal
|
||||
isInWorkflow={isInWorkflow}
|
||||
|
||||
@ -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<typeof import('@tanstack/react-query')>()
|
||||
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(<SystemFeaturesWrapper><List /></SystemFeaturesWrapper>, { searchParams })
|
||||
}
|
||||
|
||||
type AppListInfiniteOptions = {
|
||||
input: (pageParam: number) => { query: Record<string, unknown> }
|
||||
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()
|
||||
|
||||
49
web/app/components/apps/hooks/use-workflow-online-users.ts
Normal file
49
web/app/components/apps/hooks/use-workflow-online-users.ts
Normal file
@ -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<string, WorkflowOnlineUser[]>
|
||||
|
||||
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<WorkflowOnlineUsersMap>((acc, item) => {
|
||||
if (item?.app_id)
|
||||
acc[item.app_id] = item.users || []
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
return Object.entries(data).reduce<WorkflowOnlineUsersMap>((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,
|
||||
}
|
||||
}
|
||||
@ -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<Props> = ({
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false)
|
||||
const [droppedDSLFile, setDroppedDSLFile] = useState<File | undefined>()
|
||||
const [workflowOnlineUsersMap, setWorkflowOnlineUsersMap] = useState<Record<string, WorkflowOnlineUser[]>>({})
|
||||
const setKeywords = useCallback((keywords: string) => {
|
||||
setQuery(prev => ({ ...prev, keywords }))
|
||||
}, [setQuery])
|
||||
@ -90,14 +89,14 @@ const List: FC<Props> = ({
|
||||
enabled: isCurrentWorkspaceEditor,
|
||||
})
|
||||
|
||||
const appListQueryParams = {
|
||||
const appListQuery = useMemo<AppListQuery>(() => ({
|
||||
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<Props> = ({
|
||||
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<HTMLDivElement>(null)
|
||||
const options = [
|
||||
@ -187,52 +199,23 @@ const List: FC<Props> = ({
|
||||
}, [isCreatedByMe, setQuery])
|
||||
|
||||
const pages = useMemo(() => data?.pages ?? [], [data?.pages])
|
||||
const appIds = useMemo(() => {
|
||||
const ids = new Set<string>()
|
||||
pages.forEach((page) => {
|
||||
page.data?.forEach((app) => {
|
||||
if (app.id)
|
||||
ids.add(app.id)
|
||||
})
|
||||
const apps = useMemo(() => pages.flatMap(({ data: pageApps }) => pageApps), [pages])
|
||||
|
||||
const workflowOnlineUserAppIds = useMemo(() => {
|
||||
const appIds = new Set<string>()
|
||||
apps.forEach((app) => {
|
||||
if (app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT)
|
||||
appIds.add(app.id)
|
||||
})
|
||||
return Array.from(ids)
|
||||
}, [pages])
|
||||
return Array.from(appIds)
|
||||
}, [apps])
|
||||
|
||||
const refreshWorkflowOnlineUsers = useCallback(async () => {
|
||||
if (!systemFeatures.enable_collaboration_mode) {
|
||||
setWorkflowOnlineUsersMap({})
|
||||
return
|
||||
}
|
||||
|
||||
if (!appIds.length) {
|
||||
setWorkflowOnlineUsersMap({})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const onlineUsersMap = await fetchWorkflowOnlineUsers({ appIds })
|
||||
setWorkflowOnlineUsersMap(onlineUsersMap)
|
||||
}
|
||||
catch {
|
||||
setWorkflowOnlineUsersMap({})
|
||||
}
|
||||
}, [appIds, systemFeatures.enable_collaboration_mode])
|
||||
|
||||
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
|
||||
@ -287,24 +270,18 @@ const List: FC<Props> = ({
|
||||
className={cn(!hasAnyApp && 'z-10')}
|
||||
/>
|
||||
)}
|
||||
{(() => {
|
||||
if (showSkeleton)
|
||||
return <AppCardSkeleton count={6} />
|
||||
|
||||
if (hasAnyApp) {
|
||||
return pages.flatMap(({ data: apps }) => apps).map(app => (
|
||||
<AppCard
|
||||
key={app.id}
|
||||
app={app}
|
||||
onlineUsers={workflowOnlineUsersMap[app.id] ?? []}
|
||||
onRefresh={refetch}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
// No apps - show empty state
|
||||
return <Empty />
|
||||
})()}
|
||||
{showSkeleton
|
||||
? <AppCardSkeleton count={6} />
|
||||
: hasAnyApp
|
||||
? apps.map(app => (
|
||||
<AppCard
|
||||
key={app.id}
|
||||
app={app}
|
||||
onlineUsers={workflowOnlineUsersMap[app.id] ?? []}
|
||||
onRefresh={refetch}
|
||||
/>
|
||||
))
|
||||
: <Empty />}
|
||||
{isFetchingNextPage && (
|
||||
<AppCardSkeleton count={3} />
|
||||
)}
|
||||
|
||||
@ -48,24 +48,21 @@ describe('ThemeSelector', () => {
|
||||
it('should call setTheme with light when light option is clicked', () => {
|
||||
render(<ThemeSelector />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
const lightButton = screen.getByText(/light/i).closest('button')!
|
||||
fireEvent.click(lightButton)
|
||||
fireEvent.click(screen.getByText(/light/i))
|
||||
expect(mockSetTheme).toHaveBeenCalledWith('light')
|
||||
})
|
||||
|
||||
it('should call setTheme with dark when dark option is clicked', () => {
|
||||
render(<ThemeSelector />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
const darkButton = screen.getByText(/dark/i).closest('button')!
|
||||
fireEvent.click(darkButton)
|
||||
fireEvent.click(screen.getByText(/dark/i))
|
||||
expect(mockSetTheme).toHaveBeenCalledWith('dark')
|
||||
})
|
||||
|
||||
it('should call setTheme with system when system option is clicked', () => {
|
||||
render(<ThemeSelector />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
const systemButton = screen.getByText(/auto/i).closest('button')!
|
||||
fireEvent.click(systemButton)
|
||||
fireEvent.click(screen.getByText(/auto/i))
|
||||
expect(mockSetTheme).toHaveBeenCalledWith('system')
|
||||
})
|
||||
})
|
||||
|
||||
@ -8,6 +8,8 @@ type CheckboxProps = {
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
indeterminate?: boolean
|
||||
ariaLabel?: string
|
||||
ariaLabelledBy?: string
|
||||
}
|
||||
|
||||
const Checkbox = ({
|
||||
@ -17,6 +19,8 @@ const Checkbox = ({
|
||||
className,
|
||||
disabled,
|
||||
indeterminate,
|
||||
ariaLabel,
|
||||
ariaLabelledBy,
|
||||
}: CheckboxProps) => {
|
||||
const checkClassName = (checked || indeterminate)
|
||||
? 'bg-components-checkbox-bg text-components-checkbox-icon hover:bg-components-checkbox-bg-hover'
|
||||
@ -52,6 +56,8 @@ const Checkbox = ({
|
||||
role="checkbox"
|
||||
aria-checked={indeterminate ? 'mixed' : !!checked}
|
||||
aria-disabled={!!disabled}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
>
|
||||
{!checked && indeterminate && <IndeterminateIcon />}
|
||||
|
||||
@ -27,17 +27,15 @@ describe('ParamItem', () => {
|
||||
})
|
||||
|
||||
it('should render a tooltip trigger by default', () => {
|
||||
const { container } = render(<ParamItem {...defaultProps} tip="Some tip text" />)
|
||||
render(<ParamItem {...defaultProps} tip="Some tip text" />)
|
||||
|
||||
// Tooltip trigger icon should be rendered (the data-state div)
|
||||
expect(container.querySelector('[data-state]')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Some tip text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render tooltip trigger when noTooltip is true', () => {
|
||||
const { container } = render(<ParamItem {...defaultProps} noTooltip tip="Hidden tip" />)
|
||||
render(<ParamItem {...defaultProps} noTooltip tip="Hidden tip" />)
|
||||
|
||||
// No tooltip trigger icon should be rendered
|
||||
expect(container.querySelector('[data-state]')).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('Hidden tip')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a switch when hasSwitch is true', () => {
|
||||
|
||||
@ -24,10 +24,9 @@ describe('ScoreThresholdItem', () => {
|
||||
})
|
||||
|
||||
it('should render tooltip trigger', () => {
|
||||
const { container } = render(<ScoreThresholdItem {...defaultProps} />)
|
||||
render(<ScoreThresholdItem {...defaultProps} />)
|
||||
|
||||
// Tooltip trigger icon should be rendered
|
||||
expect(container.querySelector('[data-state]')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('appDebug.datasetConfig.score_thresholdTip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render InputNumber and Slider', () => {
|
||||
|
||||
@ -29,10 +29,9 @@ describe('TopKItem', () => {
|
||||
})
|
||||
|
||||
it('should render tooltip trigger', () => {
|
||||
const { container } = render(<TopKItem {...defaultProps} />)
|
||||
render(<TopKItem {...defaultProps} />)
|
||||
|
||||
// Tooltip trigger icon should be rendered
|
||||
expect(container.querySelector('[data-state]')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('appDebug.datasetConfig.top_kTip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render InputNumber and Slider', () => {
|
||||
|
||||
@ -10,7 +10,7 @@ import {
|
||||
} from '@langgenius/dify-ui/number-field'
|
||||
import { Slider } from '@langgenius/dify-ui/slider'
|
||||
import { Switch } from '@langgenius/dify-ui/switch'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
@ -44,11 +44,10 @@ const ParamItem: FC<Props> = ({ className, id, name, noTooltip, tip, step = 0.1,
|
||||
/>
|
||||
)}
|
||||
<span className="mr-1 system-sm-semibold text-text-secondary">{name}</span>
|
||||
{!noTooltip && (
|
||||
<Tooltip
|
||||
triggerClassName="w-4 h-4 shrink-0"
|
||||
popupContent={<div className="w-[200px]">{tip}</div>}
|
||||
/>
|
||||
{!noTooltip && tip && (
|
||||
<Infotip aria-label={tip} popupClassName="w-[200px]">
|
||||
{tip}
|
||||
</Infotip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,25 +1,25 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuRadioItemIndicator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
|
||||
export type Theme = 'light' | 'dark' | 'system'
|
||||
|
||||
export default function ThemeSelector() {
|
||||
const { t } = useTranslation()
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const handleThemeChange = (newTheme: Theme) => {
|
||||
setTheme(newTheme)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const getCurrentIcon = () => {
|
||||
@ -31,70 +31,36 @@ export default function ThemeSelector() {
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-end"
|
||||
offset={{ mainAxis: 6 }}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => setOpen(!open)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={(
|
||||
<ActionButton
|
||||
aria-label={t('theme.theme', { ns: 'common' })}
|
||||
className="h-8 w-8 p-[6px] data-popup-open:bg-state-base-hover"
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<ActionButton
|
||||
className={`h-8 w-8 p-[6px] ${open && 'bg-state-base-hover'}`}
|
||||
>
|
||||
{getCurrentIcon()}
|
||||
</ActionButton>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-1000">
|
||||
<div className="flex w-[144px] flex-col items-start rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-1 rounded-lg px-2 py-1.5 text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => handleThemeChange('light')}
|
||||
>
|
||||
{getCurrentIcon()}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent placement="bottom-end" sideOffset={6} popupClassName="w-[144px]">
|
||||
<DropdownMenuRadioGroup value={theme || 'system'} onValueChange={value => handleThemeChange(value as Theme)}>
|
||||
<DropdownMenuRadioItem value="light" closeOnClick>
|
||||
<span className="i-ri-sun-line h-4 w-4 text-text-tertiary" />
|
||||
<div className="flex grow items-center justify-start px-1">
|
||||
<span className="system-md-regular">{t('theme.light', { ns: 'common' })}</span>
|
||||
</div>
|
||||
{theme === 'light' && (
|
||||
<div className="flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
<span className="i-ri-check-line h-4 w-4 text-text-accent" data-testid="light-icon" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-1 rounded-lg px-2 py-1.5 text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => handleThemeChange('dark')}
|
||||
>
|
||||
<span className="grow px-1 system-md-regular">{t('theme.light', { ns: 'common' })}</span>
|
||||
<DropdownMenuRadioItemIndicator data-testid="light-icon" />
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="dark" closeOnClick>
|
||||
<span className="i-ri-moon-line h-4 w-4 text-text-tertiary" />
|
||||
<div className="flex grow items-center justify-start px-1">
|
||||
<span className="system-md-regular">{t('theme.dark', { ns: 'common' })}</span>
|
||||
</div>
|
||||
{theme === 'dark' && (
|
||||
<div className="flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
<span className="i-ri-check-line h-4 w-4 text-text-accent" data-testid="dark-icon" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-1 rounded-lg px-2 py-1.5 text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => handleThemeChange('system')}
|
||||
>
|
||||
<span className="grow px-1 system-md-regular">{t('theme.dark', { ns: 'common' })}</span>
|
||||
<DropdownMenuRadioItemIndicator data-testid="dark-icon" />
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="system" closeOnClick>
|
||||
<span className="i-ri-computer-line h-4 w-4 text-text-tertiary" />
|
||||
<div className="flex grow items-center justify-start px-1">
|
||||
<span className="system-md-regular">{t('theme.auto', { ns: 'common' })}</span>
|
||||
</div>
|
||||
{theme === 'system' && (
|
||||
<div className="flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
<span className="i-ri-check-line h-4 w-4 text-text-accent" data-testid="system-icon" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
<span className="grow px-1 system-md-regular">{t('theme.auto', { ns: 'common' })}</span>
|
||||
<DropdownMenuRadioItemIndicator data-testid="system-icon" />
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
75
web/app/components/base/upgrade-modal/index.tsx
Normal file
75
web/app/components/base/upgrade-modal/index.tsx
Normal file
@ -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 (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<DialogContent className={cn(styles.surface, 'w-[580px] max-w-[480px] overflow-hidden rounded-2xl p-0', classNames?.content)}>
|
||||
<div className="relative">
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(styles.heroOverlay, 'pointer-events-none absolute inset-0', classNames?.heroOverlay)}
|
||||
/>
|
||||
<div className={cn('px-8 pt-8', classNames?.body)}>
|
||||
{Icon && (
|
||||
<div className={cn(styles.icon, 'flex size-12 items-center justify-center rounded-xl shadow-lg backdrop-blur-[5px]', classNames?.icon)}>
|
||||
<Icon className="size-6 text-text-primary-on-surface" />
|
||||
</div>
|
||||
)}
|
||||
<div className={cn('mt-6 space-y-2', classNames?.copy)}>
|
||||
<DialogTitle className={cn(styles.highlight, 'title-3xl-semi-bold', classNames?.title)}>
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<DialogDescription className={cn('system-md-regular text-text-tertiary', classNames?.description)}>
|
||||
{description}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
{extraInfo}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn('mt-10 mb-8 flex justify-end space-x-2 px-8', classNames?.footer)}>
|
||||
{footer}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@ -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 ? <div data-testid="plan-upgrade-modal">{children}</div> : 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()
|
||||
|
||||
@ -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<React.SVGProps<SVGSVGElement>>
|
||||
Icon?: ComponentType<{ className?: string }>
|
||||
title: string
|
||||
description: string
|
||||
extraInfo?: React.ReactNode
|
||||
extraInfo?: ReactNode
|
||||
show: boolean
|
||||
onClose: () => void
|
||||
onUpgrade?: () => void
|
||||
}
|
||||
|
||||
const PlanUpgradeModal: FC<Props> = ({
|
||||
export function PlanUpgradeModal({
|
||||
Icon = SquareChecklist,
|
||||
title,
|
||||
description,
|
||||
@ -28,7 +26,7 @@ const PlanUpgradeModal: FC<Props> = ({
|
||||
show,
|
||||
onClose,
|
||||
onUpgrade,
|
||||
}) => {
|
||||
}: Props) {
|
||||
const { t } = useTranslation()
|
||||
const { setShowPricingModal } = useModalContext()
|
||||
|
||||
@ -41,51 +39,30 @@ const PlanUpgradeModal: FC<Props> = ({
|
||||
}, [onClose, onUpgrade, setShowPricingModal])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isShow={show}
|
||||
onClose={onClose}
|
||||
closable={false}
|
||||
clickOutsideNotClose
|
||||
className={`${styles.surface} w-[580px] rounded-2xl p-0!`}
|
||||
>
|
||||
<div className="relative">
|
||||
<div
|
||||
aria-hidden
|
||||
className={`${styles.heroOverlay} pointer-events-none absolute inset-0`}
|
||||
/>
|
||||
<div className="px-8 pt-8">
|
||||
<div className={`${styles.icon} flex size-12 items-center justify-center rounded-xl shadow-lg backdrop-blur-[5px]`}>
|
||||
<Icon className="size-6 text-text-primary-on-surface" />
|
||||
</div>
|
||||
<div className="mt-6 space-y-2">
|
||||
<div className={`${styles.highlight} title-3xl-semi-bold`}>
|
||||
{title}
|
||||
</div>
|
||||
<div className="system-md-regular text-text-tertiary">
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
{extraInfo}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 mb-8 flex justify-end space-x-2 px-8">
|
||||
<Button
|
||||
onClick={onClose}
|
||||
>
|
||||
{t('triggerLimitModal.dismiss', { ns: 'billing' })}
|
||||
</Button>
|
||||
<UpgradeBtn
|
||||
size="custom"
|
||||
isShort
|
||||
onClick={handleUpgrade}
|
||||
className="h-8! rounded-lg! px-2"
|
||||
labelKey="triggerLimitModal.upgrade"
|
||||
loc="trigger-events-limit-modal"
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
<UpgradeModal
|
||||
open={show}
|
||||
onOpenChange={open => !open && onClose()}
|
||||
Icon={Icon}
|
||||
title={title}
|
||||
description={description}
|
||||
extraInfo={extraInfo}
|
||||
footer={(
|
||||
<>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
>
|
||||
{t('triggerLimitModal.dismiss', { ns: 'billing' })}
|
||||
</Button>
|
||||
<UpgradeBtn
|
||||
size="custom"
|
||||
isShort
|
||||
onClick={handleUpgrade}
|
||||
className="h-8! rounded-lg! px-2"
|
||||
labelKey="triggerLimitModal.upgrade"
|
||||
loc="trigger-events-limit-modal"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(PlanUpgradeModal)
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { Mock } from 'vitest'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { TooltipProvider } from '@langgenius/dify-ui/tooltip'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { createMockPlan } from '@/__mocks__/provider-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { Plan } from '../../type'
|
||||
@ -15,6 +16,14 @@ const setupPlan = (planType: Plan) => {
|
||||
useProviderContextMock.mockReturnValue(createMockPlan(planType))
|
||||
}
|
||||
|
||||
const renderPriorityLabel = (className?: string) => {
|
||||
return render(
|
||||
<TooltipProvider delay={0} closeDelay={0}>
|
||||
<PriorityLabel className={className} />
|
||||
</TooltipProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('PriorityLabel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@ -24,7 +33,7 @@ describe('PriorityLabel', () => {
|
||||
it('should render the standard priority label when plan is sandbox', () => {
|
||||
setupPlan(Plan.sandbox)
|
||||
|
||||
render(<PriorityLabel />)
|
||||
renderPriorityLabel()
|
||||
|
||||
expect(screen.getByText('billing.plansCommon.priority.standard')).toBeInTheDocument()
|
||||
})
|
||||
@ -35,7 +44,7 @@ describe('PriorityLabel', () => {
|
||||
it('should apply custom className to the label container', () => {
|
||||
setupPlan(Plan.sandbox)
|
||||
|
||||
render(<PriorityLabel className="custom-class" />)
|
||||
renderPriorityLabel('custom-class')
|
||||
|
||||
const label = screen.getByText('billing.plansCommon.priority.standard').closest('div')
|
||||
expect(label).toHaveClass('custom-class')
|
||||
@ -47,7 +56,7 @@ describe('PriorityLabel', () => {
|
||||
it('should render priority label and icon when plan is professional', () => {
|
||||
setupPlan(Plan.professional)
|
||||
|
||||
const { container } = render(<PriorityLabel />)
|
||||
const { container } = renderPriorityLabel()
|
||||
|
||||
expect(screen.getByText('billing.plansCommon.priority.priority')).toBeInTheDocument()
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
@ -56,7 +65,7 @@ describe('PriorityLabel', () => {
|
||||
it('should render top priority label and icon when plan is team', () => {
|
||||
setupPlan(Plan.team)
|
||||
|
||||
const { container } = render(<PriorityLabel />)
|
||||
const { container } = renderPriorityLabel()
|
||||
|
||||
expect(screen.getByText('billing.plansCommon.priority.top-priority')).toBeInTheDocument()
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
@ -65,7 +74,7 @@ describe('PriorityLabel', () => {
|
||||
it('should render standard label without icon when plan is sandbox', () => {
|
||||
setupPlan(Plan.sandbox)
|
||||
|
||||
const { container } = render(<PriorityLabel />)
|
||||
const { container } = renderPriorityLabel()
|
||||
|
||||
expect(screen.getByText('billing.plansCommon.priority.standard')).toBeInTheDocument()
|
||||
expect(container.querySelector('svg')).not.toBeInTheDocument()
|
||||
@ -77,7 +86,7 @@ describe('PriorityLabel', () => {
|
||||
it('should render top-priority label with icon for enterprise plan', () => {
|
||||
setupPlan(Plan.enterprise)
|
||||
|
||||
const { container } = render(<PriorityLabel />)
|
||||
const { container } = renderPriorityLabel()
|
||||
|
||||
expect(screen.getByText('billing.plansCommon.priority.top-priority')).toBeInTheDocument()
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
@ -85,29 +94,21 @@ describe('PriorityLabel', () => {
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should show the tip text when priority is not top priority', async () => {
|
||||
it('should render a non-top priority trigger without mounting tooltip content by default', () => {
|
||||
setupPlan(Plan.sandbox)
|
||||
|
||||
render(<PriorityLabel />)
|
||||
const label = screen.getByText('billing.plansCommon.priority.standard').closest('div')
|
||||
fireEvent.mouseEnter(label as HTMLElement)
|
||||
renderPriorityLabel()
|
||||
|
||||
expect(await screen.findByText(
|
||||
'billing.plansCommon.documentProcessingPriority: billing.plansCommon.priority.standard',
|
||||
)).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.plansCommon.documentProcessingPriorityTip')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.plansCommon.priority.standard')).toBeInTheDocument()
|
||||
expect(screen.queryByText('billing.plansCommon.documentProcessingPriority')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide the tip text when priority is top priority', async () => {
|
||||
it('should render a top priority trigger without mounting upgrade tip by default', () => {
|
||||
setupPlan(Plan.enterprise)
|
||||
|
||||
render(<PriorityLabel />)
|
||||
const label = screen.getByText('billing.plansCommon.priority.top-priority').closest('div')
|
||||
fireEvent.mouseEnter(label as HTMLElement)
|
||||
renderPriorityLabel()
|
||||
|
||||
expect(await screen.findByText(
|
||||
'billing.plansCommon.documentProcessingPriority: billing.plansCommon.priority.top-priority',
|
||||
)).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.plansCommon.priority.top-priority')).toBeInTheDocument()
|
||||
expect(screen.queryByText('billing.plansCommon.documentProcessingPriorityTip')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { RiAedFill } from '@remixicon/react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import {
|
||||
DocumentProcessingPriority,
|
||||
@ -31,8 +31,25 @@ const PriorityLabel = ({ className }: PriorityLabelProps) => {
|
||||
}, [plan])
|
||||
|
||||
return (
|
||||
<Tooltip popupContent={(
|
||||
<div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div
|
||||
className={cn(
|
||||
'ml-1 inline-flex h-[18px] shrink-0 items-center rounded-[5px] border border-text-accent-secondary bg-components-badge-bg-dimm px-[5px] system-2xs-medium text-text-accent-secondary',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{
|
||||
(plan.type === Plan.professional || plan.type === Plan.team || plan.type === Plan.enterprise) && (
|
||||
<RiAedFill className="mr-0.5 size-3" />
|
||||
)
|
||||
}
|
||||
<span>{t(`plansCommon.priority.${priority}`, { ns: 'billing' })}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="mb-1 text-xs font-semibold text-text-primary">
|
||||
{t('plansCommon.documentProcessingPriority', { ns: 'billing' })}
|
||||
:
|
||||
@ -44,22 +61,7 @@ const PriorityLabel = ({ className }: PriorityLabelProps) => {
|
||||
<div className="text-xs text-text-secondary">{t('plansCommon.documentProcessingPriorityTip', { ns: 'billing' })}</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'ml-1 inline-flex h-[18px] shrink-0 items-center rounded-[5px] border border-text-accent-secondary bg-components-badge-bg-dimm px-[5px] system-2xs-medium text-text-accent-secondary',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{
|
||||
(plan.type === Plan.professional || plan.type === Plan.team || plan.type === Plan.enterprise) && (
|
||||
<RiAedFill className="mr-0.5 size-3" />
|
||||
)
|
||||
}
|
||||
<span>{t(`plansCommon.priority.${priority}`, { ns: 'billing' })}</span>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 }) => (
|
||||
<div
|
||||
data-testid="plan-upgrade-modal"
|
||||
data-show={props.show}
|
||||
data-title={props.title}
|
||||
data-description={props.description}
|
||||
>
|
||||
{props.extraInfo}
|
||||
</div>
|
||||
))
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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<Props> = ({
|
||||
export default function TriggerEventsLimitModal({
|
||||
show,
|
||||
onClose,
|
||||
onUpgrade,
|
||||
usage,
|
||||
total,
|
||||
resetInDays,
|
||||
}) => {
|
||||
}: Props) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
@ -30,7 +28,7 @@ const TriggerEventsLimitModal: FC<Props> = ({
|
||||
show={show}
|
||||
onClose={onClose}
|
||||
onUpgrade={onUpgrade}
|
||||
Icon={TriggerAll as React.ComponentType<React.SVGProps<SVGSVGElement>>}
|
||||
Icon={TriggerAll}
|
||||
title={t('triggerLimitModal.title', { ns: 'billing' })}
|
||||
description={t('triggerLimitModal.description', { ns: 'billing' })}
|
||||
extraInfo={(
|
||||
@ -47,5 +45,3 @@ const TriggerEventsLimitModal: FC<Props> = ({
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(TriggerEventsLimitModal)
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { RiImageAddLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { ACCEPT_TYPES } from '../constants'
|
||||
import { useUpload } from '../hooks/use-upload'
|
||||
import { useFileStoreWithSelector } from '../store'
|
||||
@ -29,20 +29,14 @@ const ImageUploader = () => {
|
||||
onChange={fileChangeHandle}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Tooltip
|
||||
popupContent={t('imageUploader.tooltip', {
|
||||
ns: 'datasetHitTesting',
|
||||
size: fileUploadConfig.imageFileSizeLimit,
|
||||
batchCount: fileUploadConfig.imageFileBatchLimit,
|
||||
})}
|
||||
popupClassName="system-xs-medium p-1.5 rounded-lg text-text-secondary"
|
||||
position="top"
|
||||
offset={4}
|
||||
disabled={files.length === 0}
|
||||
>
|
||||
<div
|
||||
className="group flex cursor-pointer items-center gap-x-2"
|
||||
onClick={selectHandle}
|
||||
<Tooltip disabled={files.length === 0}>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div
|
||||
className="group flex cursor-pointer items-center gap-x-2"
|
||||
onClick={selectHandle}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<div className="flex size-8 items-center justify-center rounded-lg border border-dashed border-components-dropzone-border bg-components-button-tertiary-bg group-hover:bg-components-button-tertiary-bg-hover">
|
||||
<RiImageAddLine className="size-4 text-text-tertiary" />
|
||||
@ -56,7 +50,14 @@ const ImageUploader = () => {
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={4} className="rounded-lg p-1.5 system-xs-medium text-text-secondary">
|
||||
{t('imageUploader.tooltip', {
|
||||
ns: 'datasetHitTesting',
|
||||
size: fileUploadConfig.imageFileSizeLimit,
|
||||
batchCount: fileUploadConfig.imageFileBatchLimit,
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -134,12 +134,6 @@ vi.mock('@langgenius/dify-ui/switch', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/tooltip', () => ({
|
||||
default: ({ popupContent }: { popupContent: React.ReactNode }) => (
|
||||
<div data-testid="tooltip">{popupContent}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('RetrievalParamConfig', () => {
|
||||
const createDefaultConfig = (overrides?: Partial<RetrievalConfig>): RetrievalConfig => ({
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
@ -799,7 +793,7 @@ describe('RetrievalParamConfig', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('tooltip'))!.toBeInTheDocument()
|
||||
expect(screen.getByLabelText('common.modelProvider.rerankModel.tip'))!.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -10,10 +10,10 @@ import { useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import WeightedScore from '@/app/components/app/configuration/dataset-config/params-config/weighted-score'
|
||||
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import ScoreThresholdItem from '@/app/components/base/param-item/score-threshold-item'
|
||||
import TopKItem from '@/app/components/base/param-item/top-k-item'
|
||||
import RadioCard from '@/app/components/base/radio-card'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useCurrentProviderAndModel, useModelListAndDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
|
||||
@ -127,11 +127,12 @@ const RetrievalParamConfig: FC<Props> = ({
|
||||
)}
|
||||
<div className="flex items-center">
|
||||
<span className="mr-0.5 system-sm-semibold text-text-secondary">{t('modelProvider.rerankModel.key', { ns: 'common' })}</span>
|
||||
<Tooltip
|
||||
popupContent={
|
||||
<div className="w-[200px]">{t('modelProvider.rerankModel.tip', { ns: 'common' })}</div>
|
||||
}
|
||||
/>
|
||||
<Infotip
|
||||
aria-label={t('modelProvider.rerankModel.tip', { ns: 'common' })}
|
||||
popupClassName="w-[200px]"
|
||||
>
|
||||
{t('modelProvider.rerankModel.tip', { ns: 'common' })}
|
||||
</Infotip>
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
|
||||
@ -5,8 +5,8 @@ import * as React from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import WorkflowPreview from '@/app/components/workflow/workflow-preview'
|
||||
import { usePipelineTemplateById } from '@/service/use-pipeline'
|
||||
import ChunkStructureCard from './chunk-structure-card'
|
||||
@ -111,10 +111,12 @@ const Details = ({
|
||||
<span className="system-sm-semibold-uppercase text-text-secondary">
|
||||
{t('details.structure', { ns: 'datasetPipeline' })}
|
||||
</span>
|
||||
<Tooltip
|
||||
<Infotip
|
||||
aria-label={t('details.structureTooltip', { ns: 'datasetPipeline' })}
|
||||
popupClassName="max-w-[240px]"
|
||||
popupContent={t('details.structureTooltip', { ns: 'datasetPipeline' })}
|
||||
/>
|
||||
>
|
||||
{t('details.structureTooltip', { ns: 'datasetPipeline' })}
|
||||
</Infotip>
|
||||
</div>
|
||||
<ChunkStructureCard {...chunkStructureConfig[pipelineTemplateInfo.chunk_structure]} />
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { IndexingStatusResponse } from '@/models/datasets'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@ -14,12 +13,6 @@ vi.mock('../../../common/document-file-icon', () => ({
|
||||
vi.mock('@/app/components/base/notion-icon', () => ({
|
||||
default: ({ src }: { src?: string }) => <span data-testid="notion-icon">{src}</span>,
|
||||
}))
|
||||
vi.mock('@/app/components/base/tooltip', () => ({
|
||||
default: ({ children, popupContent }: { children?: ReactNode, popupContent?: ReactNode }) => (
|
||||
<div data-testid="tooltip" data-content={popupContent}>{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('IndexingProgressItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@ -100,7 +93,7 @@ describe('IndexingProgressItem', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('tooltip')).toHaveAttribute('data-content', 'Parse failed')
|
||||
expect(screen.getByLabelText('Parse failed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show priority label when billing is enabled', () => {
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import type { FC } from 'react'
|
||||
import type { IndexingStatusResponse } from '@/models/datasets'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import {
|
||||
RiCheckboxCircleFill,
|
||||
RiErrorWarningFill,
|
||||
} from '@remixicon/react'
|
||||
import NotionIcon from '@/app/components/base/notion-icon'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import PriorityLabel from '@/app/components/billing/priority-label'
|
||||
import { DataSourceType } from '@/models/datasets'
|
||||
import DocumentFileIcon from '../../common/document-file-icon'
|
||||
@ -27,14 +27,16 @@ const StatusIcon: FC<{ status: string, error?: string }> = ({ status, error }) =
|
||||
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<Tooltip
|
||||
popupClassName="px-4 py-[14px] max-w-60 body-xs-regular text-text-secondary border-[0.5px] border-components-panel-border rounded-xl"
|
||||
offset={4}
|
||||
popupContent={error}
|
||||
>
|
||||
<span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<span aria-label={error || 'Error'} />}>
|
||||
<RiErrorWarningFill className="size-4 shrink-0 text-text-destructive" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
sideOffset={4}
|
||||
className="max-w-60 rounded-xl border-[0.5px] border-components-panel-border px-4 py-[14px] body-xs-regular text-text-secondary"
|
||||
>
|
||||
{error}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@ -92,18 +92,6 @@ vi.mock('@/app/components/billing/vector-space-full', () => ({
|
||||
default: () => <div data-testid="vector-space-full">Vector Space Full</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/billing/plan-upgrade-modal', () => ({
|
||||
default: ({ show, onClose }: { show: boolean, onClose: () => void }) => (
|
||||
show
|
||||
? (
|
||||
<div data-testid="plan-upgrade-modal">
|
||||
<button data-testid="close-upgrade-modal" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../file-preview', () => ({
|
||||
default: ({ file, hidePreview }: { file: File, hidePreview: () => void }) => (
|
||||
<div data-testid="file-preview">
|
||||
@ -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', () => {
|
||||
|
||||
@ -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
|
||||
? (
|
||||
<div data-testid="plan-upgrade-modal">
|
||||
<span>{title}</span>
|
||||
<button data-testid="close-modal" onClick={onClose}>close-modal</button>
|
||||
</div>
|
||||
)
|
||||
: 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(<PreviewPanel {...defaultProps} isShowPlanUpgradeModal />)
|
||||
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(<PreviewPanel {...defaultProps} isShowPlanUpgradeModal />)
|
||||
fireEvent.click(screen.getByTestId('close-modal'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'billing.triggerLimitModal.dismiss' }))
|
||||
expect(defaultProps.hidePlanUpgradeModal).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -165,6 +165,13 @@ describe('IndexingModeSection', () => {
|
||||
const economicalText = screen.getByText(`${ns}.stepTwo.economical`)
|
||||
const card = economicalText.closest('[class*="rounded-xl"]')
|
||||
expect(card)!.toHaveClass('pointer-events-none')
|
||||
expect(screen.getByText(`${ns}.stepTwo.notAvailableForQA`))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show parent-child disabled reason inline on economical option', () => {
|
||||
render(<IndexingModeSection {...defaultProps} docForm={ChunkingMode.parentChild} />)
|
||||
|
||||
expect(screen.getByText(`${ns}.stepTwo.notAvailableForParentChild`))!.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -30,9 +30,7 @@ describe('DelimiterInput', () => {
|
||||
|
||||
it('should render tooltip content', () => {
|
||||
render(<DelimiterInput />)
|
||||
// Tooltip triggers render; component mounts without error
|
||||
// Tooltip triggers render; component mounts without error
|
||||
expect(screen.getByText(`${ns}.stepTwo.separator`))!.toBeInTheDocument()
|
||||
expect(screen.getByLabelText(`${ns}.stepTwo.separatorTip`))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should suppress onChange during IME composition', () => {
|
||||
@ -103,6 +101,7 @@ describe('OverlapInput', () => {
|
||||
it('should render overlap label', () => {
|
||||
render(<OverlapInput onChange={vi.fn()} />)
|
||||
expect(screen.getAllByText(new RegExp(`${ns}.stepTwo.overlap`)).length).toBeGreaterThan(0)
|
||||
expect(screen.getByLabelText(`${ns}.stepTwo.overlapTip`))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render number input', () => {
|
||||
|
||||
@ -3,14 +3,20 @@
|
||||
import type { FC } from 'react'
|
||||
import type { DefaultModel, Model } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { RetrievalConfig } from '@/types/app'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import CustomDialog from '@/app/components/base/dialog'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import EconomicalRetrievalMethodConfig from '@/app/components/datasets/common/economical-retrieval-method-config'
|
||||
import RetrievalMethodConfig from '@/app/components/datasets/common/retrieval-method-config'
|
||||
import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
|
||||
@ -65,6 +71,13 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
|
||||
const docLink = useDocLink()
|
||||
|
||||
const getIndexingTechnique = () => indexType
|
||||
const economicalDisabledReason = (() => {
|
||||
if (docForm === ChunkingMode.qa)
|
||||
return t('stepTwo.notAvailableForQA', { ns: 'datasetCreation' })
|
||||
|
||||
if (docForm !== ChunkingMode.text)
|
||||
return t('stepTwo.notAvailableForParentChild', { ns: 'datasetCreation' })
|
||||
})()
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -72,7 +85,33 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
|
||||
<div className="mb-1 system-md-semibold text-text-secondary">
|
||||
{t('stepTwo.indexMode', { ns: 'datasetCreation' })}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertDialog
|
||||
open={isQAConfirmDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open)
|
||||
onQAConfirmDialogClose()
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent className="w-[432px]">
|
||||
<div className="flex flex-col gap-2 p-6 pb-4">
|
||||
<AlertDialogTitle className="text-lg leading-7 font-semibold text-text-primary">
|
||||
{t('stepTwo.qaSwitchHighQualityTipTitle', { ns: 'datasetCreation' })}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-sm leading-5 text-text-secondary">
|
||||
{t('stepTwo.qaSwitchHighQualityTipContent', { ns: 'datasetCreation' })}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton variant="secondary">
|
||||
{t('stepTwo.cancel', { ns: 'datasetCreation' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton tone="default" onClick={onQAConfirmDialogConfirm}>
|
||||
{t('stepTwo.switch', { ns: 'datasetCreation' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<div className="flex items-stretch gap-2">
|
||||
{/* Qualified option */}
|
||||
{(!hasSetIndexType || (hasSetIndexType && indexType === IndexingType.QUALIFIED)) && (
|
||||
<OptionCard
|
||||
@ -106,49 +145,15 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
|
||||
|
||||
{/* Economical option */}
|
||||
{(!hasSetIndexType || (hasSetIndexType && indexType === IndexingType.ECONOMICAL)) && (
|
||||
<>
|
||||
<CustomDialog show={isQAConfirmDialogOpen} onClose={onQAConfirmDialogClose} className="w-[432px]">
|
||||
<header className="mb-4 pt-6">
|
||||
<h2 className="text-lg font-semibold text-text-primary">
|
||||
{t('stepTwo.qaSwitchHighQualityTipTitle', { ns: 'datasetCreation' })}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm font-normal text-text-secondary">
|
||||
{t('stepTwo.qaSwitchHighQualityTipContent', { ns: 'datasetCreation' })}
|
||||
</p>
|
||||
</header>
|
||||
<div className="flex gap-2 pb-6">
|
||||
<Button className="ml-auto" onClick={onQAConfirmDialogClose}>
|
||||
{t('stepTwo.cancel', { ns: 'datasetCreation' })}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={onQAConfirmDialogConfirm}>
|
||||
{t('stepTwo.switch', { ns: 'datasetCreation' })}
|
||||
</Button>
|
||||
</div>
|
||||
</CustomDialog>
|
||||
<Tooltip
|
||||
popupContent={(
|
||||
<div className="rounded-lg border-components-panel-border bg-components-tooltip-bg p-3 text-xs font-medium text-text-secondary shadow-lg">
|
||||
{docForm === ChunkingMode.qa
|
||||
? t('stepTwo.notAvailableForQA', { ns: 'datasetCreation' })
|
||||
: t('stepTwo.notAvailableForParentChild', { ns: 'datasetCreation' })}
|
||||
</div>
|
||||
)}
|
||||
noDecoration
|
||||
position="top"
|
||||
asChild={false}
|
||||
triggerClassName="flex-1 self-stretch"
|
||||
>
|
||||
<OptionCard
|
||||
className="h-full"
|
||||
title={t('stepTwo.economical', { ns: 'datasetCreation' })}
|
||||
description={t('stepTwo.economicalTip', { ns: 'datasetCreation' })}
|
||||
icon={<img src={indexMethodIcon.economical} alt="" />}
|
||||
isActive={!hasSetIndexType && indexType === IndexingType.ECONOMICAL}
|
||||
disabled={hasSetIndexType || docForm !== ChunkingMode.text}
|
||||
onSwitched={() => onIndexTypeChange(IndexingType.ECONOMICAL)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</>
|
||||
<OptionCard
|
||||
className="flex-1 self-stretch"
|
||||
title={t('stepTwo.economical', { ns: 'datasetCreation' })}
|
||||
description={economicalDisabledReason || t('stepTwo.economicalTip', { ns: 'datasetCreation' })}
|
||||
icon={<img src={indexMethodIcon.economical} alt="" />}
|
||||
isActive={!hasSetIndexType && indexType === IndexingType.ECONOMICAL}
|
||||
disabled={hasSetIndexType || !!economicalDisabledReason}
|
||||
onSwitched={() => onIndexTypeChange(IndexingType.ECONOMICAL)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@ -12,8 +12,8 @@ import {
|
||||
} from '@langgenius/dify-ui/number-field'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { env } from '@/env'
|
||||
|
||||
const TextLabel: FC<PropsWithChildren> = (props) => {
|
||||
@ -38,13 +38,9 @@ export const DelimiterInput: FC<InputProps & { tooltip?: string }> = ({ tooltip,
|
||||
<FormField label={(
|
||||
<div className="mb-1 flex items-center">
|
||||
<span className="mr-0.5 system-sm-semibold">{t('stepTwo.separator', { ns: 'datasetCreation' })}</span>
|
||||
<Tooltip
|
||||
popupContent={(
|
||||
<div className="max-w-[200px]">
|
||||
{tooltip || t('stepTwo.separatorTip', { ns: 'datasetCreation' })}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Infotip aria-label={tooltip || t('stepTwo.separatorTip', { ns: 'datasetCreation' })} popupClassName="max-w-[200px]">
|
||||
{tooltip || t('stepTwo.separatorTip', { ns: 'datasetCreation' })}
|
||||
</Infotip>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
@ -154,13 +150,9 @@ export const OverlapInput: FC<CompoundNumberInputProps> = (props) => {
|
||||
<FormField label={(
|
||||
<div className="mb-1 flex items-center">
|
||||
<span className="system-sm-semibold">{t('stepTwo.overlap', { ns: 'datasetCreation' })}</span>
|
||||
<Tooltip
|
||||
popupContent={(
|
||||
<div className="max-w-[200px]">
|
||||
{t('stepTwo.overlapTip', { ns: 'datasetCreation' })}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Infotip aria-label={t('stepTwo.overlapTip', { ns: 'datasetCreation' })} popupClassName="max-w-[200px]">
|
||||
{t('stepTwo.overlapTip', { ns: 'datasetCreation' })}
|
||||
</Infotip>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
|
||||
@ -20,7 +20,7 @@ type OptionCardHeaderProps = {
|
||||
export const OptionCardHeader: FC<OptionCardHeaderProps> = (props) => {
|
||||
const { icon, title, description, isActive, activeClassName, effectImg, disabled } = props
|
||||
return (
|
||||
<div className={cn('relative flex h-full overflow-hidden rounded-t-xl', isActive && activeClassName, !disabled && 'cursor-pointer')}>
|
||||
<div className={cn('relative flex flex-1 overflow-hidden rounded-t-xl', isActive && activeClassName, !disabled && 'cursor-pointer')}>
|
||||
<div className="relative flex size-14 items-center justify-center overflow-hidden">
|
||||
{isActive && effectImg && <img src={effectImg} className="absolute top-0 left-0 h-full w-full" alt="" width={56} height={56} />}
|
||||
<div className="p-1">
|
||||
@ -63,7 +63,7 @@ export const OptionCard: FC<OptionCardProps> = (
|
||||
const { icon, className, title, description, isActive, children, actions, activeHeaderClassName, style, effectImg, onSwitched, noHighlight, disabled, ...rest } = props
|
||||
return (
|
||||
<div
|
||||
className={cn('rounded-xl bg-components-option-card-option-bg shadow-xs', (isActive && !noHighlight)
|
||||
className={cn('flex flex-col rounded-xl bg-components-option-card-option-bg shadow-xs', (isActive && !noHighlight)
|
||||
? 'border-[1.5px] border-components-option-card-option-selected-border'
|
||||
: 'border border-components-option-card-option-border', disabled && 'pointer-events-none opacity-50', className)}
|
||||
style={{
|
||||
|
||||
@ -2,10 +2,6 @@ import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import CheckboxWithLabel from '../checkbox-with-label'
|
||||
|
||||
vi.mock('@/app/components/base/tooltip', () => ({
|
||||
default: ({ popupContent }: { popupContent?: React.ReactNode }) => <div data-testid="tooltip">{popupContent}</div>,
|
||||
}))
|
||||
|
||||
describe('CheckboxWithLabel', () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
@ -27,12 +23,12 @@ describe('CheckboxWithLabel', () => {
|
||||
tooltip="Help text"
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('tooltip')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Help text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render tooltip when not provided', () => {
|
||||
render(<CheckboxWithLabel isChecked={false} onChange={onChange} label="Option" />)
|
||||
expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('Help text')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should toggle checked state on checkbox click', () => {
|
||||
|
||||
@ -2,10 +2,6 @@ import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Field from '../field'
|
||||
|
||||
vi.mock('@/app/components/base/tooltip', () => ({
|
||||
default: ({ popupContent }: { popupContent?: React.ReactNode }) => <div data-testid="tooltip">{popupContent}</div>,
|
||||
}))
|
||||
|
||||
describe('WebsiteField', () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
@ -30,7 +26,7 @@ describe('WebsiteField', () => {
|
||||
|
||||
it('should render tooltip when provided', () => {
|
||||
render(<Field label="URL" value="" onChange={onChange} tooltip="Enter full URL" />)
|
||||
expect(screen.getByTestId('tooltip')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Enter full URL')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass value and onChange to Input', () => {
|
||||
|
||||
@ -2,8 +2,9 @@
|
||||
import type { FC } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import * as React from 'react'
|
||||
import { useId } from 'react'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
@ -24,19 +25,28 @@ const CheckboxWithLabel: FC<Props> = ({
|
||||
tooltip,
|
||||
testId,
|
||||
}) => {
|
||||
const labelId = useId()
|
||||
const handleToggle = () => onChange(!isChecked)
|
||||
|
||||
return (
|
||||
<label className={cn(className, 'flex h-7 items-center space-x-2')}>
|
||||
<Checkbox checked={isChecked} onCheck={() => onChange(!isChecked)} id={testId} />
|
||||
<div className={cn('text-sm font-normal text-text-secondary', labelClassName)}>{label}</div>
|
||||
{tooltip && (
|
||||
<Tooltip
|
||||
popupContent={
|
||||
<div className="w-[200px]">{tooltip}</div>
|
||||
}
|
||||
triggerClassName="ml-0.5 w-4 h-4"
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
<div className={cn(className, 'flex h-7 items-center')}>
|
||||
<Checkbox checked={isChecked} onCheck={handleToggle} id={testId} ariaLabelledBy={labelId} />
|
||||
<div className="ml-2 flex min-w-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
id={labelId}
|
||||
className={cn('min-w-0 cursor-pointer border-0 bg-transparent p-0 text-left text-sm font-normal text-text-secondary', labelClassName)}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{tooltip && (
|
||||
<Infotip aria-label={tooltip} popupClassName="w-[200px]">
|
||||
{tooltip}
|
||||
</Infotip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(CheckboxWithLabel)
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
import type { FC } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import * as React from 'react'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import Input from './input'
|
||||
|
||||
type Props = {
|
||||
@ -37,12 +37,9 @@ const Field: FC<Props> = ({
|
||||
</div>
|
||||
{isRequired && <span className="ml-0.5 text-xs font-semibold text-text-destructive">*</span>}
|
||||
{tooltip && (
|
||||
<Tooltip
|
||||
popupContent={
|
||||
<div className="w-[200px]">{tooltip}</div>
|
||||
}
|
||||
triggerClassName="ml-0.5 w-4 h-4"
|
||||
/>
|
||||
<Infotip aria-label={tooltip} className="ml-0.5" popupClassName="w-[200px]">
|
||||
{tooltip}
|
||||
</Infotip>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
|
||||
@ -112,18 +112,6 @@ vi.mock('@/app/components/billing/vector-space-full', () => ({
|
||||
default: () => <div data-testid="vector-space-full">Vector Space Full</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/billing/plan-upgrade-modal', () => ({
|
||||
default: ({ show, onClose }: { show: boolean, onClose: () => void }) => (
|
||||
show
|
||||
? (
|
||||
<div data-testid="plan-upgrade-modal">
|
||||
<button data-testid="close-modal" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/create/step-one/upgrade-card', () => ({
|
||||
default: () => <div data-testid="upgrade-card">Upgrade Card</div>,
|
||||
}))
|
||||
|
||||
@ -8,10 +8,6 @@ vi.mock('@/app/components/base/checkbox', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/tooltip', () => ({
|
||||
default: ({ popupContent }: { popupContent: string }) => <div data-testid="tooltip">{popupContent}</div>,
|
||||
}))
|
||||
|
||||
describe('CheckboxWithLabel', () => {
|
||||
const defaultProps = {
|
||||
isChecked: false,
|
||||
@ -35,12 +31,12 @@ describe('CheckboxWithLabel', () => {
|
||||
|
||||
it('should render tooltip when provided', () => {
|
||||
render(<CheckboxWithLabel {...defaultProps} tooltip="Help text" />)
|
||||
expect(screen.getByTestId('tooltip')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Help text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render tooltip when not provided', () => {
|
||||
render(<CheckboxWithLabel {...defaultProps} />)
|
||||
expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('Help text')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply custom className', () => {
|
||||
|
||||
@ -62,16 +62,13 @@ describe('CheckboxWithLabel', () => {
|
||||
it('should render tooltip when provided', () => {
|
||||
render(<CheckboxWithLabel {...defaultProps} tooltip="Helpful tooltip text" />)
|
||||
|
||||
// Assert - Tooltip trigger should be present
|
||||
const tooltipTrigger = document.querySelector('[class*="ml-0.5"]')
|
||||
expect(tooltipTrigger)!.toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Helpful tooltip text'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render tooltip when not provided', () => {
|
||||
render(<CheckboxWithLabel {...defaultProps} />)
|
||||
|
||||
const tooltipTrigger = document.querySelector('[class*="ml-0.5"]')
|
||||
expect(tooltipTrigger).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('Helpful tooltip text')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -81,8 +78,7 @@ describe('CheckboxWithLabel', () => {
|
||||
<CheckboxWithLabel {...defaultProps} className="custom-class" />,
|
||||
)
|
||||
|
||||
const label = container.querySelector('label')
|
||||
expect(label)!.toHaveClass('custom-class')
|
||||
expect(container.firstChild)!.toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should apply custom labelClassName', () => {
|
||||
@ -114,16 +110,14 @@ describe('CheckboxWithLabel', () => {
|
||||
expect(mockOnChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should not trigger onChange when clicking label text due to custom checkbox', () => {
|
||||
it('should trigger onChange when clicking label text', () => {
|
||||
const mockOnChange = vi.fn()
|
||||
render(<CheckboxWithLabel {...defaultProps} onChange={mockOnChange} />)
|
||||
|
||||
// Act - Click on the label text element
|
||||
const labelText = screen.getByText('Test Label')
|
||||
fireEvent.click(labelText)
|
||||
|
||||
// Assert - Custom checkbox does not support native label-input click forwarding
|
||||
expect(mockOnChange).not.toHaveBeenCalled()
|
||||
expect(mockOnChange).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -386,15 +380,14 @@ describe('CrawledResult', () => {
|
||||
it('should pass showPreview to items', () => {
|
||||
render(<CrawledResult {...defaultProps} showPreview={true} />)
|
||||
|
||||
// Assert - Preview buttons should be visible
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const buttons = screen.getAllByRole('button', { name: 'datasetCreation.stepOne.website.preview' })
|
||||
expect(buttons.length).toBe(3)
|
||||
})
|
||||
|
||||
it('should not show preview buttons when showPreview is false', () => {
|
||||
render(<CrawledResult {...defaultProps} showPreview={false} />)
|
||||
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'datasetCreation.stepOne.website.preview' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -507,7 +500,7 @@ describe('CrawledResult', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const buttons = screen.getAllByRole('button', { name: 'datasetCreation.stepOne.website.preview' })
|
||||
fireEvent.click(buttons[1]!) // Second item's preview button
|
||||
|
||||
expect(mockOnPreview).toHaveBeenCalledWith(list[1], 1)
|
||||
@ -796,7 +789,7 @@ describe('Base Components Integration', () => {
|
||||
expect(mockOnSelectedChange).toHaveBeenCalledWith([list[0]])
|
||||
|
||||
// Act - Preview second item
|
||||
const previewButtons = screen.getAllByRole('button')
|
||||
const previewButtons = screen.getAllByRole('button', { name: 'datasetCreation.stepOne.website.preview' })
|
||||
fireEvent.click(previewButtons[1]!)
|
||||
|
||||
expect(mockOnPreview).toHaveBeenCalledWith(list[1], 1)
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
'use client'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import * as React from 'react'
|
||||
import { useId } from 'react'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
|
||||
type CheckboxWithLabelProps = {
|
||||
className?: string
|
||||
@ -21,19 +22,28 @@ const CheckboxWithLabel = ({
|
||||
labelClassName,
|
||||
tooltip,
|
||||
}: CheckboxWithLabelProps) => {
|
||||
const labelId = useId()
|
||||
const handleToggle = () => onChange(!isChecked)
|
||||
|
||||
return (
|
||||
<label className={cn('flex items-center space-x-2', className)}>
|
||||
<Checkbox checked={isChecked} onCheck={() => onChange(!isChecked)} />
|
||||
<div className={cn('system-sm-medium text-text-secondary', labelClassName)}>{label}</div>
|
||||
{tooltip && (
|
||||
<Tooltip
|
||||
popupContent={
|
||||
<div className="w-[200px]">{tooltip}</div>
|
||||
}
|
||||
triggerClassName="ml-0.5 w-4 h-4"
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
<div className={cn('flex items-center', className)}>
|
||||
<Checkbox checked={isChecked} onCheck={handleToggle} ariaLabelledBy={labelId} />
|
||||
<div className="ml-2 flex min-w-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
id={labelId}
|
||||
className={cn('min-w-0 cursor-pointer border-0 bg-transparent p-0 text-left system-sm-medium text-text-secondary', labelClassName)}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{tooltip && (
|
||||
<Infotip aria-label={tooltip} popupClassName="w-[200px]">
|
||||
{tooltip}
|
||||
</Infotip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(CheckboxWithLabel)
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
? (
|
||||
<div data-testid="plan-upgrade-modal">
|
||||
<span data-testid="modal-title">{title}</span>
|
||||
<span data-testid="modal-description">{description}</span>
|
||||
<button onClick={onClose} data-testid="close-modal">Close</button>
|
||||
</div>
|
||||
)
|
||||
: 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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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'
|
||||
|
||||
|
||||
@ -140,12 +140,12 @@ describe('StatusItem', () => {
|
||||
describe('error message tooltip', () => {
|
||||
it('should show tooltip trigger when error message is provided', () => {
|
||||
render(<StatusItem status="error" errorMessage="Test error message" />)
|
||||
expect(screen.getByTestId('error-tooltip-trigger')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Test error message')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show tooltip trigger when no error message', () => {
|
||||
render(<StatusItem status="error" />)
|
||||
expect(screen.queryByTestId('error-tooltip-trigger')).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('Test error message')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -5,11 +5,12 @@ import type { DocumentDisplayStatus } from '@/models/datasets'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Switch } from '@langgenius/dify-ui/switch'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import { useDocumentDelete, useDocumentDisable, useDocumentEnable } from '@/service/knowledge/use-document'
|
||||
import { asyncRunSafe } from '@/utils'
|
||||
@ -81,11 +82,33 @@ const StatusItem = ({ status, reverse = false, scene = 'list', textCls = '', err
|
||||
<span className={cn(`${STATUS_TEXT_COLOR_MAP[DOC_INDEX_STATUS_MAP[localStatus].color as keyof typeof STATUS_TEXT_COLOR_MAP]} text-sm`, textCls)}>
|
||||
{DOC_INDEX_STATUS_MAP[localStatus]?.text}
|
||||
</span>
|
||||
{errorMessage && (<Tooltip popupContent={<div className="max-w-[260px] break-all">{errorMessage}</div>} triggerClassName="ml-1 w-4 h-4" triggerTestId="error-tooltip-trigger" />)}
|
||||
{errorMessage && (
|
||||
<Infotip
|
||||
aria-label={errorMessage}
|
||||
className="ml-1"
|
||||
popupClassName="max-w-[260px] break-all"
|
||||
>
|
||||
{errorMessage}
|
||||
</Infotip>
|
||||
)}
|
||||
{scene === 'detail' && (
|
||||
<div className="ml-1.5 flex items-center justify-between">
|
||||
<Tooltip popupContent={t('list.action.enableWarning', { ns: 'datasetDocuments' })} popupClassName="text-text-secondary system-xs-medium" disabled={!archived}>
|
||||
<Switch checked={archived ? false : enabled} onCheckedChange={v => !archived && handleSwitch(v ? 'enable' : 'disable')} disabled={embedding || archived} size="md" />
|
||||
<Tooltip disabled={!archived}>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<span className="flex">
|
||||
<Switch
|
||||
checked={archived ? false : enabled}
|
||||
onCheckedChange={v => !archived && handleSwitch(v ? 'enable' : 'disable')}
|
||||
disabled={embedding || archived}
|
||||
size="md"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent className="system-xs-medium text-text-secondary">
|
||||
{t('list.action.enableWarning', { ns: 'datasetDocuments' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -2,11 +2,11 @@
|
||||
import type { FC } from 'react'
|
||||
import type { MetadataItemWithValue } from '../types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RiDeleteBinLine, RiQuestionLine } from '@remixicon/react'
|
||||
import { RiDeleteBinLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import useTimestamp from '@/hooks/use-timestamp'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import AddMetadataButton from '../add-metadata-button'
|
||||
@ -64,9 +64,9 @@ const InfoGroup: FC<Props> = ({
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className={cn('text-text-secondary', uppercaseTitle ? 'system-xs-semibold-uppercase' : 'system-md-semibold')}>{title}</div>
|
||||
{titleTooltip && (
|
||||
<Tooltip popupContent={<div className="max-w-[240px]">{titleTooltip}</div>}>
|
||||
<div><RiQuestionLine className="size-3.5 text-text-tertiary" /></div>
|
||||
</Tooltip>
|
||||
<Infotip aria-label={titleTooltip} popupClassName="max-w-[240px]">
|
||||
{titleTooltip}
|
||||
</Infotip>
|
||||
)}
|
||||
</div>
|
||||
{headerRight}
|
||||
|
||||
@ -8,8 +8,8 @@ import {
|
||||
useMemo,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
|
||||
@ -65,11 +65,12 @@ const SummaryIndexSetting = ({
|
||||
<div className="flex h-6 items-center justify-between">
|
||||
<div className="flex items-center system-sm-semibold-uppercase text-text-secondary">
|
||||
{t('form.summaryAutoGen', { ns: 'datasetSettings' })}
|
||||
<Tooltip
|
||||
triggerClassName="ml-1 h-4 w-4 shrink-0"
|
||||
popupContent={t('form.summaryAutoGenTip', { ns: 'datasetSettings' })}
|
||||
<Infotip
|
||||
aria-label={t('form.summaryAutoGenTip', { ns: 'datasetSettings' })}
|
||||
className="ml-1"
|
||||
>
|
||||
</Tooltip>
|
||||
{t('form.summaryAutoGenTip', { ns: 'datasetSettings' })}
|
||||
</Infotip>
|
||||
</div>
|
||||
<Switch
|
||||
checked={summaryIndexSetting?.enable ?? false}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<typeof import('@tanstack/react-query')>()
|
||||
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<typeof useParams>)
|
||||
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceEditor: options?.isEditor ?? false } as ReturnType<typeof useAppContext>)
|
||||
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<typeof useInfiniteAppList>)
|
||||
} as ReturnType<typeof useInfiniteQuery>)
|
||||
|
||||
return { refetch, fetchNextPage }
|
||||
}
|
||||
@ -164,6 +184,23 @@ describe('AppNav', () => {
|
||||
setupDefaultMocks()
|
||||
})
|
||||
|
||||
it('should configure paged app list query options', () => {
|
||||
setupDefaultMocks()
|
||||
render(<AppNav />)
|
||||
|
||||
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<typeof useParams>)
|
||||
mockUseInfiniteAppList.mockReturnValue({
|
||||
mockUseInfiniteQuery.mockReturnValue({
|
||||
data: undefined,
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
refetch: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useInfiniteAppList>)
|
||||
} as unknown as ReturnType<typeof useInfiniteQuery>)
|
||||
|
||||
// Act
|
||||
render(<AppNav />)
|
||||
|
||||
@ -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<NavItem[]>([])
|
||||
|
||||
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<NavItem[]>(() => {
|
||||
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 (
|
||||
<>
|
||||
|
||||
@ -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<typeof import('@tanstack/react-query')>()
|
||||
return {
|
||||
...actual,
|
||||
useInfiniteQuery: () => ({
|
||||
data: mockAppListData,
|
||||
isLoading: mockIsLoading,
|
||||
isFetchingNextPage: mockIsFetchingNextPage,
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: mockHasNextPage,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
// Allow configurable mock data for useAppWorkflow
|
||||
let mockWorkflowData: Record<string, unknown> | undefined | null
|
||||
let mockWorkflowLoading = false
|
||||
@ -323,6 +342,11 @@ const renderWithQueryClient = (ui: React.ReactElement) => {
|
||||
)
|
||||
}
|
||||
|
||||
type AppSelectorInfiniteOptions = {
|
||||
input: (pageParam: number) => { query: Record<string, unknown> }
|
||||
getNextPageParam: (lastPage: { has_more: boolean, page: number }) => number | undefined
|
||||
}
|
||||
|
||||
// Mock data factories
|
||||
const createMockApp = (overrides: Record<string, unknown> = {}): 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(<AppSelector {...defaultProps} />)
|
||||
|
||||
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(
|
||||
<AppSelector
|
||||
|
||||
@ -4,19 +4,22 @@ import type {
|
||||
Placement,
|
||||
} from '@floating-ui/react'
|
||||
import type { FC } from 'react'
|
||||
import type { AppListQuery } from '@/contract/console/apps'
|
||||
import type { App } from '@/types/app'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppInputsPanel from '@/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-panel'
|
||||
import AppPicker from '@/app/components/plugins/plugin-detail-panel/app-selector/app-picker'
|
||||
import AppTrigger from '@/app/components/plugins/plugin-detail-panel/app-selector/app-trigger'
|
||||
import { useAppDetail, useInfiniteAppList } from '@/service/use-apps'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { useAppDetail } from '@/service/use-apps'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
@ -50,16 +53,30 @@ const AppSelector: FC<Props> = ({
|
||||
const [isShow, setIsShow] = useState(false)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
|
||||
const appListQuery = useMemo<AppListQuery>(() => ({
|
||||
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(() => {
|
||||
|
||||
@ -1,14 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import GlobalInputs from '../global-inputs'
|
||||
|
||||
vi.mock('@/app/components/base/tooltip', () => ({
|
||||
default: ({
|
||||
popupContent,
|
||||
}: {
|
||||
popupContent: React.ReactNode
|
||||
}) => <div data-testid="tooltip">{popupContent}</div>,
|
||||
}))
|
||||
|
||||
describe('GlobalInputs', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@ -18,6 +10,6 @@ describe('GlobalInputs', () => {
|
||||
render(<GlobalInputs />)
|
||||
|
||||
expect(screen.getByText('datasetPipeline.inputFieldPanel.globalInputs.title')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tooltip')).toHaveTextContent('datasetPipeline.inputFieldPanel.globalInputs.tooltip')
|
||||
expect(screen.getByLabelText('datasetPipeline.inputFieldPanel.globalInputs.tooltip')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -20,12 +20,6 @@ vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useToolIcon: (nodeData: DataSourceNodeType) => nodeData.provider_name || 'default-icon',
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/tooltip', () => ({
|
||||
default: ({ popupContent, popupClassName }: { popupContent: string, popupClassName?: string }) => (
|
||||
<div data-testid="tooltip" data-content={popupContent} className={popupClassName} />
|
||||
),
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
@ -161,21 +155,19 @@ describe('GlobalInputs', () => {
|
||||
it('should render tooltip component', () => {
|
||||
render(<GlobalInputs />)
|
||||
|
||||
expect(screen.getByTestId('tooltip')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('datasetPipeline.inputFieldPanel.globalInputs.tooltip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass correct tooltip content', () => {
|
||||
render(<GlobalInputs />)
|
||||
|
||||
const tooltip = screen.getByTestId('tooltip')
|
||||
expect(tooltip).toHaveAttribute('data-content', 'datasetPipeline.inputFieldPanel.globalInputs.tooltip')
|
||||
expect(screen.getByLabelText('datasetPipeline.inputFieldPanel.globalInputs.tooltip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have correct tooltip className', () => {
|
||||
it('should render the tooltip trigger as an icon-sized button', () => {
|
||||
render(<GlobalInputs />)
|
||||
|
||||
const tooltip = screen.getByTestId('tooltip')
|
||||
expect(tooltip).toHaveClass('w-[240px]')
|
||||
expect(screen.getByLabelText('datasetPipeline.inputFieldPanel.globalInputs.tooltip')).toHaveClass('h-4', 'w-4')
|
||||
})
|
||||
|
||||
it('should have correct container layout', () => {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
|
||||
const GlobalInputs = () => {
|
||||
const { t } = useTranslation()
|
||||
@ -10,10 +10,9 @@ const GlobalInputs = () => {
|
||||
<span className="system-sm-semibold-uppercase text-text-secondary">
|
||||
{t('inputFieldPanel.globalInputs.title', { ns: 'datasetPipeline' })}
|
||||
</span>
|
||||
<Tooltip
|
||||
popupContent={t('inputFieldPanel.globalInputs.tooltip', { ns: 'datasetPipeline' })}
|
||||
popupClassName="w-[240px]"
|
||||
/>
|
||||
<Infotip aria-label={t('inputFieldPanel.globalInputs.tooltip', { ns: 'datasetPipeline' })} popupClassName="w-[240px]">
|
||||
{t('inputFieldPanel.globalInputs.tooltip', { ns: 'datasetPipeline' })}
|
||||
</Infotip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -330,27 +330,6 @@ describe('publisher', () => {
|
||||
})
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should keep confirm dialog mounted when first publish opens follow-up overlay', async () => {
|
||||
mockPublishedAt.mockReturnValue(null)
|
||||
renderWithQueryClient(<Publisher />)
|
||||
|
||||
fireEvent.click(screen.getByText('workflow.common.publish'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('workflow.common.publishUpdate')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /workflow.common.publishUpdate/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('pipeline.common.confirmPublish')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.mouseDown(document.body)
|
||||
|
||||
expect(screen.getByText('pipeline.common.confirmPublish')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -1,15 +1,24 @@
|
||||
import type { Node } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import NodeContextmenu from '../node-contextmenu'
|
||||
import { NodeContextmenu } from '../node-contextmenu'
|
||||
|
||||
const mockUseClickAway = vi.hoisted(() => vi.fn())
|
||||
const mockUseNodes = vi.hoisted(() => vi.fn())
|
||||
const mockUsePanelInteractions = vi.hoisted(() => vi.fn())
|
||||
const mockUseStore = vi.hoisted(() => vi.fn())
|
||||
const mockPanelOperatorPopup = vi.hoisted(() => vi.fn())
|
||||
const mockNodeActionsContextMenuContent = vi.hoisted(() => vi.fn())
|
||||
const mockContextMenuContent = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useClickAway: (...args: unknown[]) => mockUseClickAway(...args),
|
||||
vi.mock('@langgenius/dify-ui/context-menu', () => ({
|
||||
ContextMenu: ({ children, onOpenChange }: { children: React.ReactNode, onOpenChange: (open: boolean) => void }) => (
|
||||
<div>
|
||||
{children}
|
||||
<button type="button" onClick={() => onOpenChange(false)}>close-context-menu</button>
|
||||
</div>
|
||||
),
|
||||
ContextMenuContent: ({ children, positionerProps, popupClassName }: { children: React.ReactNode, positionerProps?: { anchor?: unknown }, popupClassName?: string }) => {
|
||||
mockContextMenuContent({ positionerProps, popupClassName })
|
||||
return <div>{children}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({
|
||||
@ -22,20 +31,19 @@ vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useStore: (selector: (state: { nodeMenu?: { nodeId: string, left: number, top: number } }) => unknown) => mockUseStore(selector),
|
||||
useStore: (selector: (state: { nodeMenu?: { nodeId: string, clientX: number, clientY: number } }) => unknown) => mockUseStore(selector),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup', () => ({
|
||||
__esModule: true,
|
||||
default: (props: {
|
||||
vi.mock('@/app/components/workflow/node-actions-menu/context-menu-content', () => ({
|
||||
NodeActionsContextMenuContent: (props: {
|
||||
id: string
|
||||
data: Node['data']
|
||||
showHelpLink: boolean
|
||||
onClosePopup: () => void
|
||||
onClose: () => void
|
||||
}) => {
|
||||
mockPanelOperatorPopup(props)
|
||||
mockNodeActionsContextMenuContent(props)
|
||||
return (
|
||||
<button type="button" onClick={props.onClosePopup}>
|
||||
<button type="button" onClick={props.onClose}>
|
||||
{props.id}
|
||||
:
|
||||
{props.data.title}
|
||||
@ -46,9 +54,8 @@ vi.mock('@/app/components/workflow/nodes/_base/components/panel-operator/panel-o
|
||||
|
||||
describe('NodeContextmenu', () => {
|
||||
const mockHandleNodeContextmenuCancel = vi.fn()
|
||||
let nodeMenu: { nodeId: string, left: number, top: number } | undefined
|
||||
let nodeMenu: { nodeId: string, clientX: number, clientY: number } | undefined
|
||||
let nodes: Node[]
|
||||
let clickAwayHandler: (() => void) | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@ -63,51 +70,50 @@ describe('NodeContextmenu', () => {
|
||||
type: 'code' as never,
|
||||
},
|
||||
} as Node]
|
||||
clickAwayHandler = undefined
|
||||
|
||||
mockUseClickAway.mockImplementation((handler: () => void) => {
|
||||
clickAwayHandler = handler
|
||||
})
|
||||
mockUseNodes.mockImplementation(() => nodes)
|
||||
mockUsePanelInteractions.mockReturnValue({
|
||||
handleNodeContextmenuCancel: mockHandleNodeContextmenuCancel,
|
||||
})
|
||||
mockUseStore.mockImplementation((selector: (state: { nodeMenu?: { nodeId: string, left: number, top: number } }) => unknown) => selector({ nodeMenu }))
|
||||
mockUseStore.mockImplementation((selector: (state: { nodeMenu?: { nodeId: string, clientX: number, clientY: number } }) => unknown) => selector({ nodeMenu }))
|
||||
})
|
||||
|
||||
it('should stay hidden when the node menu is absent', () => {
|
||||
render(<NodeContextmenu />)
|
||||
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
expect(mockPanelOperatorPopup).not.toHaveBeenCalled()
|
||||
expect(mockNodeActionsContextMenuContent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should stay hidden when the referenced node cannot be found', () => {
|
||||
nodeMenu = { nodeId: 'missing-node', left: 80, top: 120 }
|
||||
nodeMenu = { nodeId: 'missing-node', clientX: 80, clientY: 120 }
|
||||
|
||||
render(<NodeContextmenu />)
|
||||
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
expect(mockPanelOperatorPopup).not.toHaveBeenCalled()
|
||||
expect(mockNodeActionsContextMenuContent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render the popup at the stored position and close on popup/click-away actions', () => {
|
||||
nodeMenu = { nodeId: 'node-1', left: 80, top: 120 }
|
||||
const { container } = render(<NodeContextmenu />)
|
||||
it('should render the context menu at the stored pointer position and close on content/root actions', () => {
|
||||
nodeMenu = { nodeId: 'node-1', clientX: 80, clientY: 120 }
|
||||
render(<NodeContextmenu />)
|
||||
|
||||
expect(screen.getByRole('button')).toHaveTextContent('node-1:Node 1')
|
||||
expect(mockPanelOperatorPopup).toHaveBeenCalledWith(expect.objectContaining({
|
||||
expect(screen.getByText('node-1:Node 1')).toBeInTheDocument()
|
||||
expect(mockNodeActionsContextMenuContent).toHaveBeenCalledWith(expect.objectContaining({
|
||||
id: 'node-1',
|
||||
data: expect.objectContaining({ title: 'Node 1' }),
|
||||
showHelpLink: true,
|
||||
}))
|
||||
expect(container.firstChild).toHaveStyle({
|
||||
left: '80px',
|
||||
top: '120px',
|
||||
})
|
||||
expect(mockContextMenuContent).toHaveBeenCalledWith(expect.objectContaining({
|
||||
popupClassName: 'w-[240px] rounded-lg',
|
||||
}))
|
||||
const anchor = mockContextMenuContent.mock.calls[0]![0].positionerProps.anchor as { getBoundingClientRect: () => DOMRect }
|
||||
const rect = anchor.getBoundingClientRect()
|
||||
expect(rect.x).toBe(80)
|
||||
expect(rect.y).toBe(120)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
clickAwayHandler?.()
|
||||
fireEvent.click(screen.getByText('node-1:Node 1'))
|
||||
fireEvent.click(screen.getByText('close-context-menu'))
|
||||
|
||||
expect(mockHandleNodeContextmenuCancel).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
@ -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 }) => (
|
||||
<div data-testid="add-block">{renderTrigger()}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/divider', () => ({
|
||||
__esModule: true,
|
||||
default: ({ className }: { className?: string }) => <div data-testid="divider" className={className} />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/shortcuts-name', () => ({
|
||||
__esModule: true,
|
||||
default: ({ keys }: { keys: string[] }) => <span data-testid={`shortcut-${keys.join('-')}`}>{keys.join('+')}</span>,
|
||||
}))
|
||||
|
||||
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(<PanelContextmenu />)
|
||||
renderWorkflowFlowComponent(<PanelContextmenu />)
|
||||
|
||||
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(<PanelContextmenu />)
|
||||
it('should keep paste disabled when the clipboard is empty', async () => {
|
||||
renderWorkflowFlowComponent(<PanelContextmenu />, {
|
||||
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(<PanelContextmenu />)
|
||||
|
||||
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(<PanelContextmenu />, {
|
||||
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(<PanelContextmenu />, {
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -297,7 +297,7 @@ vi.mock('../edge-contextmenu', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../node-contextmenu', () => ({
|
||||
default: () => null,
|
||||
NodeContextmenu: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../nodes', () => ({
|
||||
|
||||
@ -96,7 +96,7 @@ const OperationDropdown: FC<Props> = ({
|
||||
className="system-md-regular"
|
||||
href={getMarketplaceUrl(`/plugins/${author}/${name}`, { theme })}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t('operation.viewDetails', { ns: 'common' })}
|
||||
</DropdownMenuLinkItem>
|
||||
|
||||
@ -82,6 +82,7 @@ const List = ({
|
||||
className="sticky bottom-0 z-10 flex h-8 cursor-pointer items-center rounded-b-lg border-[0.5px] border-t border-components-panel-border bg-components-panel-bg-blur px-4 py-1 system-sm-medium text-text-accent-light-mode-only shadow-lg"
|
||||
href={getMarketplaceUrl('', { category })}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span>{t('findMoreInMarketplace', { ns: 'plugin' })}</span>
|
||||
<RiArrowRightUpLine className="ml-0.5 h-3 w-3" />
|
||||
@ -102,6 +103,7 @@ const List = ({
|
||||
<Link
|
||||
href={urlWithSearchText}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center text-text-accent-light-mode-only"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
@ -124,6 +126,7 @@ const List = ({
|
||||
<Link
|
||||
href={urlWithSearchText}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex h-4 shrink-0 items-center system-sm-medium text-text-accent-light-mode-only"
|
||||
>
|
||||
<RiSearchLine className="mr-0.5 h-3 w-3" />
|
||||
|
||||
@ -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'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user