Compare commits

..

11 Commits

Author SHA1 Message Date
93ea88d185 Merge branch 'main' into refactor/migrate-app-detail-to-tanstack-query 2026-01-19 15:13:04 +08:00
yyh
6f40d79538 chore: prune unused eslint suppressions after merge 2026-01-19 13:33:53 +08:00
yyh
171dd120ca Merge branch 'main' into refactor/migrate-app-detail-to-tanstack-query
Resolve eslint-suppressions.json conflicts by keeping both sets of entries
2026-01-19 13:31:57 +08:00
yyh
e71ebc19e6 Merge branch 'main' into refactor/migrate-app-detail-to-tanstack-query 2026-01-19 11:17:02 +08:00
yyh
19137c173f Merge remote-tracking branch 'origin/main' into refactor/migrate-app-detail-to-tanstack-query
# Conflicts:
#	web/app/components/app/switch-app-modal/index.spec.tsx
#	web/app/components/workflow-app/components/workflow-header/features-trigger.spec.tsx
#	web/app/components/workflow-app/components/workflow-header/index.spec.tsx
2026-01-19 11:14:52 +08:00
yyh
e69f1a1f46 fix: use useInvalidateAppDetail hook in app-card.tsx for consistency 2026-01-18 23:55:30 +08:00
yyh
14f0c5dd38 fix: use useInvalidateAppDetail hook in card-view.tsx for consistency 2026-01-18 23:53:49 +08:00
yyh
23f1adc833 fix: restore mobile sidebar collapse behavior after appDetail loads
Merge the two sidebar useEffects to prevent race condition where
appDetail loading could override the mobile collapse state.
Now mobile always collapses regardless of when appDetail resolves.
2026-01-18 23:47:48 +08:00
yyh
1fe46ce0b8 fix: update test files to use TanStack Query pattern for appDetail
Update test files to reflect the appDetail migration from Zustand to TanStack Query:
- Replace setAppDetail mocks with useInvalidateAppDetail
- Add useParams mock from next/navigation
- Add useAppDetail mock from @/service/use-apps
- Remove deprecated fetchAppDetail + setAppDetail patterns
- Fix marketplace test mock data types
2026-01-18 23:46:14 +08:00
yyh
b3acb74331 fix: address code review issues from appDetail migration
- app-info.tsx: use useInvalidateAppDetail hook instead of local queryClient call
- app-publisher.tsx: convert isAppAccessSet from useEffect+useState to useMemo
- Prune unused eslint suppressions
2026-01-18 23:11:54 +08:00
yyh
91856b09ca refactor: migrate appDetail from Zustand to TanStack Query
- Remove appDetail and setAppDetail from Zustand store
- Use useAppDetail hook for server state management
- Child components now call useAppDetail(appId) directly via useParams()
- Replace setAppDetail calls with useInvalidateAppDetail for cache invalidation
- Keep only client UI state in Zustand (sidebar, modals)
- Split sidebar initialization useEffect for clearer separation of concerns
- Update test mocks to use TanStack Query pattern
- Fix missing dependencies in use-checklist.ts useMemo/useCallback hooks
2026-01-18 23:07:33 +08:00
448 changed files with 2408 additions and 22816 deletions

3
.github/labeler.yml vendored
View File

@ -1,3 +0,0 @@
web:
- changed-files:
- any-glob-to-any-file: 'web/**'

View File

@ -1,14 +0,0 @@
name: "Pull Request Labeler"
on:
pull_request_target:
jobs:
labeler:
permissions:
contents: read
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v6
with:
sync-labels: true

View File

@ -117,11 +117,6 @@ jobs:
# eslint-report: web/eslint_report.json
# github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Web tsslint
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: ./web
run: pnpm run lint:tss
- name: Web type check
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: ./web

View File

@ -134,9 +134,6 @@ jobs:
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }}
# Allow github-actions bot to trigger this workflow via repository_dispatch
# See: https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
allowed_bots: 'github-actions[bot]'
prompt: |
You are a professional i18n synchronization engineer for the Dify project.
Your task is to keep all language translations in sync with the English source (en-US).
@ -288,22 +285,6 @@ jobs:
- `${variable}` - Template literal
- `<tag>content</tag>` - HTML tags
- `_one`, `_other` - Pluralization suffixes (these are KEY suffixes, not values)
**CRITICAL: Variable names and tag names MUST stay in English - NEVER translate them**
✅ CORRECT examples:
- English: "{{count}} items" → Japanese: "{{count}} 個のアイテム"
- English: "{{name}} updated" → Korean: "{{name}} 업데이트됨"
- English: "<email>{{email}}</email>" → Chinese: "<email>{{email}}</email>"
- English: "<CustomLink>Marketplace</CustomLink>" → Japanese: "<CustomLink>マーケットプレイス</CustomLink>"
❌ WRONG examples (NEVER do this - will break the application):
- "{{count}}" → "{{カウント}}" ❌ (variable name translated to Japanese)
- "{{name}}" → "{{이름}}" ❌ (variable name translated to Korean)
- "{{email}}" → "{{邮箱}}" ❌ (variable name translated to Chinese)
- "<email>" → "<メール>" ❌ (tag name translated)
- "<CustomLink>" → "<自定义链接>" ❌ (component name translated)
- Use appropriate language register (formal/informal) based on existing translations
- Match existing translation style in each language
- Technical terms: check existing conventions per language

View File

@ -1,52 +0,0 @@
## Purpose
`api/controllers/console/datasets/datasets_document.py` contains the console (authenticated) APIs for managing dataset documents (list/create/update/delete, processing controls, estimates, etc.).
## Storage model (uploaded files)
- For local file uploads into a knowledge base, the binary is stored via `extensions.ext_storage.storage` under the key:
- `upload_files/<tenant_id>/<uuid>.<ext>`
- File metadata is stored in the `upload_files` table (`UploadFile` model), keyed by `UploadFile.id`.
- Dataset `Document` records reference the uploaded file via:
- `Document.data_source_info.upload_file_id`
## Download endpoint
- `GET /datasets/<dataset_id>/documents/<document_id>/download`
- Only supported when `Document.data_source_type == "upload_file"`.
- Performs dataset permission + tenant checks via `DocumentResource.get_document(...)`.
- Delegates `Document -> UploadFile` validation and signed URL generation to `DocumentService.get_document_download_url(...)`.
- Applies `cloud_edition_billing_rate_limit_check("knowledge")` to match other KB operations.
- Response body is **only**: `{ "url": "<signed-url>" }`.
- `POST /datasets/<dataset_id>/documents/download-zip`
- Accepts `{ "document_ids": ["..."] }` (upload-file only).
- Returns `application/zip` as a single attachment download.
- Rationale: browsers often block multiple automatic downloads; a ZIP avoids that limitation.
- Applies `cloud_edition_billing_rate_limit_check("knowledge")`.
- Delegates dataset permission checks, document/upload-file validation, and download-name generation to
`DocumentService.prepare_document_batch_download_zip(...)` before streaming the ZIP.
## Verification plan
- Upload a document from a local file into a dataset.
- Call the download endpoint and confirm it returns a signed URL.
- Open the URL and confirm:
- Response headers force download (`Content-Disposition`), and
- Downloaded bytes match the uploaded file.
- Select multiple uploaded-file documents and download as ZIP; confirm all selected files exist in the archive.
## Shared helper
- `DocumentService.get_document_download_url(document)` resolves the `UploadFile` and signs a download URL.
- `DocumentService.prepare_document_batch_download_zip(...)` performs dataset permission checks, batches
document + upload file lookups, preserves request order, and generates the client-visible ZIP filename.
- Internal helpers now live in `DocumentService` (`_get_upload_file_id_for_upload_file_document(...)`,
`_get_upload_file_for_upload_file_document(...)`, `_get_upload_files_by_document_id_for_zip_download(...)`).
- ZIP packing is handled by `FileService.build_upload_files_zip_tempfile(...)`, which also:
- sanitizes entry names to avoid path traversal, and
- deduplicates names while preserving extensions (e.g., `doc.txt``doc (1).txt`).
Streaming the response and deferring cleanup is handled by the route via `send_file(path, ...)` + `ExitStack` +
`response.call_on_close(...)` (the file is deleted when the response is closed).

View File

@ -1,18 +0,0 @@
## Purpose
`api/services/dataset_service.py` hosts dataset/document service logic used by console and API controllers.
## Batch document operations
- Batch document workflows should avoid N+1 database queries by using set-based lookups.
- Tenant checks must be enforced consistently across dataset/document operations.
- `DocumentService.get_documents_by_ids(...)` fetches documents for a dataset using `id.in_(...)`.
- `FileService.get_upload_files_by_ids(...)` performs tenant-scoped batch lookup for `UploadFile` (dedupes ids with `set(...)`).
- `DocumentService.get_document_download_url(...)` and `prepare_document_batch_download_zip(...)` handle
dataset/document permission checks plus `Document -> UploadFile` validation for download endpoints.
## Verification plan
- Exercise document list and download endpoints that use the service helpers.
- Confirm batch download uses constant query count for documents + upload files.
- Request a ZIP with a missing document id and confirm a 404 is returned.

View File

@ -1,35 +0,0 @@
## Purpose
`api/services/file_service.py` owns business logic around `UploadFile` objects: upload validation, storage persistence,
previews/generators, and deletion.
## Key invariants
- All storage I/O goes through `extensions.ext_storage.storage`.
- Uploaded file keys follow: `upload_files/<tenant_id>/<uuid>.<ext>`.
- Upload validation is enforced in `FileService.upload_file(...)` (blocked extensions, size limits, dataset-only types).
## Batch lookup helpers
- `FileService.get_upload_files_by_ids(tenant_id, upload_file_ids)` is the canonical tenant-scoped batch loader for
`UploadFile`.
## Dataset document download helpers
The dataset document download/ZIP endpoints now delegate “Document → UploadFile” validation and permission checks to
`DocumentService` (`api/services/dataset_service.py`). `FileService` stays focused on generic `UploadFile` operations
(uploading, previews, deletion), plus generic ZIP serving.
### ZIP serving
- `FileService.build_upload_files_zip_tempfile(...)` builds a ZIP from `UploadFile` objects and yields a seeked
tempfile **path** so callers can stream it (e.g., `send_file(path, ...)`) without hitting "read of closed file"
issues from file-handle lifecycle during streamed responses.
- Flask `send_file(...)` and the `ExitStack`/`call_on_close(...)` cleanup pattern are handled in the route layer.
## Verification plan
- Unit: `api/tests/unit_tests/controllers/console/datasets/test_datasets_document_download.py`
- Verify signed URL generation for upload-file documents and ZIP download behavior for multiple documents.
- Unit: `api/tests/unit_tests/services/test_file_service_zip_and_lookup.py`
- Verify ZIP packing produces a valid, openable archive and preserves file content.

View File

@ -1,28 +0,0 @@
## Purpose
Unit tests for the console dataset document download endpoint:
- `GET /datasets/<dataset_id>/documents/<document_id>/download`
## Testing approach
- Uses `Flask.test_request_context()` and calls the `Resource.get(...)` method directly.
- Monkeypatches console decorators (`login_required`, `setup_required`, rate limit) to no-ops to keep the test focused.
- Mocks:
- `DatasetService.get_dataset` / `check_dataset_permission`
- `DocumentService.get_document` for single-file download tests
- `DocumentService.get_documents_by_ids` + `FileService.get_upload_files_by_ids` for ZIP download tests
- `FileService.get_upload_files_by_ids` for `UploadFile` lookups in single-file tests
- `services.dataset_service.file_helpers.get_signed_file_url` to return a deterministic URL
- Document mocks include `id` fields so batch lookups can map documents by id.
## Covered cases
- Success returns `{ "url": "<signed>" }` for upload-file documents.
- 404 when document is not `upload_file`.
- 404 when `upload_file_id` is missing.
- 404 when referenced `UploadFile` row does not exist.
- 403 when document tenant does not match current tenant.
- Batch ZIP download returns `application/zip` for upload-file documents.
- Batch ZIP download rejects non-upload-file documents.
- Batch ZIP download uses a random `.zip` attachment name (`download_name`), so tests only assert the suffix.

View File

@ -1,18 +0,0 @@
## Purpose
Unit tests for `api/services/file_service.py` helper methods that are not covered by higher-level controller tests.
## Whats covered
- `FileService.build_upload_files_zip_tempfile(...)`
- ZIP entry name sanitization (no directory components / traversal)
- name deduplication while preserving extensions
- writing streamed bytes from `storage.load(...)` into ZIP entries
- yields a tempfile path so callers can open/stream the ZIP without holding a live file handle
- `FileService.get_upload_files_by_ids(...)`
- returns `{}` for empty id lists
- returns an id-keyed mapping for non-empty lists
## Notes
- These tests intentionally stub `storage.load` and `db.session.scalars(...).all()` to avoid needing a real DB/storage.

View File

@ -1,3 +1,4 @@
import re
import uuid
from datetime import datetime
from typing import Any, Literal, TypeAlias
@ -67,6 +68,48 @@ class AppListQuery(BaseModel):
raise ValueError("Invalid UUID format in tag_ids.") from exc
# XSS prevention: patterns that could lead to XSS attacks
# Includes: script tags, iframe tags, javascript: protocol, SVG with onload, etc.
_XSS_PATTERNS = [
r"<script[^>]*>.*?</script>", # Script tags
r"<iframe\b[^>]*?(?:/>|>.*?</iframe>)", # Iframe tags (including self-closing)
r"javascript:", # JavaScript protocol
r"<svg[^>]*?\s+onload\s*=[^>]*>", # SVG with onload handler (attribute-aware, flexible whitespace)
r"<.*?on\s*\w+\s*=", # Event handlers like onclick, onerror, etc.
r"<object\b[^>]*(?:\s*/>|>.*?</object\s*>)", # Object tags (opening tag)
r"<embed[^>]*>", # Embed tags (self-closing)
r"<link[^>]*>", # Link tags with javascript
]
def _validate_xss_safe(value: str | None, field_name: str = "Field") -> str | None:
"""
Validate that a string value doesn't contain potential XSS payloads.
Args:
value: The string value to validate
field_name: Name of the field for error messages
Returns:
The original value if safe
Raises:
ValueError: If the value contains XSS patterns
"""
if value is None:
return None
value_lower = value.lower()
for pattern in _XSS_PATTERNS:
if re.search(pattern, value_lower, re.DOTALL | re.IGNORECASE):
raise ValueError(
f"{field_name} contains invalid characters or patterns. "
"HTML tags, JavaScript, and other potentially dangerous content are not allowed."
)
return value
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)
@ -75,6 +118,11 @@ class CreateAppPayload(BaseModel):
icon: str | None = Field(default=None, description="Icon")
icon_background: str | None = Field(default=None, description="Icon background color")
@field_validator("name", "description", mode="before")
@classmethod
def validate_xss_safe(cls, value: str | None, info) -> str | None:
return _validate_xss_safe(value, info.field_name)
class UpdateAppPayload(BaseModel):
name: str = Field(..., min_length=1, description="App name")
@ -85,6 +133,11 @@ class UpdateAppPayload(BaseModel):
use_icon_as_answer_icon: bool | None = Field(default=None, description="Use icon as answer icon")
max_active_requests: int | None = Field(default=None, description="Maximum active requests")
@field_validator("name", "description", mode="before")
@classmethod
def validate_xss_safe(cls, value: str | None, info) -> str | None:
return _validate_xss_safe(value, info.field_name)
class CopyAppPayload(BaseModel):
name: str | None = Field(default=None, description="Name for the copied app")
@ -93,6 +146,11 @@ class CopyAppPayload(BaseModel):
icon: str | None = Field(default=None, description="Icon")
icon_background: str | None = Field(default=None, description="Icon background color")
@field_validator("name", "description", mode="before")
@classmethod
def validate_xss_safe(cls, value: str | None, info) -> str | None:
return _validate_xss_safe(value, info.field_name)
class AppExportQuery(BaseModel):
include_secret: bool = Field(default=False, description="Include secrets in export")

View File

@ -2,12 +2,10 @@ import json
import logging
from argparse import ArgumentTypeError
from collections.abc import Sequence
from contextlib import ExitStack
from typing import Any, Literal, cast
from uuid import UUID
from typing import Literal, cast
import sqlalchemy as sa
from flask import request, send_file
from flask import request
from flask_restx import Resource, fields, marshal, marshal_with
from pydantic import BaseModel, Field
from sqlalchemy import asc, desc, select
@ -44,7 +42,6 @@ from models import DatasetProcessRule, Document, DocumentSegment, UploadFile
from models.dataset import DocumentPipelineExecutionLog
from services.dataset_service import DatasetService, DocumentService
from services.entities.knowledge_entities.knowledge_entities import KnowledgeConfig, ProcessRule, RetrievalModel
from services.file_service import FileService
from ..app.error import (
ProviderModelCurrentlyNotSupportError,
@ -68,9 +65,6 @@ from ..wraps import (
logger = logging.getLogger(__name__)
# NOTE: Keep constants near the top of the module for discoverability.
DOCUMENT_BATCH_DOWNLOAD_ZIP_MAX_DOCS = 100
def _get_or_create_model(model_name: str, field_def):
existing = console_ns.models.get(model_name)
@ -110,12 +104,6 @@ class DocumentRenamePayload(BaseModel):
name: str
class DocumentBatchDownloadZipPayload(BaseModel):
"""Request payload for bulk downloading documents as a zip archive."""
document_ids: list[UUID] = Field(..., min_length=1, max_length=DOCUMENT_BATCH_DOWNLOAD_ZIP_MAX_DOCS)
class DocumentDatasetListParam(BaseModel):
page: int = Field(1, title="Page", description="Page number.")
limit: int = Field(20, title="Limit", description="Page size.")
@ -132,7 +120,6 @@ register_schema_models(
RetrievalModel,
DocumentRetryPayload,
DocumentRenamePayload,
DocumentBatchDownloadZipPayload,
)
@ -866,62 +853,6 @@ class DocumentApi(DocumentResource):
return {"result": "success"}, 204
@console_ns.route("/datasets/<uuid:dataset_id>/documents/<uuid:document_id>/download")
class DocumentDownloadApi(DocumentResource):
"""Return a signed download URL for a dataset document's original uploaded file."""
@console_ns.doc("get_dataset_document_download_url")
@console_ns.doc(description="Get a signed download URL for a dataset document's original uploaded file")
@setup_required
@login_required
@account_initialization_required
@cloud_edition_billing_rate_limit_check("knowledge")
def get(self, dataset_id: str, document_id: str) -> dict[str, Any]:
# Reuse the shared permission/tenant checks implemented in DocumentResource.
document = self.get_document(str(dataset_id), str(document_id))
return {"url": DocumentService.get_document_download_url(document)}
@console_ns.route("/datasets/<uuid:dataset_id>/documents/download-zip")
class DocumentBatchDownloadZipApi(DocumentResource):
"""Download multiple uploaded-file documents as a single ZIP (avoids browser multi-download limits)."""
@console_ns.doc("download_dataset_documents_as_zip")
@console_ns.doc(description="Download selected dataset documents as a single ZIP archive (upload-file only)")
@setup_required
@login_required
@account_initialization_required
@cloud_edition_billing_rate_limit_check("knowledge")
@console_ns.expect(console_ns.models[DocumentBatchDownloadZipPayload.__name__])
def post(self, dataset_id: str):
"""Stream a ZIP archive containing the requested uploaded documents."""
# Parse and validate request payload.
payload = DocumentBatchDownloadZipPayload.model_validate(console_ns.payload or {})
current_user, current_tenant_id = current_account_with_tenant()
dataset_id = str(dataset_id)
document_ids: list[str] = [str(document_id) for document_id in payload.document_ids]
upload_files, download_name = DocumentService.prepare_document_batch_download_zip(
dataset_id=dataset_id,
document_ids=document_ids,
tenant_id=current_tenant_id,
current_user=current_user,
)
# Delegate ZIP packing to FileService, but keep Flask response+cleanup in the route.
with ExitStack() as stack:
zip_path = stack.enter_context(FileService.build_upload_files_zip_tempfile(upload_files=upload_files))
response = send_file(
zip_path,
mimetype="application/zip",
as_attachment=True,
download_name=download_name,
)
cleanup = stack.pop_all()
response.call_on_close(cleanup.close)
return response
@console_ns.route("/datasets/<uuid:dataset_id>/documents/<uuid:document_id>/processing/<string:action>")
class DocumentProcessingApi(DocumentResource):
@console_ns.doc("update_document_processing")

View File

@ -320,17 +320,18 @@ class BasePluginClient:
case PluginInvokeError.__name__:
error_object = json.loads(message)
invoke_error_type = error_object.get("error_type")
args = error_object.get("args")
match invoke_error_type:
case InvokeRateLimitError.__name__:
raise InvokeRateLimitError(description=error_object.get("message"))
raise InvokeRateLimitError(description=args.get("description"))
case InvokeAuthorizationError.__name__:
raise InvokeAuthorizationError(description=error_object.get("message"))
raise InvokeAuthorizationError(description=args.get("description"))
case InvokeBadRequestError.__name__:
raise InvokeBadRequestError(description=error_object.get("message"))
raise InvokeBadRequestError(description=args.get("description"))
case InvokeConnectionError.__name__:
raise InvokeConnectionError(description=error_object.get("message"))
raise InvokeConnectionError(description=args.get("description"))
case InvokeServerUnavailableError.__name__:
raise InvokeServerUnavailableError(description=error_object.get("message"))
raise InvokeServerUnavailableError(description=args.get("description"))
case CredentialsValidateFailedError.__name__:
raise CredentialsValidateFailedError(error_object.get("message"))
case EndpointSetupFailedError.__name__:
@ -338,11 +339,11 @@ class BasePluginClient:
case TriggerProviderCredentialValidationError.__name__:
raise TriggerProviderCredentialValidationError(error_object.get("message"))
case TriggerPluginInvokeError.__name__:
raise TriggerPluginInvokeError(description=error_object.get("message"))
raise TriggerPluginInvokeError(description=error_object.get("description"))
case TriggerInvokeError.__name__:
raise TriggerInvokeError(error_object.get("message"))
case EventIgnoreError.__name__:
raise EventIgnoreError(description=error_object.get("message"))
raise EventIgnoreError(description=error_object.get("description"))
case _:
raise PluginInvokeError(description=message)
case PluginDaemonInternalServerError.__name__:

View File

@ -13,11 +13,10 @@ import sqlalchemy as sa
from redis.exceptions import LockNotOwnedError
from sqlalchemy import exists, func, select
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden, NotFound
from werkzeug.exceptions import NotFound
from configs import dify_config
from core.errors.error import LLMBadRequestError, ProviderTokenNotInitError
from core.file import helpers as file_helpers
from core.helper.name_generator import generate_incremental_name
from core.model_manager import ModelManager
from core.model_runtime.entities.model_entities import ModelFeature, ModelType
@ -74,7 +73,6 @@ from services.errors.document import DocumentIndexingError
from services.errors.file import FileNotExistsError
from services.external_knowledge_service import ExternalDatasetService
from services.feature_service import FeatureModel, FeatureService
from services.file_service import FileService
from services.rag_pipeline.rag_pipeline import RagPipelineService
from services.tag_service import TagService
from services.vector_service import VectorService
@ -1164,7 +1162,6 @@ class DocumentService:
Document.archived.is_(True),
),
}
DOCUMENT_BATCH_DOWNLOAD_ZIP_FILENAME_EXTENSION = ".zip"
@classmethod
def normalize_display_status(cls, status: str | None) -> str | None:
@ -1291,143 +1288,6 @@ class DocumentService:
else:
return None
@staticmethod
def get_documents_by_ids(dataset_id: str, document_ids: Sequence[str]) -> Sequence[Document]:
"""Fetch documents for a dataset in a single batch query."""
if not document_ids:
return []
document_id_list: list[str] = [str(document_id) for document_id in document_ids]
# Fetch all requested documents in one query to avoid N+1 lookups.
documents: Sequence[Document] = db.session.scalars(
select(Document).where(
Document.dataset_id == dataset_id,
Document.id.in_(document_id_list),
)
).all()
return documents
@staticmethod
def get_document_download_url(document: Document) -> str:
"""
Return a signed download URL for an upload-file document.
"""
upload_file = DocumentService._get_upload_file_for_upload_file_document(document)
return file_helpers.get_signed_file_url(upload_file_id=upload_file.id, as_attachment=True)
@staticmethod
def prepare_document_batch_download_zip(
*,
dataset_id: str,
document_ids: Sequence[str],
tenant_id: str,
current_user: Account,
) -> tuple[list[UploadFile], str]:
"""
Resolve upload files for batch ZIP downloads and generate a client-visible filename.
"""
dataset = DatasetService.get_dataset(dataset_id)
if not dataset:
raise NotFound("Dataset not found.")
try:
DatasetService.check_dataset_permission(dataset, current_user)
except NoPermissionError as e:
raise Forbidden(str(e))
upload_files_by_document_id = DocumentService._get_upload_files_by_document_id_for_zip_download(
dataset_id=dataset_id,
document_ids=document_ids,
tenant_id=tenant_id,
)
upload_files = [upload_files_by_document_id[document_id] for document_id in document_ids]
download_name = DocumentService._generate_document_batch_download_zip_filename()
return upload_files, download_name
@staticmethod
def _generate_document_batch_download_zip_filename() -> str:
"""
Generate a random attachment filename for the batch download ZIP.
"""
return f"{uuid.uuid4().hex}{DocumentService.DOCUMENT_BATCH_DOWNLOAD_ZIP_FILENAME_EXTENSION}"
@staticmethod
def _get_upload_file_id_for_upload_file_document(
document: Document,
*,
invalid_source_message: str,
missing_file_message: str,
) -> str:
"""
Normalize and validate `Document -> UploadFile` linkage for download flows.
"""
if document.data_source_type != "upload_file":
raise NotFound(invalid_source_message)
data_source_info: dict[str, Any] = document.data_source_info_dict or {}
upload_file_id: str | None = data_source_info.get("upload_file_id")
if not upload_file_id:
raise NotFound(missing_file_message)
return str(upload_file_id)
@staticmethod
def _get_upload_file_for_upload_file_document(document: Document) -> UploadFile:
"""
Load the `UploadFile` row for an upload-file document.
"""
upload_file_id = DocumentService._get_upload_file_id_for_upload_file_document(
document,
invalid_source_message="Document does not have an uploaded file to download.",
missing_file_message="Uploaded file not found.",
)
upload_files_by_id = FileService.get_upload_files_by_ids(document.tenant_id, [upload_file_id])
upload_file = upload_files_by_id.get(upload_file_id)
if not upload_file:
raise NotFound("Uploaded file not found.")
return upload_file
@staticmethod
def _get_upload_files_by_document_id_for_zip_download(
*,
dataset_id: str,
document_ids: Sequence[str],
tenant_id: str,
) -> dict[str, UploadFile]:
"""
Batch load upload files keyed by document id for ZIP downloads.
"""
document_id_list: list[str] = [str(document_id) for document_id in document_ids]
documents = DocumentService.get_documents_by_ids(dataset_id, document_id_list)
documents_by_id: dict[str, Document] = {str(document.id): document for document in documents}
missing_document_ids: set[str] = set(document_id_list) - set(documents_by_id.keys())
if missing_document_ids:
raise NotFound("Document not found.")
upload_file_ids: list[str] = []
upload_file_ids_by_document_id: dict[str, str] = {}
for document_id, document in documents_by_id.items():
if document.tenant_id != tenant_id:
raise Forbidden("No permission.")
upload_file_id = DocumentService._get_upload_file_id_for_upload_file_document(
document,
invalid_source_message="Only uploaded-file documents can be downloaded as ZIP.",
missing_file_message="Only uploaded-file documents can be downloaded as ZIP.",
)
upload_file_ids.append(upload_file_id)
upload_file_ids_by_document_id[document_id] = upload_file_id
upload_files_by_id = FileService.get_upload_files_by_ids(tenant_id, upload_file_ids)
missing_upload_file_ids: set[str] = set(upload_file_ids) - set(upload_files_by_id.keys())
if missing_upload_file_ids:
raise NotFound("Only uploaded-file documents can be downloaded as ZIP.")
return {
document_id: upload_files_by_id[upload_file_id]
for document_id, upload_file_id in upload_file_ids_by_document_id.items()
}
@staticmethod
def get_document_by_id(document_id: str) -> Document | None:
document = db.session.query(Document).where(Document.id == document_id).first()

View File

@ -2,11 +2,7 @@ import base64
import hashlib
import os
import uuid
from collections.abc import Iterator, Sequence
from contextlib import contextmanager, suppress
from tempfile import NamedTemporaryFile
from typing import Literal, Union
from zipfile import ZIP_DEFLATED, ZipFile
from sqlalchemy import Engine, select
from sqlalchemy.orm import Session, sessionmaker
@ -21,7 +17,6 @@ from constants import (
)
from core.file import helpers as file_helpers
from core.rag.extractor.extract_processor import ExtractProcessor
from extensions.ext_database import db
from extensions.ext_storage import storage
from libs.datetime_utils import naive_utc_now
from libs.helper import extract_tenant_id
@ -172,9 +167,6 @@ class FileService:
return upload_file
def get_file_preview(self, file_id: str):
"""
Return a short text preview extracted from a document file.
"""
with self._session_maker(expire_on_commit=False) as session:
upload_file = session.query(UploadFile).where(UploadFile.id == file_id).first()
@ -261,101 +253,3 @@ class FileService:
return
storage.delete(upload_file.key)
session.delete(upload_file)
@staticmethod
def get_upload_files_by_ids(tenant_id: str, upload_file_ids: Sequence[str]) -> dict[str, UploadFile]:
"""
Fetch `UploadFile` rows for a tenant in a single batch query.
This is a generic `UploadFile` lookup helper (not dataset/document specific), so it lives in `FileService`.
"""
if not upload_file_ids:
return {}
# Normalize and deduplicate ids before using them in the IN clause.
upload_file_id_list: list[str] = [str(upload_file_id) for upload_file_id in upload_file_ids]
unique_upload_file_ids: list[str] = list(set(upload_file_id_list))
# Fetch upload files in one query for efficient batch access.
upload_files: Sequence[UploadFile] = db.session.scalars(
select(UploadFile).where(
UploadFile.tenant_id == tenant_id,
UploadFile.id.in_(unique_upload_file_ids),
)
).all()
return {str(upload_file.id): upload_file for upload_file in upload_files}
@staticmethod
def _sanitize_zip_entry_name(name: str) -> str:
"""
Sanitize a ZIP entry name to avoid path traversal and weird separators.
We keep this conservative: the upload flow already rejects `/` and `\\`, but older rows (or imported data)
could still contain unsafe names.
"""
# Drop any directory components and prevent empty names.
base = os.path.basename(name).strip() or "file"
# ZIP uses forward slashes as separators; remove any residual separator characters.
return base.replace("/", "_").replace("\\", "_")
@staticmethod
def _dedupe_zip_entry_name(original_name: str, used_names: set[str]) -> str:
"""
Return a unique ZIP entry name, inserting suffixes before the extension.
"""
# Keep the original name when it's not already used.
if original_name not in used_names:
return original_name
# Insert suffixes before the extension (e.g., "doc.txt" -> "doc (1).txt").
stem, extension = os.path.splitext(original_name)
suffix = 1
while True:
candidate = f"{stem} ({suffix}){extension}"
if candidate not in used_names:
return candidate
suffix += 1
@staticmethod
@contextmanager
def build_upload_files_zip_tempfile(
*,
upload_files: Sequence[UploadFile],
) -> Iterator[str]:
"""
Build a ZIP from `UploadFile`s and yield a tempfile path.
We yield a path (rather than an open file handle) to avoid "read of closed file" issues when Flask/Werkzeug
streams responses. The caller is expected to keep this context open until the response is fully sent, then
close it (e.g., via `response.call_on_close(...)`) to delete the tempfile.
"""
used_names: set[str] = set()
# Build a ZIP in a temp file and keep it on disk until the caller finishes streaming it.
tmp_path: str | None = None
try:
with NamedTemporaryFile(mode="w+b", suffix=".zip", delete=False) as tmp:
tmp_path = tmp.name
with ZipFile(tmp, mode="w", compression=ZIP_DEFLATED) as zf:
for upload_file in upload_files:
# Ensure the entry name is safe and unique.
safe_name = FileService._sanitize_zip_entry_name(upload_file.name)
arcname = FileService._dedupe_zip_entry_name(safe_name, used_names)
used_names.add(arcname)
# Stream file bytes from storage into the ZIP entry.
with zf.open(arcname, "w") as entry:
for chunk in storage.load(upload_file.key, stream=True):
entry.write(chunk)
# Flush so `send_file(path, ...)` can re-open it safely on all platforms.
tmp.flush()
assert tmp_path is not None
yield tmp_path
finally:
# Remove the temp file when the context is closed (typically after the response finishes streaming).
if tmp_path is not None:
with suppress(FileNotFoundError):
os.remove(tmp_path)

View File

@ -0,0 +1,254 @@
"""
Unit tests for XSS prevention in App payloads.
This test module validates that HTML tags, JavaScript, and other potentially
dangerous content are rejected in App names and descriptions.
"""
import pytest
from controllers.console.app.app import CopyAppPayload, CreateAppPayload, UpdateAppPayload
class TestXSSPreventionUnit:
"""Unit tests for XSS prevention in App payloads."""
def test_create_app_valid_names(self):
"""Test CreateAppPayload with valid app names."""
# Normal app names should be valid
valid_names = [
"My App",
"Test App 123",
"App with - dash",
"App with _ underscore",
"App with + plus",
"App with () parentheses",
"App with [] brackets",
"App with {} braces",
"App with ! exclamation",
"App with @ at",
"App with # hash",
"App with $ dollar",
"App with % percent",
"App with ^ caret",
"App with & ampersand",
"App with * asterisk",
"Unicode: 测试应用",
"Emoji: 🤖",
"Mixed: Test 测试 123",
]
for name in valid_names:
payload = CreateAppPayload(
name=name,
mode="chat",
)
assert payload.name == name
def test_create_app_xss_script_tags(self):
"""Test CreateAppPayload rejects script tags."""
xss_payloads = [
"<script>alert(document.cookie)</script>",
"<Script>alert(1)</Script>",
"<SCRIPT>alert('XSS')</SCRIPT>",
"<script>alert(String.fromCharCode(88,83,83))</script>",
"<script src='evil.js'></script>",
"<script>document.location='http://evil.com'</script>",
]
for name in xss_payloads:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_xss_iframe_tags(self):
"""Test CreateAppPayload rejects iframe tags."""
xss_payloads = [
"<iframe src='evil.com'></iframe>",
"<Iframe srcdoc='<script>alert(1)</script>'></iframe>",
"<IFRAME src='javascript:alert(1)'></iframe>",
]
for name in xss_payloads:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_xss_javascript_protocol(self):
"""Test CreateAppPayload rejects javascript: protocol."""
xss_payloads = [
"javascript:alert(1)",
"JAVASCRIPT:alert(1)",
"JavaScript:alert(document.cookie)",
"javascript:void(0)",
"javascript://comment%0Aalert(1)",
]
for name in xss_payloads:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_xss_svg_onload(self):
"""Test CreateAppPayload rejects SVG with onload."""
xss_payloads = [
"<svg onload=alert(1)>",
"<SVG ONLOAD=alert(1)>",
"<svg/x/onload=alert(1)>",
]
for name in xss_payloads:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_xss_event_handlers(self):
"""Test CreateAppPayload rejects HTML event handlers."""
xss_payloads = [
"<div onclick=alert(1)>",
"<img onerror=alert(1)>",
"<body onload=alert(1)>",
"<input onfocus=alert(1)>",
"<a onmouseover=alert(1)>",
"<DIV ONCLICK=alert(1)>",
"<img src=x onerror=alert(1)>",
]
for name in xss_payloads:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_xss_object_embed(self):
"""Test CreateAppPayload rejects object and embed tags."""
xss_payloads = [
"<object data='evil.swf'></object>",
"<embed src='evil.swf'>",
"<OBJECT data='javascript:alert(1)'></OBJECT>",
]
for name in xss_payloads:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_xss_link_javascript(self):
"""Test CreateAppPayload rejects link tags with javascript."""
xss_payloads = [
"<link href='javascript:alert(1)'>",
"<LINK HREF='javascript:alert(1)'>",
]
for name in xss_payloads:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_xss_in_description(self):
"""Test CreateAppPayload rejects XSS in description."""
xss_descriptions = [
"<script>alert(1)</script>",
"javascript:alert(1)",
"<img onerror=alert(1)>",
]
for description in xss_descriptions:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(
name="Valid Name",
mode="chat",
description=description,
)
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_valid_descriptions(self):
"""Test CreateAppPayload with valid descriptions."""
valid_descriptions = [
"A simple description",
"Description with < and > symbols",
"Description with & ampersand",
"Description with 'quotes' and \"double quotes\"",
"Description with / slashes",
"Description with \\ backslashes",
"Description with ; semicolons",
"Unicode: 这是一个描述",
"Emoji: 🎉🚀",
]
for description in valid_descriptions:
payload = CreateAppPayload(
name="Valid App Name",
mode="chat",
description=description,
)
assert payload.description == description
def test_create_app_none_description(self):
"""Test CreateAppPayload with None description."""
payload = CreateAppPayload(
name="Valid App Name",
mode="chat",
description=None,
)
assert payload.description is None
def test_update_app_xss_prevention(self):
"""Test UpdateAppPayload also prevents XSS."""
xss_names = [
"<script>alert(1)</script>",
"javascript:alert(1)",
"<img onerror=alert(1)>",
]
for name in xss_names:
with pytest.raises(ValueError) as exc_info:
UpdateAppPayload(name=name)
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_update_app_valid_names(self):
"""Test UpdateAppPayload with valid names."""
payload = UpdateAppPayload(name="Valid Updated Name")
assert payload.name == "Valid Updated Name"
def test_copy_app_xss_prevention(self):
"""Test CopyAppPayload also prevents XSS."""
xss_names = [
"<script>alert(1)</script>",
"javascript:alert(1)",
"<img onerror=alert(1)>",
]
for name in xss_names:
with pytest.raises(ValueError) as exc_info:
CopyAppPayload(name=name)
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_copy_app_valid_names(self):
"""Test CopyAppPayload with valid names."""
payload = CopyAppPayload(name="Valid Copy Name")
assert payload.name == "Valid Copy Name"
def test_copy_app_none_name(self):
"""Test CopyAppPayload with None name (should be allowed)."""
payload = CopyAppPayload(name=None)
assert payload.name is None
def test_edge_case_angle_brackets_content(self):
"""Test that angle brackets with actual content are rejected."""
# Angle brackets without valid HTML-like patterns should be checked
# The regex pattern <.*?on\w+\s*= should catch event handlers
# But let's verify other patterns too
# Valid: angle brackets used as symbols (not matched by our patterns)
# Our patterns specifically look for dangerous constructs
# Invalid: actual HTML tags with event handlers
invalid_names = [
"<div onclick=xss>",
"<img src=x onerror=alert(1)>",
]
for name in invalid_names:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()

View File

@ -1,430 +0,0 @@
"""
Unit tests for the dataset document download endpoint.
These tests validate that the controller returns a signed download URL for
upload-file documents, and rejects unsupported or missing file cases.
"""
from __future__ import annotations
import importlib
import sys
from collections import UserDict
from io import BytesIO
from types import SimpleNamespace
from typing import Any
from zipfile import ZipFile
import pytest
from flask import Flask
from werkzeug.exceptions import Forbidden, NotFound
@pytest.fixture
def app() -> Flask:
"""Create a minimal Flask app for request-context based controller tests."""
app = Flask(__name__)
app.config["TESTING"] = True
return app
@pytest.fixture
def datasets_document_module(monkeypatch: pytest.MonkeyPatch):
"""
Reload `controllers.console.datasets.datasets_document` with lightweight decorators.
We patch auth / setup / rate-limit decorators to no-ops so we can unit test the
controller logic without requiring the full console stack.
"""
from controllers.console import console_ns, wraps
from libs import login
def _noop(func): # type: ignore[no-untyped-def]
return func
# Bypass login/setup/account checks in unit tests.
monkeypatch.setattr(login, "login_required", _noop)
monkeypatch.setattr(wraps, "setup_required", _noop)
monkeypatch.setattr(wraps, "account_initialization_required", _noop)
# Bypass billing-related decorators used by other endpoints in this module.
monkeypatch.setattr(wraps, "cloud_edition_billing_resource_check", lambda *_args, **_kwargs: (lambda f: f))
monkeypatch.setattr(wraps, "cloud_edition_billing_rate_limit_check", lambda *_args, **_kwargs: (lambda f: f))
# Avoid Flask-RESTX route registration side effects during import.
def _noop_route(*_args, **_kwargs): # type: ignore[override]
def _decorator(cls):
return cls
return _decorator
monkeypatch.setattr(console_ns, "route", _noop_route)
module_name = "controllers.console.datasets.datasets_document"
sys.modules.pop(module_name, None)
return importlib.import_module(module_name)
def _mock_user(*, is_dataset_editor: bool = True) -> SimpleNamespace:
"""Build a minimal user object compatible with dataset permission checks."""
return SimpleNamespace(is_dataset_editor=is_dataset_editor, id="user-123")
def _mock_document(
*,
document_id: str,
tenant_id: str,
data_source_type: str,
upload_file_id: str | None,
) -> SimpleNamespace:
"""Build a minimal document object used by the controller."""
data_source_info_dict: dict[str, Any] | None = None
if upload_file_id is not None:
data_source_info_dict = {"upload_file_id": upload_file_id}
else:
data_source_info_dict = {}
return SimpleNamespace(
id=document_id,
tenant_id=tenant_id,
data_source_type=data_source_type,
data_source_info_dict=data_source_info_dict,
)
def _wire_common_success_mocks(
*,
module,
monkeypatch: pytest.MonkeyPatch,
current_tenant_id: str,
document_tenant_id: str,
data_source_type: str,
upload_file_id: str | None,
upload_file_exists: bool,
signed_url: str,
) -> None:
"""Patch controller dependencies to create a deterministic test environment."""
import services.dataset_service as dataset_service_module
# Make `current_account_with_tenant()` return a known user + tenant id.
monkeypatch.setattr(module, "current_account_with_tenant", lambda: (_mock_user(), current_tenant_id))
# Return a dataset object and allow permission checks to pass.
monkeypatch.setattr(module.DatasetService, "get_dataset", lambda _dataset_id: SimpleNamespace(id="ds-1"))
monkeypatch.setattr(module.DatasetService, "check_dataset_permission", lambda *_args, **_kwargs: None)
# Return a document that will be validated inside DocumentResource.get_document.
document = _mock_document(
document_id="doc-1",
tenant_id=document_tenant_id,
data_source_type=data_source_type,
upload_file_id=upload_file_id,
)
monkeypatch.setattr(module.DocumentService, "get_document", lambda *_args, **_kwargs: document)
# Mock UploadFile lookup via FileService batch helper.
upload_files_by_id: dict[str, Any] = {}
if upload_file_exists and upload_file_id is not None:
upload_files_by_id[str(upload_file_id)] = SimpleNamespace(id=str(upload_file_id))
monkeypatch.setattr(module.FileService, "get_upload_files_by_ids", lambda *_args, **_kwargs: upload_files_by_id)
# Mock signing helper so the returned URL is deterministic.
monkeypatch.setattr(dataset_service_module.file_helpers, "get_signed_file_url", lambda **_kwargs: signed_url)
def _mock_send_file(obj, **kwargs): # type: ignore[no-untyped-def]
"""Return a lightweight representation of `send_file(...)` for unit tests."""
class _ResponseMock(UserDict):
def __init__(self, sent_file: object, send_file_kwargs: dict[str, object]) -> None:
super().__init__({"_sent_file": sent_file, "_send_file_kwargs": send_file_kwargs})
self._on_close: object | None = None
def call_on_close(self, func): # type: ignore[no-untyped-def]
self._on_close = func
return func
return _ResponseMock(obj, kwargs)
def test_batch_download_zip_returns_send_file(
app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Ensure batch ZIP download returns a zip attachment via `send_file`."""
# Arrange common permission mocks.
monkeypatch.setattr(datasets_document_module, "current_account_with_tenant", lambda: (_mock_user(), "tenant-123"))
monkeypatch.setattr(
datasets_document_module.DatasetService, "get_dataset", lambda _dataset_id: SimpleNamespace(id="ds-1")
)
monkeypatch.setattr(
datasets_document_module.DatasetService, "check_dataset_permission", lambda *_args, **_kwargs: None
)
# Two upload-file documents, each referencing an UploadFile.
doc1 = _mock_document(
document_id="11111111-1111-1111-1111-111111111111",
tenant_id="tenant-123",
data_source_type="upload_file",
upload_file_id="file-1",
)
doc2 = _mock_document(
document_id="22222222-2222-2222-2222-222222222222",
tenant_id="tenant-123",
data_source_type="upload_file",
upload_file_id="file-2",
)
monkeypatch.setattr(
datasets_document_module.DocumentService,
"get_documents_by_ids",
lambda *_args, **_kwargs: [doc1, doc2],
)
monkeypatch.setattr(
datasets_document_module.FileService,
"get_upload_files_by_ids",
lambda *_args, **_kwargs: {
"file-1": SimpleNamespace(id="file-1", name="a.txt", key="k1"),
"file-2": SimpleNamespace(id="file-2", name="b.txt", key="k2"),
},
)
# Mock storage streaming content.
import services.file_service as file_service_module
monkeypatch.setattr(file_service_module.storage, "load", lambda _key, stream=True: [b"hello"])
# Replace send_file used by the controller to avoid a real Flask response object.
monkeypatch.setattr(datasets_document_module, "send_file", _mock_send_file)
# Act
with app.test_request_context(
"/datasets/ds-1/documents/download-zip",
method="POST",
json={"document_ids": ["11111111-1111-1111-1111-111111111111", "22222222-2222-2222-2222-222222222222"]},
):
api = datasets_document_module.DocumentBatchDownloadZipApi()
result = api.post(dataset_id="ds-1")
# Assert: we returned via send_file with correct mime type and attachment.
assert result["_send_file_kwargs"]["mimetype"] == "application/zip"
assert result["_send_file_kwargs"]["as_attachment"] is True
assert isinstance(result["_send_file_kwargs"]["download_name"], str)
assert result["_send_file_kwargs"]["download_name"].endswith(".zip")
# Ensure our cleanup hook is registered and execute it to avoid temp file leaks in unit tests.
assert getattr(result, "_on_close", None) is not None
result._on_close() # type: ignore[attr-defined]
def test_batch_download_zip_response_is_openable_zip(
app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Ensure the real Flask `send_file` response body is a valid ZIP that can be opened."""
# Arrange: same controller mocks as the lightweight send_file test, but we keep the real `send_file`.
monkeypatch.setattr(datasets_document_module, "current_account_with_tenant", lambda: (_mock_user(), "tenant-123"))
monkeypatch.setattr(
datasets_document_module.DatasetService, "get_dataset", lambda _dataset_id: SimpleNamespace(id="ds-1")
)
monkeypatch.setattr(
datasets_document_module.DatasetService, "check_dataset_permission", lambda *_args, **_kwargs: None
)
doc1 = _mock_document(
document_id="33333333-3333-3333-3333-333333333333",
tenant_id="tenant-123",
data_source_type="upload_file",
upload_file_id="file-1",
)
doc2 = _mock_document(
document_id="44444444-4444-4444-4444-444444444444",
tenant_id="tenant-123",
data_source_type="upload_file",
upload_file_id="file-2",
)
monkeypatch.setattr(
datasets_document_module.DocumentService,
"get_documents_by_ids",
lambda *_args, **_kwargs: [doc1, doc2],
)
monkeypatch.setattr(
datasets_document_module.FileService,
"get_upload_files_by_ids",
lambda *_args, **_kwargs: {
"file-1": SimpleNamespace(id="file-1", name="a.txt", key="k1"),
"file-2": SimpleNamespace(id="file-2", name="b.txt", key="k2"),
},
)
# Stream distinct bytes per key so we can verify both ZIP entries.
import services.file_service as file_service_module
monkeypatch.setattr(
file_service_module.storage, "load", lambda key, stream=True: [b"one"] if key == "k1" else [b"two"]
)
# Act
with app.test_request_context(
"/datasets/ds-1/documents/download-zip",
method="POST",
json={"document_ids": ["33333333-3333-3333-3333-333333333333", "44444444-4444-4444-4444-444444444444"]},
):
api = datasets_document_module.DocumentBatchDownloadZipApi()
response = api.post(dataset_id="ds-1")
# Assert: response body is a valid ZIP and contains the expected entries.
response.direct_passthrough = False
data = response.get_data()
response.close()
with ZipFile(BytesIO(data), mode="r") as zf:
assert zf.namelist() == ["a.txt", "b.txt"]
assert zf.read("a.txt") == b"one"
assert zf.read("b.txt") == b"two"
def test_batch_download_zip_rejects_non_upload_file_document(
app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Ensure batch ZIP download rejects non upload-file documents."""
monkeypatch.setattr(datasets_document_module, "current_account_with_tenant", lambda: (_mock_user(), "tenant-123"))
monkeypatch.setattr(
datasets_document_module.DatasetService, "get_dataset", lambda _dataset_id: SimpleNamespace(id="ds-1")
)
monkeypatch.setattr(
datasets_document_module.DatasetService, "check_dataset_permission", lambda *_args, **_kwargs: None
)
doc = _mock_document(
document_id="55555555-5555-5555-5555-555555555555",
tenant_id="tenant-123",
data_source_type="website_crawl",
upload_file_id="file-1",
)
monkeypatch.setattr(
datasets_document_module.DocumentService,
"get_documents_by_ids",
lambda *_args, **_kwargs: [doc],
)
with app.test_request_context(
"/datasets/ds-1/documents/download-zip",
method="POST",
json={"document_ids": ["55555555-5555-5555-5555-555555555555"]},
):
api = datasets_document_module.DocumentBatchDownloadZipApi()
with pytest.raises(NotFound):
api.post(dataset_id="ds-1")
def test_document_download_returns_url_for_upload_file_document(
app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Ensure upload-file documents return a `{url}` JSON payload."""
_wire_common_success_mocks(
module=datasets_document_module,
monkeypatch=monkeypatch,
current_tenant_id="tenant-123",
document_tenant_id="tenant-123",
data_source_type="upload_file",
upload_file_id="file-123",
upload_file_exists=True,
signed_url="https://example.com/signed",
)
# Build a request context then call the resource method directly.
with app.test_request_context("/datasets/ds-1/documents/doc-1/download", method="GET"):
api = datasets_document_module.DocumentDownloadApi()
result = api.get(dataset_id="ds-1", document_id="doc-1")
assert result == {"url": "https://example.com/signed"}
def test_document_download_rejects_non_upload_file_document(
app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Ensure non-upload documents raise 404 (no file to download)."""
_wire_common_success_mocks(
module=datasets_document_module,
monkeypatch=monkeypatch,
current_tenant_id="tenant-123",
document_tenant_id="tenant-123",
data_source_type="website_crawl",
upload_file_id="file-123",
upload_file_exists=True,
signed_url="https://example.com/signed",
)
with app.test_request_context("/datasets/ds-1/documents/doc-1/download", method="GET"):
api = datasets_document_module.DocumentDownloadApi()
with pytest.raises(NotFound):
api.get(dataset_id="ds-1", document_id="doc-1")
def test_document_download_rejects_missing_upload_file_id(
app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Ensure missing `upload_file_id` raises 404."""
_wire_common_success_mocks(
module=datasets_document_module,
monkeypatch=monkeypatch,
current_tenant_id="tenant-123",
document_tenant_id="tenant-123",
data_source_type="upload_file",
upload_file_id=None,
upload_file_exists=False,
signed_url="https://example.com/signed",
)
with app.test_request_context("/datasets/ds-1/documents/doc-1/download", method="GET"):
api = datasets_document_module.DocumentDownloadApi()
with pytest.raises(NotFound):
api.get(dataset_id="ds-1", document_id="doc-1")
def test_document_download_rejects_when_upload_file_record_missing(
app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Ensure missing UploadFile row raises 404."""
_wire_common_success_mocks(
module=datasets_document_module,
monkeypatch=monkeypatch,
current_tenant_id="tenant-123",
document_tenant_id="tenant-123",
data_source_type="upload_file",
upload_file_id="file-123",
upload_file_exists=False,
signed_url="https://example.com/signed",
)
with app.test_request_context("/datasets/ds-1/documents/doc-1/download", method="GET"):
api = datasets_document_module.DocumentDownloadApi()
with pytest.raises(NotFound):
api.get(dataset_id="ds-1", document_id="doc-1")
def test_document_download_rejects_tenant_mismatch(
app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Ensure tenant mismatch is rejected by the shared `get_document()` permission check."""
_wire_common_success_mocks(
module=datasets_document_module,
monkeypatch=monkeypatch,
current_tenant_id="tenant-123",
document_tenant_id="tenant-999",
data_source_type="upload_file",
upload_file_id="file-123",
upload_file_exists=True,
signed_url="https://example.com/signed",
)
with app.test_request_context("/datasets/ds-1/documents/doc-1/download", method="GET"):
api = datasets_document_module.DocumentDownloadApi()
with pytest.raises(Forbidden):
api.get(dataset_id="ds-1", document_id="doc-1")

View File

@ -346,7 +346,6 @@ class TestPluginRuntimeErrorHandling:
mock_response.status_code = 200
invoke_error = {
"error_type": "InvokeRateLimitError",
"message": "Rate limit exceeded",
"args": {"description": "Rate limit exceeded"},
}
error_message = json.dumps({"error_type": "PluginInvokeError", "message": json.dumps(invoke_error)})
@ -365,7 +364,6 @@ class TestPluginRuntimeErrorHandling:
mock_response.status_code = 200
invoke_error = {
"error_type": "InvokeAuthorizationError",
"message": "Invalid credentials",
"args": {"description": "Invalid credentials"},
}
error_message = json.dumps({"error_type": "PluginInvokeError", "message": json.dumps(invoke_error)})
@ -384,7 +382,6 @@ class TestPluginRuntimeErrorHandling:
mock_response.status_code = 200
invoke_error = {
"error_type": "InvokeBadRequestError",
"message": "Invalid parameters",
"args": {"description": "Invalid parameters"},
}
error_message = json.dumps({"error_type": "PluginInvokeError", "message": json.dumps(invoke_error)})
@ -403,7 +400,6 @@ class TestPluginRuntimeErrorHandling:
mock_response.status_code = 200
invoke_error = {
"error_type": "InvokeConnectionError",
"message": "Connection to external service failed",
"args": {"description": "Connection to external service failed"},
}
error_message = json.dumps({"error_type": "PluginInvokeError", "message": json.dumps(invoke_error)})
@ -422,7 +418,6 @@ class TestPluginRuntimeErrorHandling:
mock_response.status_code = 200
invoke_error = {
"error_type": "InvokeServerUnavailableError",
"message": "Service temporarily unavailable",
"args": {"description": "Service temporarily unavailable"},
}
error_message = json.dumps({"error_type": "PluginInvokeError", "message": json.dumps(invoke_error)})

View File

@ -1,99 +0,0 @@
"""
Unit tests for `services.file_service.FileService` helpers.
We keep these tests focused on:
- ZIP tempfile building (sanitization + deduplication + content writes)
- tenant-scoped batch lookup behavior (`get_upload_files_by_ids`)
"""
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
from zipfile import ZipFile
import pytest
import services.file_service as file_service_module
from services.file_service import FileService
def test_build_upload_files_zip_tempfile_sanitizes_and_dedupes_names(monkeypatch: pytest.MonkeyPatch) -> None:
"""Ensure ZIP entry names are safe and unique while preserving extensions."""
# Arrange: three upload files that all sanitize down to the same basename ("b.txt").
upload_files: list[Any] = [
SimpleNamespace(name="a/b.txt", key="k1"),
SimpleNamespace(name="c/b.txt", key="k2"),
SimpleNamespace(name="../b.txt", key="k3"),
]
# Stream distinct bytes per key so we can verify content is written to the right entry.
data_by_key: dict[str, list[bytes]] = {"k1": [b"one"], "k2": [b"two"], "k3": [b"three"]}
def _load(key: str, stream: bool = True) -> list[bytes]:
# Return the corresponding chunks for this key (the production code iterates chunks).
assert stream is True
return data_by_key[key]
monkeypatch.setattr(file_service_module.storage, "load", _load)
# Act: build zip in a tempfile.
with FileService.build_upload_files_zip_tempfile(upload_files=upload_files) as tmp:
with ZipFile(tmp, mode="r") as zf:
# Assert: names are sanitized (no directory components) and deduped with suffixes.
assert zf.namelist() == ["b.txt", "b (1).txt", "b (2).txt"]
# Assert: each entry contains the correct bytes from storage.
assert zf.read("b.txt") == b"one"
assert zf.read("b (1).txt") == b"two"
assert zf.read("b (2).txt") == b"three"
def test_get_upload_files_by_ids_returns_empty_when_no_ids(monkeypatch: pytest.MonkeyPatch) -> None:
"""Ensure empty input returns an empty mapping without hitting the database."""
class _Session:
def scalars(self, _stmt): # type: ignore[no-untyped-def]
raise AssertionError("db.session.scalars should not be called for empty id lists")
monkeypatch.setattr(file_service_module, "db", SimpleNamespace(session=_Session()))
assert FileService.get_upload_files_by_ids("tenant-1", []) == {}
def test_get_upload_files_by_ids_returns_id_keyed_mapping(monkeypatch: pytest.MonkeyPatch) -> None:
"""Ensure batch lookup returns a dict keyed by stringified UploadFile ids."""
upload_files: list[Any] = [
SimpleNamespace(id="file-1", tenant_id="tenant-1"),
SimpleNamespace(id="file-2", tenant_id="tenant-1"),
]
class _ScalarResult:
def __init__(self, items: list[Any]) -> None:
self._items = items
def all(self) -> list[Any]:
return self._items
class _Session:
def __init__(self, items: list[Any]) -> None:
self._items = items
self.calls: list[object] = []
def scalars(self, stmt): # type: ignore[no-untyped-def]
# Capture the statement so we can at least assert the query path is taken.
self.calls.append(stmt)
return _ScalarResult(self._items)
session = _Session(upload_files)
monkeypatch.setattr(file_service_module, "db", SimpleNamespace(session=session))
# Provide duplicates to ensure callers can safely pass repeated ids.
result = FileService.get_upload_files_by_ids("tenant-1", ["file-1", "file-1", "file-2"])
assert set(result.keys()) == {"file-1", "file-2"}
assert result["file-1"].id == "file-1"
assert result["file-2"].id == "file-2"
assert len(session.calls) == 1

View File

@ -1 +0,0 @@
save-exact=true

View File

@ -1,8 +1,6 @@
{
"recommendations": [
"bradlc.vscode-tailwindcss",
"kisstkondoros.vscode-codemetrics",
"johnsoncodehk.vscode-tsslint",
"dbaeumer.vscode-eslint"
"kisstkondoros.vscode-codemetrics"
]
}

View File

@ -1,7 +1,6 @@
'use client'
import type { FC } from 'react'
import type { NavIcon } from '@/app/components/app-sidebar/navLink'
import type { App } from '@/types/app'
import {
RiDashboard2Fill,
RiDashboard2Line,
@ -12,13 +11,11 @@ import {
RiTerminalWindowFill,
RiTerminalWindowLine,
} from '@remixicon/react'
import { useUnmount } from 'ahooks'
import dynamic from 'next/dynamic'
import { usePathname, useRouter } from 'next/navigation'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useShallow } from 'zustand/react/shallow'
import AppSideBar from '@/app/components/app-sidebar'
import { useStore } from '@/app/components/app/store'
import Loading from '@/app/components/base/loading'
@ -26,7 +23,7 @@ import { useStore as useTagStore } from '@/app/components/base/tag-management/st
import { useAppContext } from '@/context/app-context'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useDocumentTitle from '@/hooks/use-document-title'
import { fetchAppDetailDirect } from '@/service/apps'
import { useAppDetail } from '@/service/use-apps'
import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames'
import s from './style.module.css'
@ -41,47 +38,41 @@ export type IAppDetailLayoutProps = {
}
const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
const {
children,
appId, // get appId in path
} = props
const { children, appId } = props
const { t } = useTranslation()
const router = useRouter()
const pathname = usePathname()
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const { isCurrentWorkspaceEditor, isLoadingCurrentWorkspace, currentWorkspace } = useAppContext()
const { appDetail, setAppDetail, setAppSidebarExpand } = useStore(useShallow(state => ({
appDetail: state.appDetail,
setAppDetail: state.setAppDetail,
setAppSidebarExpand: state.setAppSidebarExpand,
})))
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
const [isLoadingAppDetail, setIsLoadingAppDetail] = useState(false)
const [appDetailRes, setAppDetailRes] = useState<App | null>(null)
const [navigation, setNavigation] = useState<Array<{
name: string
href: string
icon: NavIcon
selectedIcon: NavIcon
}>>([])
const getNavigationConfig = useCallback((appId: string, isCurrentWorkspaceEditor: boolean, mode: AppModeEnum) => {
const navConfig = [
const { isCurrentWorkspaceEditor, isLoadingCurrentWorkspace } = useAppContext()
const setAppSidebarExpand = useStore(s => s.setAppSidebarExpand)
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
const { data: appDetail, isPending, error } = useAppDetail(appId)
const navigation = useMemo(() => {
if (!appDetail)
return []
const mode = appDetail.mode
const isWorkflowMode = mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT
return [
...(isCurrentWorkspaceEditor
? [{
name: t('appMenus.promptEng', { ns: 'common' }),
href: `/app/${appId}/${(mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT) ? 'workflow' : 'configuration'}`,
icon: RiTerminalWindowLine,
selectedIcon: RiTerminalWindowFill,
href: `/app/${appId}/${isWorkflowMode ? 'workflow' : 'configuration'}`,
icon: RiTerminalWindowLine as NavIcon,
selectedIcon: RiTerminalWindowFill as NavIcon,
}]
: []
),
{
name: t('appMenus.apiAccess', { ns: 'common' }),
href: `/app/${appId}/develop`,
icon: RiTerminalBoxLine,
selectedIcon: RiTerminalBoxFill,
icon: RiTerminalBoxLine as NavIcon,
selectedIcon: RiTerminalBoxFill as NavIcon,
},
...(isCurrentWorkspaceEditor
? [{
@ -89,74 +80,64 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
? t('appMenus.logAndAnn', { ns: 'common' })
: t('appMenus.logs', { ns: 'common' }),
href: `/app/${appId}/logs`,
icon: RiFileList3Line,
selectedIcon: RiFileList3Fill,
icon: RiFileList3Line as NavIcon,
selectedIcon: RiFileList3Fill as NavIcon,
}]
: []
),
{
name: t('appMenus.overview', { ns: 'common' }),
href: `/app/${appId}/overview`,
icon: RiDashboard2Line,
selectedIcon: RiDashboard2Fill,
icon: RiDashboard2Line as NavIcon,
selectedIcon: RiDashboard2Fill as NavIcon,
},
]
return navConfig
}, [t])
}, [appDetail, appId, isCurrentWorkspaceEditor, t])
useDocumentTitle(appDetail?.name || t('menus.appDetail', { ns: 'common' }))
useEffect(() => {
if (appDetail) {
const localeMode = localStorage.getItem('app-detail-collapse-or-expand') || 'expand'
const mode = isMobile ? 'collapse' : 'expand'
setAppSidebarExpand(isMobile ? mode : localeMode)
// TODO: consider screen size and mode
// if ((appDetail.mode === AppModeEnum.ADVANCED_CHAT || appDetail.mode === 'workflow') && (pathname).endsWith('workflow'))
// setAppSidebarExpand('collapse')
}
}, [appDetail, isMobile])
useEffect(() => {
setAppDetail()
setIsLoadingAppDetail(true)
fetchAppDetailDirect({ url: '/apps', id: appId }).then((res: App) => {
setAppDetailRes(res)
}).catch((e: any) => {
if (e.status === 404)
router.replace('/apps')
}).finally(() => {
setIsLoadingAppDetail(false)
})
}, [appId, pathname])
useEffect(() => {
if (!appDetailRes || !currentWorkspace.id || isLoadingCurrentWorkspace || isLoadingAppDetail)
if (!appDetail)
return
const res = appDetailRes
// redirection
const canIEditApp = isCurrentWorkspaceEditor
if (!canIEditApp && (pathname.endsWith('configuration') || pathname.endsWith('workflow') || pathname.endsWith('logs'))) {
router.replace(`/app/${appId}/overview`)
return
}
if ((res.mode === AppModeEnum.WORKFLOW || res.mode === AppModeEnum.ADVANCED_CHAT) && (pathname).endsWith('configuration')) {
router.replace(`/app/${appId}/workflow`)
}
else if ((res.mode !== AppModeEnum.WORKFLOW && res.mode !== AppModeEnum.ADVANCED_CHAT) && (pathname).endsWith('workflow')) {
router.replace(`/app/${appId}/configuration`)
if (isMobile) {
setAppSidebarExpand('collapse')
}
else {
setAppDetail({ ...res, enable_sso: false })
setNavigation(getNavigationConfig(appId, isCurrentWorkspaceEditor, res.mode))
const localeMode = localStorage.getItem('app-detail-collapse-or-expand') || 'expand'
setAppSidebarExpand(localeMode)
}
}, [appDetailRes, isCurrentWorkspaceEditor, isLoadingAppDetail, isLoadingCurrentWorkspace])
}, [appDetail, isMobile, setAppSidebarExpand])
useUnmount(() => {
setAppDetail()
})
useEffect(() => {
if (!appDetail || isLoadingCurrentWorkspace)
return
if (!appDetail) {
const mode = appDetail.mode
const isWorkflowMode = mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT
if (!isCurrentWorkspaceEditor) {
const restrictedPaths = ['configuration', 'workflow', 'logs']
if (restrictedPaths.some(p => pathname.endsWith(p))) {
router.replace(`/app/${appId}/overview`)
return
}
}
if (isWorkflowMode && pathname.endsWith('configuration'))
router.replace(`/app/${appId}/workflow`)
else if (!isWorkflowMode && pathname.endsWith('workflow'))
router.replace(`/app/${appId}/configuration`)
}, [appDetail, isCurrentWorkspaceEditor, isLoadingCurrentWorkspace, pathname, appId, router])
useEffect(() => {
if (error) {
const httpError = error as { status?: number }
if (httpError.status === 404)
router.replace('/apps')
}
}, [error, router])
if (isPending) {
return (
<div className="flex h-full items-center justify-center bg-background-body">
<Loading />
@ -164,13 +145,12 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
)
}
if (!appDetail)
return null
return (
<div className={cn(s.app, 'relative flex', 'overflow-hidden')}>
{appDetail && (
<AppSideBar
navigation={navigation}
/>
)}
<AppSideBar navigation={navigation} />
<div className="grow overflow-hidden bg-components-panel-bg">
{children}
</div>
@ -180,4 +160,5 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
</div>
)
}
export default React.memo(AppDetailLayout)

View File

@ -5,13 +5,11 @@ import type { BlockEnum } from '@/app/components/workflow/types'
import type { UpdateAppSiteCodeResponse } from '@/models/app'
import type { App } from '@/types/app'
import type { I18nKeysByPrefix } from '@/types/i18n'
import * as React from 'react'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import AppCard from '@/app/components/app/overview/app-card'
import TriggerCard from '@/app/components/app/overview/trigger-card'
import { useStore as useAppStore } from '@/app/components/app/store'
import Loading from '@/app/components/base/loading'
import { ToastContext } from '@/app/components/base/toast'
import MCPServiceCard from '@/app/components/tools/mcp/mcp-service-card'
@ -19,11 +17,11 @@ import { isTriggerNode } from '@/app/components/workflow/types'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useDocLink } from '@/context/i18n'
import {
fetchAppDetail,
updateAppSiteAccessToken,
updateAppSiteConfig,
updateAppSiteStatus,
} from '@/service/apps'
import { useAppDetail, useInvalidateAppDetail } from '@/service/use-apps'
import { useAppWorkflow } from '@/service/use-workflow'
import { AppModeEnum } from '@/types/app'
import { asyncRunSafe } from '@/utils'
@ -38,8 +36,8 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => {
const { t } = useTranslation()
const docLink = useDocLink()
const { notify } = useContext(ToastContext)
const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(state => state.setAppDetail)
const { data: appDetail } = useAppDetail(appId)
const invalidateAppDetail = useInvalidateAppDetail()
const isWorkflowApp = appDetail?.mode === AppModeEnum.WORKFLOW
const showMCPCard = isInPanel
@ -89,21 +87,13 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => {
? buildTriggerModeMessage(t('mcp.server.title', { ns: 'tools' }))
: null
const updateAppDetail = async () => {
try {
const res = await fetchAppDetail({ url: '/apps', id: appId })
setAppDetail({ ...res })
}
catch (error) { console.error(error) }
}
const handleCallbackResult = (err: Error | null, message?: I18nKeysByPrefix<'common', 'actionMsg.'>) => {
const type = err ? 'error' : 'success'
message ||= (type === 'success' ? 'modifiedSuccessfully' : 'modifiedUnsuccessfully')
if (type === 'success')
updateAppDetail()
invalidateAppDetail(appId)
notify({
type,

View File

@ -8,8 +8,8 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { TIME_PERIOD_MAPPING as LONG_TIME_PERIOD_MAPPING } from '@/app/components/app/log/filter'
import { AvgResponseTime, AvgSessionInteractions, AvgUserInteractions, ConversationsChart, CostChart, EndUsersChart, MessagesChart, TokenPerSecond, UserSatisfactionRate, WorkflowCostChart, WorkflowDailyTerminalsChart, WorkflowMessagesChart } from '@/app/components/app/overview/app-chart'
import { useStore as useAppStore } from '@/app/components/app/store'
import { IS_CLOUD_EDITION } from '@/config'
import { useAppDetail } from '@/service/use-apps'
import LongTimeRangePicker from './long-time-range-picker'
import TimeRangePicker from './time-range-picker'
@ -34,7 +34,7 @@ export type IChartViewProps = {
export default function ChartView({ appId, headerRight }: IChartViewProps) {
const { t } = useTranslation()
const appDetail = useAppStore(state => state.appDetail)
const { data: appDetail } = useAppDetail(appId)
const isChatApp = appDetail?.mode !== 'completion' && appDetail?.mode !== 'workflow'
const isWorkflow = appDetail?.mode === 'workflow'
const [period, setPeriod] = useState<PeriodParams>(IS_CLOUD_EDITION

View File

@ -12,13 +12,12 @@ import {
RiFileUploadLine,
} from '@remixicon/react'
import dynamic from 'next/dynamic'
import { useRouter } from 'next/navigation'
import { useParams, useRouter } from 'next/navigation'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view'
import { useStore as useAppStore } from '@/app/components/app/store'
import Button from '@/app/components/base/button'
import ContentDialog from '@/app/components/base/content-dialog'
import { ToastContext } from '@/app/components/base/toast'
@ -26,7 +25,7 @@ import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps'
import { useInvalidateAppList } from '@/service/use-apps'
import { useAppDetail, useInvalidateAppDetail, useInvalidateAppList } from '@/service/use-apps'
import { fetchWorkflowDraft } from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
@ -64,9 +63,10 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const { replace } = useRouter()
const { appId } = useParams()
const { onPlanInfoChanged } = useProviderContext()
const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(state => state.setAppDetail)
const { data: appDetail } = useAppDetail(appId as string)
const invalidateAppDetail = useInvalidateAppDetail()
const invalidateAppList = useInvalidateAppList()
const [open, setOpen] = useState(openState)
const [showEditModal, setShowEditModal] = useState(false)
@ -89,7 +89,7 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
if (!appDetail)
return
try {
const app = await updateAppInfo({
await updateAppInfo({
appID: appDetail.id,
name,
icon_type,
@ -104,12 +104,12 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
type: 'success',
message: t('editDone', { ns: 'app' }),
})
setAppDetail(app)
invalidateAppDetail(appId as string)
}
catch {
notify({ type: 'error', message: t('editFailed', { ns: 'app' }) })
}
}, [appDetail, notify, setAppDetail, t])
}, [appDetail, notify, invalidateAppDetail, appId, t])
const onCopy: DuplicateAppModalProps['onConfirm'] = async ({ name, icon_type, icon, icon_background }) => {
if (!appDetail)
@ -195,7 +195,6 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
notify({ type: 'success', message: t('appDeleted', { ns: 'app' }) })
invalidateAppList()
onPlanInfoChanged()
setAppDetail()
replace('/apps')
}
catch (e: any) {
@ -205,7 +204,7 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
})
}
setShowConfirmDelete(false)
}, [appDetail, invalidateAppList, notify, onPlanInfoChanged, replace, setAppDetail, t])
}, [appDetail, invalidateAppList, notify, onPlanInfoChanged, replace, t])
const { isCurrentWorkspaceEditor } = useAppContext()
@ -242,7 +241,6 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
]
const secondaryOperations: Operation[] = [
// Import DSL (conditional)
...(appDetail.mode === AppModeEnum.ADVANCED_CHAT || appDetail.mode === AppModeEnum.WORKFLOW)
? [{
id: 'import',
@ -255,7 +253,6 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
},
}]
: [],
// Divider
{
id: 'divider-1',
title: '',
@ -263,7 +260,6 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
onClick: () => { /* divider has no action */ },
type: 'divider' as const,
},
// Delete operation
{
id: 'delete',
title: t('operation.delete', { ns: 'common' }),
@ -276,7 +272,6 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
},
]
// Keep the switch operation separate as it's not part of the main operations
const switchOperation = (appDetail.mode === AppModeEnum.COMPLETION || appDetail.mode === AppModeEnum.CHAT)
? {
id: 'switch',
@ -370,11 +365,9 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
<div className="system-2xs-medium-uppercase text-text-tertiary">{appDetail.mode === AppModeEnum.ADVANCED_CHAT ? t('types.advanced', { ns: 'app' }) : appDetail.mode === AppModeEnum.AGENT_CHAT ? t('types.agent', { ns: 'app' }) : appDetail.mode === AppModeEnum.CHAT ? t('types.chatbot', { ns: 'app' }) : appDetail.mode === AppModeEnum.COMPLETION ? t('types.completion', { ns: 'app' }) : t('types.workflow', { ns: 'app' })}</div>
</div>
</div>
{/* description */}
{appDetail.description && (
<div className="system-xs-regular overflow-wrap-anywhere max-h-[105px] w-full max-w-full overflow-y-auto whitespace-normal break-words text-text-tertiary">{appDetail.description}</div>
)}
{/* operations */}
<AppOperations
gap={4}
primaryOperations={primaryOperations}
@ -386,7 +379,6 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
isInPanel={true}
className="flex flex-1 flex-col gap-2 overflow-auto px-2 py-1"
/>
{/* Switch operation (if available) */}
{switchOperation && (
<div className="flex min-h-fit shrink-0 flex-col items-start justify-center gap-3 self-stretch pb-2">
<Button

View File

@ -3,16 +3,17 @@ import {
RiEqualizer2Line,
RiMenuLine,
} from '@remixicon/react'
import { useParams } from 'next/navigation'
import * as React from 'react'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { useAppContext } from '@/context/app-context'
import { useAppDetail } from '@/service/use-apps'
import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames'
import AppIcon from '../base/app-icon'
@ -31,8 +32,9 @@ type Props = {
const AppSidebarDropdown = ({ navigation }: Props) => {
const { t } = useTranslation()
const { appId } = useParams()
const { isCurrentWorkspaceEditor } = useAppContext()
const appDetail = useAppStore(state => state.appDetail)
const { data: appDetail } = useAppDetail(appId as string)
const [detailExpand, setDetailExpand] = useState(false)
const [open, doSetOpen] = useState(false)

View File

@ -71,7 +71,7 @@ const DatasetInfo: FC<DatasetInfoProps> = ({
</div>
<div className="system-2xs-medium-uppercase text-text-tertiary">
{isExternalProvider && t('externalTag', { ns: 'dataset' })}
{!!(!isExternalProvider && isPipelinePublished && dataset.doc_form && dataset.indexing_technique) && (
{!isExternalProvider && isPipelinePublished && dataset.doc_form && dataset.indexing_technique && (
<div className="flex items-center gap-x-2">
<span>{t(`chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`, { ns: 'dataset' })}</span>
<span>{formatIndexingTechniqueAndMethod(dataset.indexing_technique, dataset.retrieval_model_dict?.search_method)}</span>

View File

@ -114,7 +114,7 @@ const DatasetSidebarDropdown = ({
</div>
<div className="system-2xs-medium-uppercase text-text-tertiary">
{isExternalProvider && t('externalTag', { ns: 'dataset' })}
{!!(!isExternalProvider && dataset.doc_form && dataset.indexing_technique) && (
{!isExternalProvider && dataset.doc_form && dataset.indexing_technique && (
<div className="flex items-center gap-x-2">
<span>{t(`chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`, { ns: 'dataset' })}</span>
<span>{formatIndexingTechniqueAndMethod(dataset.indexing_technique, dataset.retrieval_model_dict?.search_method)}</span>

View File

@ -144,7 +144,7 @@ const EditAnnotationModal: FC<Props> = ({
<MessageCheckRemove />
<div>{t('editModal.removeThisCache', { ns: 'appAnnotation' })}</div>
</div>
{!!createdAt && (
{createdAt && (
<div>
{t('editModal.createdAt', { ns: 'appAnnotation' })}
&nbsp;

View File

@ -203,7 +203,7 @@ const Annotation: FC<Props> = (props) => {
</Filter>
{isLoading
? <Loading type="app" />
// eslint-disable-next-line sonarjs/no-nested-conditional
: total > 0
? (
<List

View File

@ -15,6 +15,7 @@ import {
RiVerifiedBadgeLine,
} from '@remixicon/react'
import { useKeyPress } from 'ahooks'
import { useParams } from 'next/navigation'
import {
memo,
useCallback,
@ -24,7 +25,6 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import EmbeddedModal from '@/app/components/app/overview/embedded'
import { useStore as useAppStore } from '@/app/components/app/store'
import { trackEvent } from '@/app/components/base/amplitude'
import Button from '@/app/components/base/button'
import { CodeBrowser } from '@/app/components/base/icons/src/vender/line/development'
@ -41,8 +41,8 @@ import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { AccessMode } from '@/models/access-control'
import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control'
import { fetchAppDetailDirect } from '@/service/apps'
import { fetchInstalledAppList } from '@/service/explore'
import { useAppDetail, useInvalidateAppDetail } from '@/service/use-apps'
import { AppModeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
import Divider from '../../base/divider'
@ -139,15 +139,15 @@ const AppPublisher = ({
startNodeLimitExceeded = false,
}: AppPublisherProps) => {
const { t } = useTranslation()
const { appId } = useParams()
const [published, setPublished] = useState(false)
const [open, setOpen] = useState(false)
const [showAppAccessControl, setShowAppAccessControl] = useState(false)
const [isAppAccessSet, setIsAppAccessSet] = useState(true)
const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false)
const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(s => s.setAppDetail)
const { data: appDetail } = useAppDetail(appId as string)
const invalidateAppDetail = useInvalidateAppDetail()
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const { formatTimeFromNow } = useFormatTimeFromNow()
const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {}
@ -177,16 +177,12 @@ const AppPublisher = ({
refetch()
}, [open, appDetail, refetch, systemFeatures])
useEffect(() => {
if (appDetail && appAccessSubjects) {
if (appDetail.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && appAccessSubjects.groups?.length === 0 && appAccessSubjects.members?.length === 0)
setIsAppAccessSet(false)
else
setIsAppAccessSet(true)
}
else {
setIsAppAccessSet(true)
}
const isAppAccessSet = useMemo(() => {
if (!appDetail || !appAccessSubjects)
return true
if (appDetail.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && appAccessSubjects.groups?.length === 0 && appAccessSubjects.members?.length === 0)
return false
return true
}, [appAccessSubjects, appDetail])
const handlePublish = useCallback(async (params?: ModelAndParameter | PublishWorkflowParams) => {
@ -239,16 +235,15 @@ const AppPublisher = ({
}, [appDetail?.id, openAsyncWindow])
const handleAccessControlUpdate = useCallback(async () => {
if (!appDetail)
if (!appId)
return
try {
const res = await fetchAppDetailDirect({ url: '/apps', id: appDetail.id })
setAppDetail(res)
invalidateAppDetail(appId as string)
}
finally {
setShowAppAccessControl(false)
}
}, [appDetail, setAppDetail])
}, [appId, invalidateAppDetail])
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => {
e.preventDefault()

View File

@ -28,16 +28,16 @@ const FeaturePanel: FC<IFeaturePanelProps> = ({
<div className={cn('px-3 pt-2', hasHeaderBottomBorder && 'border-b border-divider-subtle')} data-testid="feature-panel-header">
<div className="flex h-8 items-center justify-between">
<div className="flex shrink-0 items-center space-x-1">
{!!headerIcon && <div className="flex h-6 w-6 items-center justify-center">{headerIcon}</div>}
{headerIcon && <div className="flex h-6 w-6 items-center justify-center">{headerIcon}</div>}
<div className="system-sm-semibold text-text-secondary">{title}</div>
</div>
<div className="flex items-center gap-2">
{!!headerRight && <div>{headerRight}</div>}
{headerRight && <div>{headerRight}</div>}
</div>
</div>
</div>
{/* Body */}
{!!children && (
{children && (
<div className={cn(!noBodySpacing && 'mt-1 px-3')} data-testid="feature-panel-body">
{children}
</div>

View File

@ -4,11 +4,11 @@ import type { Item as SelectItem } from './type-select'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { InputVar, MoreInfo, UploadFileSetting } from '@/app/components/workflow/types'
import { produce } from 'immer'
import { useParams } from 'next/navigation'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { useStore as useAppStore } from '@/app/components/app/store'
import Checkbox from '@/app/components/base/checkbox'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
import Input from '@/app/components/base/input'
@ -22,6 +22,7 @@ import FileUploadSetting from '@/app/components/workflow/nodes/_base/components/
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import { ChangeType, InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
import ConfigContext from '@/context/debug-configuration'
import { useAppDetail } from '@/service/use-apps'
import { AppModeEnum, TransferMethod } from '@/types/app'
import { checkKeys, getNewVarInWorkflow, replaceSpaceWithUnderscoreInVarNameInput } from '@/utils/var'
import ConfigSelect from '../config-select'
@ -72,10 +73,11 @@ const ConfigModal: FC<IConfigModalProps> = ({
}) => {
const { modelConfig } = useContext(ConfigContext)
const { t } = useTranslation()
const { appId } = useParams()
const { data: appDetail } = useAppDetail(appId as string)
const [tempPayload, setTempPayload] = useState<InputVar>(() => normalizeSelectDefaultValue(payload || getNewVarInWorkflow('') as any))
const { type, label, variable, options, max_length } = tempPayload
const modalRef = useRef<HTMLDivElement>(null)
const appDetail = useAppStore(state => state.appDetail)
const isBasicApp = appDetail?.mode !== AppModeEnum.ADVANCED_CHAT && appDetail?.mode !== AppModeEnum.WORKFLOW
const jsonSchemaStr = useMemo(() => {
const isJsonObject = type === InputVarType.jsonObject

View File

@ -2,11 +2,13 @@ import type { ReactNode } from 'react'
import type { IConfigVarProps } from './index'
import type { ExternalDataTool } from '@/models/common'
import type { PromptVariable } from '@/models/debug'
import type { App } from '@/types/app'
import { act, fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { vi } from 'vitest'
import Toast from '@/app/components/base/toast'
import DebugConfigurationContext from '@/context/debug-configuration'
import { useAppDetail } from '@/service/use-apps'
import { AppModeEnum } from '@/types/app'
import ConfigVar, { ADD_EXTERNAL_DATA_TOOL } from './index'
@ -38,6 +40,15 @@ vi.mock('@/context/modal-context', () => ({
}),
}))
vi.mock('next/navigation', () => ({
useParams: () => ({
appId: 'test-app-id',
}),
}))
vi.mock('@/service/use-apps')
const mockUseAppDetail = vi.mocked(useAppDetail)
type SortableItem = {
id: string
variable: PromptVariable
@ -85,6 +96,18 @@ const createPromptVariable = (overrides: Partial<PromptVariable> = {}): PromptVa
}
}
function setupUseAppDetailMock() {
mockUseAppDetail.mockReturnValue({
data: {
id: 'test-app-id',
mode: AppModeEnum.CHAT,
} as App,
isLoading: false,
isPending: false,
error: null,
} as ReturnType<typeof useAppDetail>)
}
const renderConfigVar = (props: Partial<IConfigVarProps> = {}, debugOverrides: Partial<DebugConfigurationState> = {}) => {
const defaultProps: IConfigVarProps = {
promptVariables: [],
@ -219,6 +242,7 @@ describe('ConfigVar', () => {
subscriptionCallback = null
variableIndex = 0
notifySpy.mockClear()
setupUseAppDetailMock()
})
it('should save updates when editing a basic variable', async () => {

View File

@ -134,6 +134,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
},
] as const
// eslint-disable-next-line sonarjs/no-nested-template-literals, sonarjs/no-nested-conditional
const [instructionFromSessionStorage, setInstruction] = useSessionStorageState<string>(`improve-instruction-${flowId}${isBasicMode ? '' : `-${nodeId}${editorId ? `-${editorId}` : ''}`}`)
const instruction = instructionFromSessionStorage || ''
const [ideaOutput, setIdeaOutput] = useState<string>('')

View File

@ -91,7 +91,7 @@ const Item: FC<ItemProps> = ({
</ActionButton>
</div>
{
!!config.indexing_technique && (
config.indexing_technique && (
<Badge
className="shrink-0 group-hover:hidden"
text={formatIndexingTechniqueAndMethod(config.indexing_technique, config.retrieval_model_dict?.search_method)}

View File

@ -175,7 +175,7 @@ const SelectDataSet: FC<ISelectDataSetProps> = ({
</div>
)}
{
!!item.indexing_technique && (
item.indexing_technique && (
<Badge
className="shrink-0"
text={formatIndexingTechniqueAndMethod(item.indexing_technique, item.retrieval_model_dict?.search_method)}

View File

@ -247,7 +247,7 @@ const SettingsModal: FC<SettingsModalProps> = ({
/>
</div>
</div>
{!!(currentDataset && currentDataset.indexing_technique) && (
{currentDataset && currentDataset.indexing_technique && (
<div className={cn(rowClass)}>
<div className={labelClass}>
<div className="system-sm-semibold text-text-secondary">{t('form.indexMethod', { ns: 'datasetSettings' })}</div>

View File

@ -465,8 +465,8 @@ vi.mock('@/app/components/base/chat/chat', () => ({
</div>
))}
</div>
{!!questionIcon && <div data-testid="question-icon">{questionIcon}</div>}
{!!answerIcon && <div data-testid="answer-icon">{answerIcon}</div>}
{questionIcon && <div data-testid="question-icon">{questionIcon}</div>}
{answerIcon && <div data-testid="answer-icon">{answerIcon}</div>}
<textarea
data-testid="chat-input"
placeholder="Type a message"

View File

@ -71,9 +71,10 @@ import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { PromptMode } from '@/models/debug'
import { fetchAppDetailDirect, updateAppModelConfig } from '@/service/apps'
import { updateAppModelConfig } from '@/service/apps'
import { fetchDatasets } from '@/service/datasets'
import { fetchCollectionList } from '@/service/tools'
import { useAppDetail } from '@/service/use-apps'
import { useFileUploadConfig } from '@/service/use-common'
import { AgentStrategy, AppModeEnum, ModelModeType, Resolution, RETRIEVE_TYPE, TransferMethod } from '@/types/app'
import {
@ -95,22 +96,22 @@ const Configuration: FC = () => {
const { notify } = useContext(ToastContext)
const { isLoadingCurrentWorkspace, currentWorkspace } = useAppContext()
const { appDetail, showAppConfigureFeaturesModal, setAppSidebarExpand, setShowAppConfigureFeaturesModal } = useAppStore(useShallow(state => ({
appDetail: state.appDetail,
const { showAppConfigureFeaturesModal, setAppSidebarExpand, setShowAppConfigureFeaturesModal } = useAppStore(useShallow(state => ({
setAppSidebarExpand: state.setAppSidebarExpand,
showAppConfigureFeaturesModal: state.showAppConfigureFeaturesModal,
setShowAppConfigureFeaturesModal: state.setShowAppConfigureFeaturesModal,
})))
const { data: fileUploadConfigResponse } = useFileUploadConfig()
const pathname = usePathname()
const matched = pathname.match(/\/app\/([^/]+)/)
const appId = (matched?.length && matched[1]) ? matched[1] : ''
const { data: appDetail } = useAppDetail(appId)
const latestPublishedAt = useMemo(() => appDetail?.model_config?.updated_at, [appDetail])
const [formattingChanged, setFormattingChanged] = useState(false)
const { setShowAccountSettingModal } = useModalContext()
const [hasFetchedDetail, setHasFetchedDetail] = useState(false)
const isLoading = !hasFetchedDetail
const pathname = usePathname()
const matched = pathname.match(/\/app\/([^/]+)/)
const appId = (matched?.length && matched[1]) ? matched[1] : ''
const [mode, setMode] = useState<AppModeEnum>(AppModeEnum.CHAT)
const [publishedConfig, setPublishedConfig] = useState<PublishConfig | null>(null)
@ -548,7 +549,10 @@ const Configuration: FC = () => {
}, [modelConfig])
useEffect(() => {
(async () => {
if (!appDetail)
return
const initConfig = async () => {
const collectionList = await fetchCollectionList()
if (basePath) {
collectionList.forEach((item) => {
@ -557,9 +561,8 @@ const Configuration: FC = () => {
})
}
setCollectionList(collectionList)
const res = await fetchAppDetailDirect({ url: '/apps', id: appId })
setMode(res.mode as AppModeEnum)
const modelConfig = res.model_config as BackendModelConfig
setMode(appDetail.mode as AppModeEnum)
const modelConfig = appDetail.model_config as BackendModelConfig
const promptMode = modelConfig.prompt_type === PromptMode.advanced ? PromptMode.advanced : PromptMode.simple
doSetPromptMode(promptMode)
if (promptMode === PromptMode.advanced) {
@ -669,7 +672,7 @@ const Configuration: FC = () => {
external_data_tools: modelConfig.external_data_tools ?? [],
system_parameters: modelConfig.system_parameters,
dataSets: datasets || [],
agentConfig: res.mode === AppModeEnum.AGENT_CHAT ? {
agentConfig: appDetail.mode === AppModeEnum.AGENT_CHAT ? {
max_iteration: DEFAULT_AGENT_SETTING.max_iteration,
...modelConfig.agent_mode,
// remove dataset
@ -680,7 +683,7 @@ const Configuration: FC = () => {
const toolInCollectionList = collectionList.find(c => tool.provider_id === c.id)
return {
...tool,
isDeleted: res.deleted_tools?.some((deletedTool: any) => deletedTool.provider_id === tool.provider_id && deletedTool.tool_name === tool.tool_name) ?? false,
isDeleted: appDetail.deleted_tools?.some((deletedTool: any) => deletedTool.provider_id === tool.provider_id && deletedTool.tool_name === tool.tool_name) ?? false,
notAuthor: toolInCollectionList?.is_team_authorization === false,
...(tool.provider_type === 'builtin'
? {
@ -726,8 +729,10 @@ const Configuration: FC = () => {
datasetConfigsToSet.retrieval_model = datasetConfigsToSet.retrieval_model ?? RETRIEVE_TYPE.multiWay
setDatasetConfigs(datasetConfigsToSet)
setHasFetchedDetail(true)
})()
}, [appId])
}
initConfig()
}, [appDetail])
const promptEmpty = (() => {
if (mode !== AppModeEnum.COMPLETION)

View File

@ -1,16 +1,22 @@
import type { App, AppIconType } from '@/types/app'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useStore as useAppStore } from '@/app/components/app/store'
import { PageType } from '@/app/components/base/features/new-feature-panel/annotation-reply/type'
import { useAppDetail } from '@/service/use-apps'
import { AppModeEnum } from '@/types/app'
import LogAnnotation from './index'
vi.mock('@/service/use-apps')
const mockUseAppDetail = vi.mocked(useAppDetail)
const mockRouterPush = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockRouterPush,
}),
useParams: () => ({
appId: 'app-123',
}),
}))
vi.mock('@/app/components/app/annotation', () => ({
@ -61,17 +67,25 @@ const createMockApp = (overrides: Partial<App> = {}): App => ({
...overrides,
})
function mockAppDetailReturn(app: App | undefined) {
mockUseAppDetail.mockReturnValue({
data: app,
isLoading: false,
error: null,
} as ReturnType<typeof useAppDetail>)
}
describe('LogAnnotation', () => {
beforeEach(() => {
vi.clearAllMocks()
useAppStore.setState({ appDetail: createMockApp() })
mockAppDetailReturn(createMockApp())
})
// Rendering behavior
describe('Rendering', () => {
it('should render loading state when app detail is missing', () => {
// Arrange
useAppStore.setState({ appDetail: undefined })
mockAppDetailReturn(undefined)
// Act
render(<LogAnnotation pageType={PageType.log} />)
@ -82,7 +96,7 @@ describe('LogAnnotation', () => {
it('should render log and annotation tabs for non-completion apps', () => {
// Arrange
useAppStore.setState({ appDetail: createMockApp({ mode: AppModeEnum.CHAT }) })
mockAppDetailReturn(createMockApp({ mode: AppModeEnum.CHAT }))
// Act
render(<LogAnnotation pageType={PageType.log} />)
@ -94,7 +108,7 @@ describe('LogAnnotation', () => {
it('should render only log tab for completion apps', () => {
// Arrange
useAppStore.setState({ appDetail: createMockApp({ mode: AppModeEnum.COMPLETION }) })
mockAppDetailReturn(createMockApp({ mode: AppModeEnum.COMPLETION }))
// Act
render(<LogAnnotation pageType={PageType.log} />)
@ -106,7 +120,7 @@ describe('LogAnnotation', () => {
it('should hide tabs and render workflow log in workflow mode', () => {
// Arrange
useAppStore.setState({ appDetail: createMockApp({ mode: AppModeEnum.WORKFLOW }) })
mockAppDetailReturn(createMockApp({ mode: AppModeEnum.WORKFLOW }))
// Act
render(<LogAnnotation pageType={PageType.log} />)
@ -121,7 +135,7 @@ describe('LogAnnotation', () => {
describe('Props', () => {
it('should render log content when page type is log', () => {
// Arrange
useAppStore.setState({ appDetail: createMockApp({ mode: AppModeEnum.CHAT }) })
mockAppDetailReturn(createMockApp({ mode: AppModeEnum.CHAT }))
// Act
render(<LogAnnotation pageType={PageType.log} />)
@ -133,7 +147,7 @@ describe('LogAnnotation', () => {
it('should render annotation content when page type is annotation', () => {
// Arrange
useAppStore.setState({ appDetail: createMockApp({ mode: AppModeEnum.CHAT }) })
mockAppDetailReturn(createMockApp({ mode: AppModeEnum.CHAT }))
// Act
render(<LogAnnotation pageType={PageType.annotation} />)

View File

@ -1,16 +1,16 @@
'use client'
import type { FC } from 'react'
import { useRouter } from 'next/navigation'
import { useParams, useRouter } from 'next/navigation'
import * as React from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Annotation from '@/app/components/app/annotation'
import Log from '@/app/components/app/log'
import { useStore as useAppStore } from '@/app/components/app/store'
import WorkflowLog from '@/app/components/app/workflow-log'
import { PageType } from '@/app/components/base/features/new-feature-panel/annotation-reply/type'
import Loading from '@/app/components/base/loading'
import TabSlider from '@/app/components/base/tab-slider-plain'
import { useAppDetail } from '@/service/use-apps'
import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames'
@ -23,7 +23,8 @@ const LogAnnotation: FC<Props> = ({
}) => {
const { t } = useTranslation()
const router = useRouter()
const appDetail = useAppStore(state => state.appDetail)
const { appId } = useParams()
const { data: appDetail } = useAppDetail(appId as string)
const options = useMemo(() => {
if (appDetail?.mode === AppModeEnum.COMPLETION)

View File

@ -19,7 +19,6 @@ import * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppBasic from '@/app/components/app-sidebar/basic'
import { useStore as useAppStore } from '@/app/components/app/store'
import Button from '@/app/components/base/button'
import Confirm from '@/app/components/base/confirm'
import CopyFeedback from '@/app/components/base/copy-feedback'
@ -35,7 +34,7 @@ import { useGlobalPublicStore } from '@/context/global-public-context'
import { useDocLink } from '@/context/i18n'
import { AccessMode } from '@/models/access-control'
import { useAppWhiteListSubjects } from '@/service/access-control'
import { fetchAppDetailDirect } from '@/service/apps'
import { useAppDetail, useInvalidateAppDetail } from '@/service/use-apps'
import { useAppWorkflow } from '@/service/use-workflow'
import { AppModeEnum } from '@/types/app'
import { asyncRunSafe } from '@/utils'
@ -73,11 +72,11 @@ function AppCard({
}: IAppCardProps) {
const router = useRouter()
const pathname = usePathname()
const invalidateAppDetail = useInvalidateAppDetail()
const { isCurrentWorkspaceManager, isCurrentWorkspaceEditor } = useAppContext()
const { data: currentWorkflow } = useAppWorkflow(appInfo.mode === AppModeEnum.WORKFLOW ? appInfo.id : '')
const docLink = useDocLink()
const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(state => state.setAppDetail)
const { data: appDetail } = useAppDetail(appInfo.id)
const [showSettingsModal, setShowSettingsModal] = useState(false)
const [showEmbedded, setShowEmbedded] = useState(false)
const [showCustomizeModal, setShowCustomizeModal] = useState(false)
@ -178,16 +177,10 @@ function AppCard({
return
setShowAccessControl(true)
}, [appDetail])
const handleAccessControlUpdate = useCallback(async () => {
try {
const res = await fetchAppDetailDirect({ url: '/apps', id: appDetail!.id })
setAppDetail(res)
setShowAccessControl(false)
}
catch (error) {
console.error('Failed to fetch app detail:', error)
}
}, [appDetail, setAppDetail])
const handleAccessControlUpdate = useCallback(() => {
invalidateAppDetail(appInfo.id)
setShowAccessControl(false)
}, [invalidateAppDetail, appInfo.id])
return (
<div

View File

@ -175,7 +175,7 @@ describe('SettingsModal', () => {
renderSettingsModal()
fireEvent.click(screen.getByText('appOverview.overview.appInfo.settings.more.entry'))
const privacyInput = screen.getByPlaceholderText('appOverview.overview.appInfo.settings.more.privacyPolicyPlaceholder')
// eslint-disable-next-line sonarjs/no-clear-text-protocols
fireEvent.change(privacyInput, { target: { value: 'ftp://invalid-url' } })
fireEvent.click(screen.getByText('common.operation.save'))

View File

@ -1,9 +1,7 @@
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import type { App, AppSSO } from '@/types/app'
import { create } from 'zustand'
type State = {
appDetail?: App & Partial<AppSSO>
appSidebarExpand: string
currentLogItem?: IChatItem
currentLogModalActiveTab: string
@ -14,7 +12,6 @@ type State = {
}
type Action = {
setAppDetail: (appDetail?: App & Partial<AppSSO>) => void
setAppSidebarExpand: (state: string) => void
setCurrentLogItem: (item?: IChatItem) => void
setCurrentLogModalActiveTab: (tab: string) => void
@ -25,8 +22,6 @@ type Action = {
}
export const useStore = create<State & Action>(set => ({
appDetail: undefined,
setAppDetail: appDetail => set(() => ({ appDetail })),
appSidebarExpand: '',
setAppSidebarExpand: appSidebarExpand => set(() => ({ appSidebarExpand })),
currentLogItem: undefined,

View File

@ -2,7 +2,6 @@ import type { App } from '@/types/app'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { useStore as useAppStore } from '@/app/components/app/store'
import { ToastContext } from '@/app/components/base/toast'
import { Plan } from '@/app/components/billing/type'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
@ -16,9 +15,13 @@ vi.mock('next/navigation', () => ({
push: mockPush,
replace: mockReplace,
}),
useParams: () => ({ appId: 'app-123' }),
}))
// Use real store - global zustand mock will auto-reset between tests
const mockInvalidateAppDetail = vi.fn()
vi.mock('@/service/use-apps', () => ({
useInvalidateAppDetail: () => mockInvalidateAppDetail,
}))
const mockSwitchApp = vi.fn()
const mockDeleteApp = vi.fn()
@ -135,17 +138,9 @@ const renderComponent = (overrides: Partial<React.ComponentProps<typeof SwitchAp
}
}
const setAppDetailSpy = vi.fn()
describe('SwitchAppModal', () => {
beforeEach(() => {
vi.clearAllMocks()
// Spy on setAppDetail
const originalSetAppDetail = useAppStore.getState().setAppDetail
setAppDetailSpy.mockImplementation((...args: Parameters<typeof originalSetAppDetail>) => {
originalSetAppDetail(...args)
})
useAppStore.setState({ setAppDetail: setAppDetailSpy as typeof originalSetAppDetail })
mockIsEditor = true
mockEnableBilling = false
mockPlan = {
@ -281,7 +276,7 @@ describe('SwitchAppModal', () => {
})
expect(mockReplace).toHaveBeenCalledWith('/app/new-app-002/workflow')
expect(mockPush).not.toHaveBeenCalled()
expect(setAppDetailSpy).toHaveBeenCalledTimes(1)
expect(mockInvalidateAppDetail).toHaveBeenCalledWith('app-123')
})
it('should notify error when switch app fails', async () => {

View File

@ -3,11 +3,10 @@
import type { App } from '@/types/app'
import { RiCloseLine } from '@remixicon/react'
import { noop } from 'es-toolkit/function'
import { useRouter } from 'next/navigation'
import { useParams, useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { useStore as useAppStore } from '@/app/components/app/store'
import AppIcon from '@/app/components/base/app-icon'
import Button from '@/app/components/base/button'
import Checkbox from '@/app/components/base/checkbox'
@ -21,6 +20,7 @@ import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { deleteApp, switchApp } from '@/service/apps'
import { useInvalidateAppDetail } from '@/service/use-apps'
import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
import { cn } from '@/utils/classnames'
@ -38,7 +38,8 @@ const SwitchAppModal = ({ show, appDetail, inAppDetail = false, onSuccess, onClo
const { push, replace } = useRouter()
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const setAppDetail = useAppStore(s => s.setAppDetail)
const { appId } = useParams()
const invalidateAppDetail = useInvalidateAppDetail()
const { isCurrentWorkspaceEditor } = useAppContext()
const { plan, enableBilling } = useProviderContext()
@ -70,7 +71,7 @@ const SwitchAppModal = ({ show, appDetail, inAppDetail = false, onSuccess, onClo
onClose()
notify({ type: 'success', message: t('newApp.appCreated', { ns: 'app' }) })
if (inAppDetail)
setAppDetail()
invalidateAppDetail(appId as string)
if (removeOriginal)
await deleteApp(appDetail.id)
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')

View File

@ -11,18 +11,24 @@
import type { App, AppIconType, AppModeEnum } from '@/types/app'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useAppDetail } from '@/service/use-apps'
import DetailPanel from './detail'
// ============================================================================
// Mocks
// ============================================================================
vi.mock('@/service/use-apps')
const mockUseAppDetail = vi.mocked(useAppDetail)
const mockRouterPush = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockRouterPush,
}),
useParams: () => ({
appId: 'test-app-id',
}),
}))
// Mock the Run component as it has complex dependencies
@ -88,6 +94,18 @@ const createMockApp = (overrides: Partial<App> = {}): App => ({
...overrides,
})
// ============================================================================
// Helper Functions
// ============================================================================
function mockAppDetailReturn(app: App | undefined) {
mockUseAppDetail.mockReturnValue({
data: app,
isLoading: false,
error: null,
} as ReturnType<typeof useAppDetail>)
}
// ============================================================================
// Tests
// ============================================================================
@ -97,7 +115,7 @@ describe('DetailPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
useAppStore.setState({ appDetail: createMockApp() })
mockAppDetailReturn(createMockApp())
})
// --------------------------------------------------------------------------
@ -125,7 +143,7 @@ describe('DetailPanel', () => {
})
it('should render Run component with correct URLs', () => {
useAppStore.setState({ appDetail: createMockApp({ id: 'app-456' }) })
mockAppDetailReturn(createMockApp({ id: 'app-456' }))
render(<DetailPanel runID="run-789" onClose={defaultOnClose} />)
@ -185,7 +203,7 @@ describe('DetailPanel', () => {
it('should navigate to workflow page with replayRunId when replay button is clicked', async () => {
const user = userEvent.setup()
useAppStore.setState({ appDetail: createMockApp({ id: 'app-replay-test' }) })
mockAppDetailReturn(createMockApp({ id: 'app-replay-test' }))
render(<DetailPanel runID="run-to-replay" onClose={defaultOnClose} canReplay={true} />)
@ -197,7 +215,7 @@ describe('DetailPanel', () => {
it('should not navigate when replay clicked but appDetail is missing', async () => {
const user = userEvent.setup()
useAppStore.setState({ appDetail: undefined })
mockAppDetailReturn(undefined)
render(<DetailPanel runID="run-123" onClose={defaultOnClose} canReplay={true} />)
@ -213,7 +231,7 @@ describe('DetailPanel', () => {
// --------------------------------------------------------------------------
describe('URL Generation', () => {
it('should generate correct run detail URL', () => {
useAppStore.setState({ appDetail: createMockApp({ id: 'my-app' }) })
mockAppDetailReturn(createMockApp({ id: 'my-app' }))
render(<DetailPanel runID="my-run" onClose={defaultOnClose} />)
@ -221,7 +239,7 @@ describe('DetailPanel', () => {
})
it('should generate correct tracing list URL', () => {
useAppStore.setState({ appDetail: createMockApp({ id: 'my-app' }) })
mockAppDetailReturn(createMockApp({ id: 'my-app' }))
render(<DetailPanel runID="my-run" onClose={defaultOnClose} />)
@ -229,7 +247,7 @@ describe('DetailPanel', () => {
})
it('should handle special characters in runID', () => {
useAppStore.setState({ appDetail: createMockApp({ id: 'app-id' }) })
mockAppDetailReturn(createMockApp({ id: 'app-id' }))
render(<DetailPanel runID="run-with-special-123" onClose={defaultOnClose} />)
@ -242,7 +260,7 @@ describe('DetailPanel', () => {
// --------------------------------------------------------------------------
describe('Store Integration', () => {
it('should read appDetail from store', () => {
useAppStore.setState({ appDetail: createMockApp({ id: 'store-app-id' }) })
mockAppDetailReturn(createMockApp({ id: 'store-app-id' }))
render(<DetailPanel runID="run-123" onClose={defaultOnClose} />)
@ -250,7 +268,7 @@ describe('DetailPanel', () => {
})
it('should handle undefined appDetail from store gracefully', () => {
useAppStore.setState({ appDetail: undefined })
mockAppDetailReturn(undefined)
render(<DetailPanel runID="run-123" onClose={defaultOnClose} />)
@ -272,7 +290,7 @@ describe('DetailPanel', () => {
it('should handle very long runID', () => {
const longRunId = 'a'.repeat(100)
useAppStore.setState({ appDetail: createMockApp({ id: 'app-id' }) })
mockAppDetailReturn(createMockApp({ id: 'app-id' }))
render(<DetailPanel runID={longRunId} onClose={defaultOnClose} />)

View File

@ -1,12 +1,12 @@
'use client'
import type { FC } from 'react'
import { RiCloseLine, RiPlayLargeLine } from '@remixicon/react'
import { useRouter } from 'next/navigation'
import { useParams, useRouter } from 'next/navigation'
import { useTranslation } from 'react-i18next'
import { useStore } from '@/app/components/app/store'
import TooltipPlus from '@/app/components/base/tooltip'
import { WorkflowContextProvider } from '@/app/components/workflow/context'
import Run from '@/app/components/workflow/run'
import { useAppDetail } from '@/service/use-apps'
type ILogDetail = {
runID: string
@ -16,7 +16,8 @@ type ILogDetail = {
const DetailPanel: FC<ILogDetail> = ({ runID, onClose, canReplay = false }) => {
const { t } = useTranslation()
const appDetail = useStore(state => state.appDetail)
const { appId } = useParams()
const { data: appDetail } = useAppDetail(appId as string)
const router = useRouter()
const handleReplay = () => {

View File

@ -34,6 +34,18 @@ import Logs from './index'
vi.mock('@/service/use-log')
vi.mock('@/service/use-apps', () => ({
useAppDetail: () => ({
data: {
id: 'test-app-id',
name: 'Test App',
mode: 'workflow',
},
isLoading: false,
error: null,
}),
}))
vi.mock('ahooks', () => ({
useDebounce: <T,>(value: T) => value,
useDebounceFn: (fn: (value: string) => void) => ({ run: fn }),
@ -51,6 +63,9 @@ vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
}),
useParams: () => ({
appId: 'test-app-id',
}),
}))
vi.mock('next/link', () => ({

View File

@ -13,7 +13,6 @@ import type { WorkflowAppLogDetail, WorkflowLogsResponse, WorkflowRunDetail } fr
import type { App, AppIconType, AppModeEnum } from '@/types/app'
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 { APP_PAGE_LIMIT } from '@/config'
import { WorkflowRunTriggeredFrom } from '@/models/log'
import WorkflowAppLogList from './list'
@ -27,6 +26,9 @@ vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockRouterPush,
}),
useParams: () => ({
appId: 'test-app-id',
}),
}))
// Mock useTimestamp hook
@ -86,6 +88,40 @@ vi.mock('ahooks', () => ({
},
}))
// Mock app detail data for useAppDetail hook
const mockAppDetail: App = {
id: 'test-app-id',
name: 'Test App',
description: 'Test app description',
author_name: 'Test Author',
icon_type: 'emoji' as AppIconType,
icon: '',
icon_background: '#FFEAD5',
icon_url: null,
use_icon_as_answer_icon: false,
mode: 'workflow' as AppModeEnum,
enable_site: true,
enable_api: true,
api_rpm: 60,
api_rph: 3600,
is_demo: false,
model_config: {} as App['model_config'],
app_model_config: {} as App['app_model_config'],
created_at: Date.now(),
updated_at: Date.now(),
site: {
access_token: 'token',
app_base_url: 'https://example.com',
} as App['site'],
api_base_url: 'https://api.example.com',
tags: [],
access_mode: 'public_access' as App['access_mode'],
}
vi.mock('@/service/use-apps', () => ({
useAppDetail: () => ({ data: mockAppDetail, isPending: false }),
}))
// ============================================================================
// Test Data Factories
// ============================================================================
@ -168,7 +204,6 @@ describe('WorkflowAppLogList', () => {
beforeEach(() => {
vi.clearAllMocks()
useAppStore.setState({ appDetail: createMockApp() })
})
// --------------------------------------------------------------------------
@ -426,7 +461,6 @@ describe('WorkflowAppLogList', () => {
describe('Drawer', () => {
it('should open drawer when clicking on a log row', async () => {
const user = userEvent.setup()
useAppStore.setState({ appDetail: createMockApp({ id: 'app-123' }) })
const logs = createMockLogsResponse([
createMockWorkflowLog({
id: 'log-1',
@ -449,7 +483,6 @@ describe('WorkflowAppLogList', () => {
it('should close drawer and call onRefresh when closing', async () => {
const user = userEvent.setup()
const onRefresh = vi.fn()
useAppStore.setState({ appDetail: createMockApp() })
const logs = createMockLogsResponse([createMockWorkflowLog()])
render(
@ -498,7 +531,6 @@ describe('WorkflowAppLogList', () => {
describe('Replay Functionality', () => {
it('should allow replay when triggered from app-run', async () => {
const user = userEvent.setup()
useAppStore.setState({ appDetail: createMockApp({ id: 'app-replay' }) })
const logs = createMockLogsResponse([
createMockWorkflowLog({
workflow_run: createMockWorkflowRun({
@ -521,12 +553,11 @@ describe('WorkflowAppLogList', () => {
const replayButton = screen.getByRole('button', { name: 'appLog.runDetail.testWithParams' })
await user.click(replayButton)
expect(mockRouterPush).toHaveBeenCalledWith('/app/app-replay/workflow?replayRunId=run-to-replay')
expect(mockRouterPush).toHaveBeenCalledWith('/app/test-app-id/workflow?replayRunId=run-to-replay')
})
it('should allow replay when triggered from debugging', async () => {
const user = userEvent.setup()
useAppStore.setState({ appDetail: createMockApp({ id: 'app-debug' }) })
const logs = createMockLogsResponse([
createMockWorkflowLog({
workflow_run: createMockWorkflowRun({
@ -552,7 +583,6 @@ describe('WorkflowAppLogList', () => {
it('should not show replay for webhook triggers', async () => {
const user = userEvent.setup()
useAppStore.setState({ appDetail: createMockApp({ id: 'app-webhook' }) })
const logs = createMockLogsResponse([
createMockWorkflowLog({
workflow_run: createMockWorkflowRun({

View File

@ -4,14 +4,15 @@ import type { IChatItem } from '@/app/components/base/chat/chat/type'
import type { AgentIteration, AgentLogDetailResponse } from '@/models/log'
import { uniq } from 'es-toolkit/array'
import { flatten } from 'es-toolkit/compat'
import { useParams } from 'next/navigation'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { useStore as useAppStore } from '@/app/components/app/store'
import Loading from '@/app/components/base/loading'
import { ToastContext } from '@/app/components/base/toast'
import { fetchAgentLogDetail } from '@/service/log'
import { useAppDetail } from '@/service/use-apps'
import { cn } from '@/utils/classnames'
import ResultPanel from './result'
import TracingPanel from './tracing'
@ -32,7 +33,8 @@ const AgentLogDetail: FC<AgentLogDetailProps> = ({
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const [currentTab, setCurrentTab] = useState<string>(activeTab)
const appDetail = useAppStore(s => s.appDetail)
const { appId } = useParams()
const { data: appDetail } = useAppDetail(appId as string)
const [loading, setLoading] = useState<boolean>(true)
const [runDetail, setRunDetail] = useState<AgentLogDetailResponse>()
const [list, setList] = useState<AgentIteration[]>([])

View File

@ -1,8 +1,8 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import type { AgentLogDetailResponse } from '@/models/log'
import { useEffect, useRef } from 'react'
import { useStore as useAppStore } from '@/app/components/app/store'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
import { ToastProvider } from '@/app/components/base/toast'
import AgentLogModal from '.'
@ -64,21 +64,34 @@ const MOCK_CHAT_ITEM: IChatItem = {
conversationId: 'conv-123',
}
const MOCK_APP_DETAIL = {
id: 'app-1',
name: 'Analytics Agent',
mode: 'agent-chat',
}
const createQueryClient = () => {
const client = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: false,
},
},
})
client.setQueryData(['apps', 'detail', 'app-1'], MOCK_APP_DETAIL)
return client
}
const AgentLogModalDemo = ({
width = 960,
}: {
width?: number
}) => {
const originalFetchRef = useRef<typeof globalThis.fetch>(null)
const setAppDetail = useAppStore(state => state.setAppDetail)
const [queryClient] = useState(() => createQueryClient())
useEffect(() => {
setAppDetail({
id: 'app-1',
name: 'Analytics Agent',
mode: 'agent-chat',
} as any)
originalFetchRef.current = globalThis.fetch?.bind(globalThis)
const handler = async (input: RequestInfo | URL, init?: RequestInit) => {
@ -104,22 +117,23 @@ const AgentLogModalDemo = ({
return () => {
if (originalFetchRef.current)
globalThis.fetch = originalFetchRef.current
setAppDetail(undefined)
}
}, [setAppDetail])
}, [])
return (
<ToastProvider>
<div className="relative min-h-[540px] w-full bg-background-default-subtle p-6">
<AgentLogModal
currentLogItem={MOCK_CHAT_ITEM}
width={width}
onCancel={() => {
console.log('Agent log modal closed')
}}
/>
</div>
</ToastProvider>
<QueryClientProvider client={queryClient}>
<ToastProvider>
<div className="relative min-h-[540px] w-full bg-background-default-subtle p-6">
<AgentLogModal
currentLogItem={MOCK_CHAT_ITEM}
width={width}
onCancel={() => {
console.log('Agent log modal closed')
}}
/>
</div>
</ToastProvider>
</QueryClientProvider>
)
}

View File

@ -72,7 +72,7 @@ const ToolCallItem: FC<Props> = ({ toolCall, isLLM = false, isFinal, tokens, obs
{toolName}
</div>
<div className="shrink-0 text-xs leading-[18px] text-text-tertiary">
{!!toolCall.time_cost && (
{toolCall.time_cost && (
<span>{getTime(toolCall.time_cost || 0)}</span>
)}
{isLLM && (

View File

@ -212,7 +212,7 @@ const Answer: FC<AnswerProps> = ({
)
}
{
!!(item.siblingCount && item.siblingCount > 1 && item.siblingIndex !== undefined) && (
item.siblingCount && item.siblingCount > 1 && item.siblingIndex !== undefined && (
<ContentSwitch
count={item.siblingCount}
currentIndex={item.siblingIndex}

View File

@ -29,7 +29,7 @@ const More: FC<MoreProps> = ({
>
{`${t('detail.tokenCost', { ns: 'appLog' })} ${formatNumber(more.tokens)}`}
</div>
{!!more.tokens_per_second && (
{more.tokens_per_second && (
<div
className="mr-2 max-w-[25%] shrink-0 truncate"
title={`${more.tokens_per_second} tokens/s`}

View File

@ -1,4 +1,4 @@
import type { FC, MouseEvent } from 'react'
import type { FC } from 'react'
import type { Resources } from './index'
import Link from 'next/link'
import { Fragment, useState } from 'react'
@ -18,8 +18,6 @@ import {
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { useDocumentDownload } from '@/service/knowledge/use-document'
import { downloadUrl } from '@/utils/download'
import ProgressTooltip from './progress-tooltip'
import Tooltip from './tooltip'
@ -38,30 +36,6 @@ const Popup: FC<PopupProps> = ({
? (/\.([^.]*)$/.exec(data.documentName)?.[1] || '')
: 'notion'
const { mutateAsync: downloadDocument, isPending: isDownloading } = useDocumentDownload()
/**
* Download the original uploaded file for citations whose data source is upload-file.
* We request a signed URL from the dataset document download endpoint, then trigger browser download.
*/
const handleDownloadUploadFile = async (e: MouseEvent<HTMLElement>) => {
// Prevent toggling the citation popup when user clicks the download link.
e.preventDefault()
e.stopPropagation()
// Only upload-file citations can be downloaded this way (needs dataset/document ids).
const isUploadFile = data.dataSourceType === 'upload_file' || data.dataSourceType === 'file'
const datasetId = data.sources?.[0]?.dataset_id
const documentId = data.documentId || data.sources?.[0]?.document_id
if (!isUploadFile || !datasetId || !documentId || isDownloading)
return
// Fetch signed URL (usually points to `/files/<id>/file-preview?...&as_attachment=true`).
const res = await downloadDocument({ datasetId, documentId })
if (res?.url)
downloadUrl({ url: res.url, fileName: data.documentName })
}
return (
<PortalToFollowElem
open={open}
@ -75,7 +49,6 @@ const Popup: FC<PopupProps> = ({
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
<div className="flex h-7 max-w-[240px] items-center rounded-lg bg-components-button-secondary-bg px-2">
<FileIcon type={fileType} className="mr-1 h-4 w-4 shrink-0" />
{/* Keep the trigger purely for opening the popup (no download link here). */}
<div className="truncate text-xs text-text-tertiary">{data.documentName}</div>
</div>
</PortalToFollowElemTrigger>
@ -84,21 +57,7 @@ const Popup: FC<PopupProps> = ({
<div className="px-4 pb-2 pt-3">
<div className="flex h-[18px] items-center">
<FileIcon type={fileType} className="mr-1 h-4 w-4 shrink-0" />
<div className="system-xs-medium truncate text-text-tertiary">
{/* If it's an upload-file reference, the title becomes a download link. */}
{(data.dataSourceType === 'upload_file' || data.dataSourceType === 'file') && !!data.sources?.[0]?.dataset_id
? (
<button
type="button"
className="cursor-pointer truncate text-text-tertiary hover:underline"
onClick={handleDownloadUploadFile}
disabled={isDownloading}
>
{data.documentName}
</button>
)
: data.documentName}
</div>
<div className="system-xs-medium truncate text-text-tertiary">{data.documentName}</div>
</div>
</div>
<div className="max-h-[450px] overflow-y-auto rounded-lg bg-components-panel-bg px-4 py-0.5">
@ -146,7 +105,7 @@ const Popup: FC<PopupProps> = ({
icon={<BezierCurve03 className="mr-1 h-3 w-3" />}
/>
{
!!source.score && (
source.score && (
<ProgressTooltip data={Number(source.score.toFixed(2))} />
)
}

View File

@ -1,3 +1,4 @@
import type { UnsafeUnwrappedHeaders } from 'next/headers'
import type { FC } from 'react'
import { headers } from 'next/headers'
import Script from 'next/script'
@ -25,14 +26,14 @@ const extractNonceFromCSP = (cspHeader: string | null): string | undefined => {
return nonceMatch ? nonceMatch[1] : undefined
}
const GA: FC<IGAProps> = async ({
const GA: FC<IGAProps> = ({
gaType,
}) => {
if (IS_CE_EDITION)
return null
const cspHeader = IS_PROD
? (await headers()).get('content-security-policy')
? (headers() as unknown as UnsafeUnwrappedHeaders).get('content-security-policy')
: null
const nonce = extractNonceFromCSP(cspHeader)

View File

@ -90,7 +90,7 @@ const UploaderButton: FC<UploaderButtonProps> = ({
<PortalToFollowElemContent className="z-50">
<div className="w-[260px] rounded-lg border-[0.5px] border-gray-200 bg-white p-2 shadow-lg">
<ImageLinkInput onUpload={handleUpload} disabled={disabled} />
{!!hasUploadFromLocal && (
{hasUploadFromLocal && (
<>
<div className="mt-2 flex items-center px-2 text-xs font-medium text-gray-400">
<div className="mr-3 h-px w-[93px] bg-gradient-to-l from-[#F3F4F6]" />

View File

@ -109,7 +109,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({
disabled={disabled}
{...props}
/>
{!!(showClearIcon && value && !disabled && !destructive) && (
{showClearIcon && value && !disabled && !destructive && (
<div
className={cn('group absolute right-2 top-1/2 -translate-y-1/2 cursor-pointer p-[1px]')}
onClick={onClear}

View File

@ -205,7 +205,7 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any
}
catch {
try {
// eslint-disable-next-line no-new-func
// eslint-disable-next-line no-new-func, sonarjs/code-eval
const result = new Function(`return ${trimmedContent}`)()
if (typeof result === 'object' && result !== null) {
setFinalChartOption(result)
@ -250,7 +250,7 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any
}
catch {
try {
// eslint-disable-next-line no-new-func
// eslint-disable-next-line no-new-func, sonarjs/code-eval
const result = new Function(`return ${trimmedContent}`)()
if (typeof result === 'object' && result !== null) {
setFinalChartOption(result)

View File

@ -2,8 +2,8 @@ import type { Meta, StoryObj } from '@storybook/nextjs'
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import type { WorkflowRunDetailResponse } from '@/models/log'
import type { NodeTracing, NodeTracingListResponse } from '@/types/workflow'
import { useEffect } from 'react'
import { useStore } from '@/app/components/app/store'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
import { WorkflowContextProvider } from '@/app/components/workflow/context'
import { BlockEnum } from '@/app/components/workflow/types'
import MessageLogModal from '.'
@ -94,12 +94,24 @@ const mockCurrentLogItem: IChatItem = {
workflow_run_id: 'run-demo-1',
}
const useMessageLogMocks = () => {
useEffect(() => {
const store = useStore.getState()
store.setAppDetail(SAMPLE_APP_DETAIL)
const createQueryClient = () => {
const client = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: false,
},
},
})
client.setQueryData(['apps', 'detail', 'app-demo-1'], SAMPLE_APP_DETAIL)
return client
}
const originalFetch = globalThis.fetch?.bind(globalThis) ?? null
const useMessageLogMocks = () => {
const originalFetchRef = useRef<typeof globalThis.fetch>(null)
useEffect(() => {
originalFetchRef.current = globalThis.fetch?.bind(globalThis) ?? null
const handle = async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string'
@ -122,8 +134,8 @@ const useMessageLogMocks = () => {
)
}
if (originalFetch)
return originalFetch(input, init)
if (originalFetchRef.current)
return originalFetchRef.current(input, init)
throw new Error(`Unmocked fetch call for ${url}`)
}
@ -131,8 +143,8 @@ const useMessageLogMocks = () => {
globalThis.fetch = handle as typeof globalThis.fetch
return () => {
globalThis.fetch = originalFetch || globalThis.fetch
useStore.getState().setAppDetail(undefined)
if (originalFetchRef.current)
globalThis.fetch = originalFetchRef.current
}
}, [])
}
@ -140,17 +152,21 @@ const useMessageLogMocks = () => {
type MessageLogModalProps = React.ComponentProps<typeof MessageLogModal>
const MessageLogPreview = (props: MessageLogModalProps) => {
const [queryClient] = useState(() => createQueryClient())
useMessageLogMocks()
return (
<div className="relative min-h-[640px] w-full bg-background-default-subtle p-6">
<WorkflowContextProvider>
<MessageLogModal
{...props}
currentLogItem={mockCurrentLogItem}
/>
</WorkflowContextProvider>
</div>
<QueryClientProvider client={queryClient}>
<div className="relative min-h-[640px] w-full bg-background-default-subtle p-6">
<WorkflowContextProvider>
<MessageLogModal
{...props}
currentLogItem={mockCurrentLogItem}
/>
</WorkflowContextProvider>
</div>
</QueryClientProvider>
)
}

View File

@ -2,10 +2,11 @@ import type { FC } from 'react'
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import { RiCloseLine } from '@remixicon/react'
import { useClickAway } from 'ahooks'
import { useParams } from 'next/navigation'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore } from '@/app/components/app/store'
import Run from '@/app/components/workflow/run'
import { useAppDetail } from '@/service/use-apps'
import { cn } from '@/utils/classnames'
type MessageLogModalProps = {
@ -25,7 +26,8 @@ const MessageLogModal: FC<MessageLogModalProps> = ({
const { t } = useTranslation()
const ref = useRef(null)
const [mounted, setMounted] = useState(false)
const appDetail = useStore(state => state.appDetail)
const { appId } = useParams()
const { data: appDetail } = useAppDetail(appId as string)
useClickAway(() => {
if (mounted)

View File

@ -52,7 +52,7 @@ export default function Modal({
<div className={cn('flex min-h-full items-center justify-center p-4 text-center', containerClassName)}>
<TransitionChild>
<DialogPanel className={cn('relative w-full max-w-[480px] rounded-2xl bg-components-panel-bg p-6 text-left align-middle shadow-xl transition-all', overflowVisible ? 'overflow-visible' : 'overflow-hidden', 'duration-100 ease-in data-[closed]:scale-95 data-[closed]:opacity-0', 'data-[enter]:scale-100 data-[enter]:opacity-100', 'data-[enter]:scale-95 data-[leave]:opacity-0', className)}>
{!!title && (
{title && (
<DialogTitle
as="h3"
className="title-2xl-semi-bold text-text-primary"
@ -60,7 +60,7 @@ export default function Modal({
{title}
</DialogTitle>
)}
{!!description && (
{description && (
<div className="body-md-regular mt-2 text-text-secondary">
{description}
</div>

View File

@ -86,7 +86,7 @@ const Modal = ({
</div>
</div>
{
!!children && (
children && (
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-3">{children}</div>
)
}
@ -125,7 +125,7 @@ const Modal = ({
</Button>
</div>
</div>
{!!bottomSlot && (
{bottomSlot && (
<div className="shrink-0">
{bottomSlot}
</div>

View File

@ -56,7 +56,7 @@ const RadioCard: FC<Props> = ({
</div>
)}
</div>
{!!((isChosen && chosenConfig) || noRadio) && (
{((isChosen && chosenConfig) || noRadio) && (
<div className="mt-2 flex gap-x-2">
<div className="size-8 shrink-0"></div>
<div className={cn(chosenConfigWrapClassName, 'grow')}>

View File

@ -52,7 +52,7 @@ export default function Radio({
)}
onClick={() => handleChange(value)}
>
{!!children && (
{children && (
<label
className={
cn(labelClassName, 'cursor-pointer text-sm')

View File

@ -130,7 +130,7 @@ export const SegmentedControl = <T extends string | number | symbol>({
{text && (
<div className={cn('inline-flex items-center gap-x-1', ItemTextWrapperVariants({ size }))}>
<span>{text}</span>
{!!(count && size === 'large') && (
{count && size === 'large' && (
<div className="system-2xs-medium-uppercase inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-[5px] text-text-tertiary">
{count}
</div>

View File

@ -379,7 +379,7 @@ const PortalSelect: FC<PortalSelectProps> = ({
{selectedItem?.name ?? localPlaceholder}
</span>
<div className="mx-0.5">
{!!(installedValue && selectedItem && selectedItem.value !== installedValue) && (
{installedValue && selectedItem && selectedItem.value !== installedValue && (
<Badge>
{installedValue}
{' '}

View File

@ -80,7 +80,7 @@ const Toast = ({
<div className="system-sm-semibold text-text-primary [word-break:break-word]">{message}</div>
{customComponent}
</div>
{!!children && (
{children && (
<div className="system-xs-regular text-text-secondary">
{children}
</div>

View File

@ -12,11 +12,11 @@ export const ToolTipContent: FC<ToolTipContentProps> = ({
}) => {
return (
<div className="w-[180px]">
{!!title && (
{title && (
<div className="mb-1.5 font-semibold text-text-secondary">{title}</div>
)}
<div className="mb-1.5 text-text-tertiary">{children}</div>
{!!action && <div className="cursor-pointer text-text-accent">{action}</div>}
{action && <div className="cursor-pointer text-text-accent">{action}</div>}
</div>
)
}

View File

@ -109,7 +109,7 @@ const Tooltip: FC<TooltipProps> = ({
<PortalToFollowElemContent
className={cn('z-[9999]', portalContentClassName || '')}
>
{!!popupContent && (
{popupContent && (
<div
className={cn(
!noDecoration && 'system-xs-regular relative max-w-[300px] break-words rounded-md bg-components-panel-bg px-3 py-2 text-left text-text-tertiary shadow-lg',

View File

@ -87,10 +87,10 @@ export const OptionCard: FC<OptionCardProps> = (
disabled={disabled}
/>
{/** Body */}
{!!(isActive && (children || actions)) && (
{isActive && (children || actions) && (
<div className="rounded-b-xl bg-components-panel-bg px-4 py-3">
{children}
{!!actions && (
{actions && (
<div className="mt-4 flex gap-2">
{actions}
</div>

View File

@ -41,14 +41,6 @@ const createDefaultCrawlOptions = (overrides: Partial<CrawlOptions> = {}): Crawl
...overrides,
})
const createDeferred = <T,>() => {
let resolve!: (value: T) => void
const promise = new Promise<T>((res) => {
resolve = res
})
return { promise, resolve }
}
const createCrawlResultItem = (overrides: Partial<CrawlResultItem> = {}): CrawlResultItem => ({
title: 'Test Page Title',
markdown: '# Test Content\n\nThis is test markdown content.',
@ -401,15 +393,7 @@ describe('WaterCrawl', () => {
it('should update controlFoldOptions when step changes', async () => {
// Arrange
const mockCreateTask = createWatercrawlTask as Mock
const mockCheckStatus = checkWatercrawlTaskStatus as Mock
const deferredCreateTask = createDeferred<{ job_id: string }>()
mockCreateTask.mockImplementation(() => deferredCreateTask.promise)
mockCheckStatus.mockResolvedValueOnce({
status: 'completed',
current: 0,
total: 0,
data: [],
})
mockCreateTask.mockImplementation(() => new Promise(() => { /* pending */ }))
const props = createDefaultProps()
@ -427,11 +411,6 @@ describe('WaterCrawl', () => {
await waitFor(() => {
expect(screen.getByText(/totalPageScraped/i)).toBeInTheDocument()
})
deferredCreateTask.resolve({ job_id: 'test-job' })
await waitFor(() => {
expect(mockCheckStatus).toHaveBeenCalled()
})
})
})
@ -1112,14 +1091,8 @@ describe('WaterCrawl', () => {
const mockCreateTask = createWatercrawlTask as Mock
const mockCheckStatus = checkWatercrawlTaskStatus as Mock
const deferredCreateTask = createDeferred<{ job_id: string }>()
mockCreateTask.mockImplementation(() => deferredCreateTask.promise)
mockCheckStatus.mockResolvedValueOnce({
status: 'completed',
current: 0,
total: 0,
data: [],
})
mockCreateTask.mockResolvedValueOnce({ job_id: 'zero-current-job' })
mockCheckStatus.mockImplementation(() => new Promise(() => { /* never resolves */ }))
const props = createDefaultProps({
crawlOptions: createDefaultCrawlOptions({ limit: 10 }),
@ -1135,11 +1108,6 @@ describe('WaterCrawl', () => {
await waitFor(() => {
expect(screen.getByText(/totalPageScraped.*0\/10/)).toBeInTheDocument()
})
deferredCreateTask.resolve({ job_id: 'zero-current-job' })
await waitFor(() => {
expect(mockCheckStatus).toHaveBeenCalled()
})
})
it('should handle crawlResult with zero total and empty limit', async () => {
@ -1147,14 +1115,8 @@ describe('WaterCrawl', () => {
const mockCreateTask = createWatercrawlTask as Mock
const mockCheckStatus = checkWatercrawlTaskStatus as Mock
const deferredCreateTask = createDeferred<{ job_id: string }>()
mockCreateTask.mockImplementation(() => deferredCreateTask.promise)
mockCheckStatus.mockResolvedValueOnce({
status: 'completed',
current: 0,
total: 0,
data: [],
})
mockCreateTask.mockResolvedValueOnce({ job_id: 'zero-total-job' })
mockCheckStatus.mockImplementation(() => new Promise(() => { /* never resolves */ }))
const props = createDefaultProps({
crawlOptions: createDefaultCrawlOptions({ limit: '0' }),
@ -1170,11 +1132,6 @@ describe('WaterCrawl', () => {
await waitFor(() => {
expect(screen.getByText(/totalPageScraped.*0\/0/)).toBeInTheDocument()
})
deferredCreateTask.resolve({ job_id: 'zero-total-job' })
await waitFor(() => {
expect(mockCheckStatus).toHaveBeenCalled()
})
})
it('should handle undefined crawlResult data in finished state', async () => {
@ -1211,14 +1168,8 @@ describe('WaterCrawl', () => {
const mockCreateTask = createWatercrawlTask as Mock
const mockCheckStatus = checkWatercrawlTaskStatus as Mock
const deferredCreateTask = createDeferred<{ job_id: string }>()
mockCreateTask.mockImplementation(() => deferredCreateTask.promise)
mockCheckStatus.mockResolvedValueOnce({
status: 'completed',
current: 0,
total: 15,
data: [],
})
mockCreateTask.mockResolvedValueOnce({ job_id: 'no-total-job' })
mockCheckStatus.mockImplementation(() => new Promise(() => { /* never resolves */ }))
const props = createDefaultProps({
crawlOptions: createDefaultCrawlOptions({ limit: 15 }),
@ -1234,11 +1185,6 @@ describe('WaterCrawl', () => {
await waitFor(() => {
expect(screen.getByText(/totalPageScraped.*0\/15/)).toBeInTheDocument()
})
deferredCreateTask.resolve({ job_id: 'no-total-job' })
await waitFor(() => {
expect(mockCheckStatus).toHaveBeenCalled()
})
})
it('should handle crawlResult with current=0 and total=0 during running', async () => {
@ -1247,12 +1193,6 @@ describe('WaterCrawl', () => {
const mockCheckStatus = checkWatercrawlTaskStatus as Mock
mockCreateTask.mockResolvedValueOnce({ job_id: 'both-zero-job' })
const deferredStatus = createDeferred<{
status: string
current: number
total: number
data: CrawlResultItem[]
}>()
mockCheckStatus
.mockResolvedValueOnce({
status: 'running',
@ -1260,7 +1200,7 @@ describe('WaterCrawl', () => {
total: 0,
data: [],
})
.mockImplementationOnce(() => deferredStatus.promise)
.mockImplementationOnce(() => new Promise(() => { /* never resolves */ }))
const props = createDefaultProps({
crawlOptions: createDefaultCrawlOptions({ limit: 5 }),
@ -1276,16 +1216,6 @@ describe('WaterCrawl', () => {
await waitFor(() => {
expect(screen.getByText(/totalPageScraped/)).toBeInTheDocument()
})
deferredStatus.resolve({
status: 'completed',
current: 0,
total: 0,
data: [],
})
await waitFor(() => {
expect(mockCheckStatus).toHaveBeenCalledTimes(2)
})
})
})
@ -1566,14 +1496,8 @@ describe('WaterCrawl', () => {
const mockCreateTask = createWatercrawlTask as Mock
const mockCheckStatus = checkWatercrawlTaskStatus as Mock
const deferredCreateTask = createDeferred<{ job_id: string }>()
mockCreateTask.mockImplementation(() => deferredCreateTask.promise)
mockCheckStatus.mockResolvedValueOnce({
status: 'completed',
current: 0,
total: 10,
data: [],
})
mockCreateTask.mockResolvedValueOnce({ job_id: 'progress-job' })
mockCheckStatus.mockImplementation(() => new Promise(() => { /* pending */ }))
const props = createDefaultProps({
crawlOptions: createDefaultCrawlOptions({ limit: 10 }),
@ -1589,11 +1513,6 @@ describe('WaterCrawl', () => {
await waitFor(() => {
expect(screen.getByText(/totalPageScraped.*0\/10/)).toBeInTheDocument()
})
deferredCreateTask.resolve({ job_id: 'progress-job' })
await waitFor(() => {
expect(mockCheckStatus).toHaveBeenCalled()
})
})
it('should display time consumed after crawl completion', async () => {

View File

@ -30,10 +30,9 @@ import { useDatasetDetailContextWithSelector as useDatasetDetailContext } from '
import useTimestamp from '@/hooks/use-timestamp'
import { ChunkingMode, DataSourceType, DocumentActionType } from '@/models/datasets'
import { DatasourceType } from '@/models/pipeline'
import { useDocumentArchive, useDocumentBatchRetryIndex, useDocumentDelete, useDocumentDisable, useDocumentDownloadZip, useDocumentEnable } from '@/service/knowledge/use-document'
import { useDocumentArchive, useDocumentBatchRetryIndex, useDocumentDelete, useDocumentDisable, useDocumentEnable } from '@/service/knowledge/use-document'
import { asyncRunSafe } from '@/utils'
import { cn } from '@/utils/classnames'
import { downloadBlob } from '@/utils/download'
import { formatNumber } from '@/utils/format'
import BatchAction from '../detail/completed/common/batch-action'
import StatusItem from '../status-item'
@ -223,7 +222,6 @@ const DocumentList: FC<IDocumentListProps> = ({
const { mutateAsync: disableDocument } = useDocumentDisable()
const { mutateAsync: deleteDocument } = useDocumentDelete()
const { mutateAsync: retryIndexDocument } = useDocumentBatchRetryIndex()
const { mutateAsync: requestDocumentsZip, isPending: isDownloadingZip } = useDocumentDownloadZip()
const handleAction = (actionName: DocumentActionType) => {
return async () => {
@ -302,39 +300,6 @@ const DocumentList: FC<IDocumentListProps> = ({
return dataSourceType === DatasourceType.onlineDrive
}, [])
const downloadableSelectedIds = useMemo(() => {
const selectedSet = new Set(selectedIds)
return localDocs
.filter(doc => selectedSet.has(doc.id) && doc.data_source_type === DataSourceType.FILE)
.map(doc => doc.id)
}, [localDocs, selectedIds])
/**
* Generate a random ZIP filename for bulk document downloads.
* We intentionally avoid leaking dataset info in the exported archive name.
*/
const generateDocsZipFileName = useCallback((): string => {
// Prefer UUID for uniqueness; fall back to time+random when unavailable.
const randomPart = (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')
? crypto.randomUUID()
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
return `${randomPart}-docs.zip`
}, [])
const handleBatchDownload = useCallback(async () => {
if (isDownloadingZip)
return
// Download as a single ZIP to avoid browser caps on multiple automatic downloads.
const [e, blob] = await asyncRunSafe(requestDocumentsZip({ datasetId, documentIds: downloadableSelectedIds }))
if (e || !blob) {
Toast.notify({ type: 'error', message: t('actionMsg.downloadUnsuccessfully', { ns: 'common' }) })
return
}
downloadBlob({ data: blob, fileName: generateDocsZipFileName() })
}, [datasetId, downloadableSelectedIds, generateDocsZipFileName, isDownloadingZip, requestDocumentsZip, t])
return (
<div className="relative mt-3 flex h-full w-full flex-col">
<div className="relative h-0 grow overflow-x-auto">
@ -498,7 +463,6 @@ const DocumentList: FC<IDocumentListProps> = ({
onArchive={handleAction(DocumentActionType.archive)}
onBatchEnable={handleAction(DocumentActionType.enable)}
onBatchDisable={handleAction(DocumentActionType.disable)}
onBatchDownload={downloadableSelectedIds.length > 0 ? handleBatchDownload : undefined}
onBatchDelete={handleAction(DocumentActionType.delete)}
onEditMetadata={showEditModal}
onBatchReIndex={hasErrorDocumentsSelected ? handleBatchReIndex : undefined}
@ -508,7 +472,7 @@ const DocumentList: FC<IDocumentListProps> = ({
/>
)}
{/* Show Pagination only if the total is more than the limit */}
{!!pagination.total && (
{pagination.total && (
<Pagination
{...pagination}
className="w-full shrink-0"

View File

@ -1,10 +1,8 @@
import type { OperationName } from '../types'
import type { CommonResponse } from '@/models/common'
import type { DocumentDownloadResponse } from '@/service/datasets'
import {
RiArchive2Line,
RiDeleteBinLine,
RiDownload2Line,
RiEditLine,
RiEqualizer2Line,
RiLoopLeftLine,
@ -30,7 +28,6 @@ import {
useDocumentArchive,
useDocumentDelete,
useDocumentDisable,
useDocumentDownload,
useDocumentEnable,
useDocumentPause,
useDocumentResume,
@ -40,7 +37,6 @@ import {
} from '@/service/knowledge/use-document'
import { asyncRunSafe } from '@/utils'
import { cn } from '@/utils/classnames'
import { downloadUrl } from '@/utils/download'
import s from '../style.module.css'
import RenameModal from './rename-modal'
@ -73,7 +69,7 @@ const Operations = ({
scene = 'list',
className = '',
}: OperationsProps) => {
const { id, name, enabled = false, archived = false, data_source_type, display_status } = detail || {}
const { id, enabled = false, archived = false, data_source_type, display_status } = detail || {}
const [showModal, setShowModal] = useState(false)
const [deleting, setDeleting] = useState(false)
const { notify } = useContext(ToastContext)
@ -84,7 +80,6 @@ const Operations = ({
const { mutateAsync: enableDocument } = useDocumentEnable()
const { mutateAsync: disableDocument } = useDocumentDisable()
const { mutateAsync: deleteDocument } = useDocumentDelete()
const { mutateAsync: downloadDocument, isPending: isDownloading } = useDocumentDownload()
const { mutateAsync: syncDocument } = useSyncDocument()
const { mutateAsync: syncWebsite } = useSyncWebsite()
const { mutateAsync: pauseDocument } = useDocumentPause()
@ -163,24 +158,6 @@ const Operations = ({
onUpdate()
}, [onUpdate])
const handleDownload = useCallback(async () => {
// Avoid repeated clicks while the signed URL request is in-flight.
if (isDownloading)
return
// Request a signed URL first (it points to `/files/<id>/file-preview?...&as_attachment=true`).
const [e, res] = await asyncRunSafe<DocumentDownloadResponse>(
downloadDocument({ datasetId, documentId: id }) as Promise<DocumentDownloadResponse>,
)
if (e || !res?.url) {
notify({ type: 'error', message: t('actionMsg.downloadUnsuccessfully', { ns: 'common' }) })
return
}
// Trigger download without navigating away (helps avoid duplicate downloads in some browsers).
downloadUrl({ url: res.url, fileName: name })
}, [datasetId, downloadDocument, id, isDownloading, name, notify, t])
return (
<div className="flex items-center" onClick={e => e.stopPropagation()}>
{isListScene && !embeddingAvailable && (
@ -237,20 +214,6 @@ const Operations = ({
<RiEditLine className="h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.table.rename', { ns: 'datasetDocuments' })}</span>
</div>
{data_source_type === DataSourceType.FILE && (
<div
className={s.actionItem}
onClick={(evt) => {
evt.preventDefault()
evt.stopPropagation()
evt.nativeEvent.stopImmediatePropagation?.()
handleDownload()
}}
>
<RiDownload2Line className="h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.download', { ns: 'datasetDocuments' })}</span>
</div>
)}
{['notion_import', DataSourceType.WEB].includes(data_source_type) && (
<div className={s.actionItem} onClick={() => onOperate('sync')}>
<RiLoopLeftLine className="h-4 w-4 text-text-tertiary" />
@ -260,23 +223,6 @@ const Operations = ({
<Divider className="my-1" />
</>
)}
{archived && data_source_type === DataSourceType.FILE && (
<>
<div
className={s.actionItem}
onClick={(evt) => {
evt.preventDefault()
evt.stopPropagation()
evt.nativeEvent.stopImmediatePropagation?.()
handleDownload()
}}
>
<RiDownload2Line className="h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.download', { ns: 'datasetDocuments' })}</span>
</div>
<Divider className="my-1" />
</>
)}
{!archived && display_status?.toLowerCase() === 'indexing' && (
<div className={s.actionItem} onClick={() => onOperate('pause')}>
<RiPauseCircleLine className="h-4 w-4 text-text-tertiary" />

View File

@ -24,7 +24,7 @@ vi.mock('@/app/components/datasets/documents/detail/metadata', () => ({
<div data-testid="field-info" data-label={label}>
<span data-testid="field-label">{label}</span>
<span data-testid="field-value">{displayedValue}</span>
{!!valueIcon && <span data-testid="field-icon">{valueIcon}</span>}
{valueIcon && <span data-testid="field-icon">{valueIcon}</span>}
</div>
),
}))

View File

@ -28,10 +28,10 @@ const RuleDetail = ({
case 'mode':
value = !sourceData?.mode
? value
// eslint-disable-next-line sonarjs/no-nested-conditional
: sourceData.mode === ProcessMode.general
? (t('embedding.custom', { ns: 'datasetDocuments' }) as string)
// eslint-disable-next-line sonarjs/no-nested-conditional
: `${t('embedding.hierarchical', { ns: 'datasetDocuments' })} · ${sourceData?.rules?.parent_mode === 'paragraph'
? t('parentMode.paragraph', { ns: 'dataset' })
: t('parentMode.fullDoc', { ns: 'dataset' })}`
@ -70,7 +70,7 @@ const RuleDetail = ({
src={
retrievalMethod === RETRIEVE_METHOD.fullText
? retrievalIcon.fullText
// eslint-disable-next-line sonarjs/no-nested-conditional
: retrievalMethod === RETRIEVE_METHOD.hybrid
? retrievalIcon.hybrid
: retrievalIcon.vector

View File

@ -1,5 +1,5 @@
import type { FC } from 'react'
import { RiArchive2Line, RiCheckboxCircleLine, RiCloseCircleLine, RiDeleteBinLine, RiDownload2Line, RiDraftLine, RiRefreshLine } from '@remixicon/react'
import { RiArchive2Line, RiCheckboxCircleLine, RiCloseCircleLine, RiDeleteBinLine, RiDraftLine, RiRefreshLine } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
@ -14,7 +14,6 @@ type IBatchActionProps = {
selectedIds: string[]
onBatchEnable: () => void
onBatchDisable: () => void
onBatchDownload?: () => void
onBatchDelete: () => Promise<void>
onArchive?: () => void
onEditMetadata?: () => void
@ -27,7 +26,6 @@ const BatchAction: FC<IBatchActionProps> = ({
selectedIds,
onBatchEnable,
onBatchDisable,
onBatchDownload,
onArchive,
onBatchDelete,
onEditMetadata,
@ -105,16 +103,6 @@ const BatchAction: FC<IBatchActionProps> = ({
<span className="px-0.5">{t(`${i18nPrefix}.reIndex`, { ns: 'dataset' })}</span>
</Button>
)}
{onBatchDownload && (
<Button
variant="ghost"
className="gap-x-0.5 px-3"
onClick={onBatchDownload}
>
<RiDownload2Line className="size-4" />
<span className="px-0.5">{t(`${i18nPrefix}.download`, { ns: 'dataset' })}</span>
</Button>
)}
<Button
variant="ghost"
destructive

View File

@ -1,239 +0,0 @@
import type { CreateExternalAPIReq, FormSchema } from '../declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Form from './Form'
// Mock context for i18n doc link
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
}))
describe('Form', () => {
const defaultFormSchemas: FormSchema[] = [
{
variable: 'name',
type: 'text',
label: { en_US: 'Name', zh_CN: '名称' },
required: true,
},
{
variable: 'endpoint',
type: 'text',
label: { en_US: 'API Endpoint', zh_CN: 'API 端点' },
required: true,
},
{
variable: 'api_key',
type: 'secret',
label: { en_US: 'API Key', zh_CN: 'API 密钥' },
required: true,
},
]
const defaultValue: CreateExternalAPIReq = {
name: '',
settings: {
endpoint: '',
api_key: '',
},
}
const defaultProps = {
value: defaultValue,
onChange: vi.fn(),
formSchemas: defaultFormSchemas,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(<Form {...defaultProps} />)
expect(container.querySelector('form')).toBeInTheDocument()
})
it('should render all form fields based on formSchemas', () => {
render(<Form {...defaultProps} />)
expect(screen.getByLabelText(/name/i)).toBeInTheDocument()
expect(screen.getByLabelText(/api endpoint/i)).toBeInTheDocument()
expect(screen.getByLabelText(/api key/i)).toBeInTheDocument()
})
it('should render required indicator for required fields', () => {
render(<Form {...defaultProps} />)
const labels = screen.getAllByText('*')
expect(labels.length).toBe(3) // All 3 fields are required
})
it('should render documentation link for endpoint field', () => {
render(<Form {...defaultProps} />)
const docLink = screen.getByText('dataset.externalAPIPanelDocumentation')
expect(docLink).toBeInTheDocument()
expect(docLink.closest('a')).toHaveAttribute('href', expect.stringContaining('docs.example.com'))
})
it('should render password type input for secret fields', () => {
render(<Form {...defaultProps} />)
const apiKeyInput = screen.getByLabelText(/api key/i)
expect(apiKeyInput).toHaveAttribute('type', 'password')
})
it('should render text type input for text fields', () => {
render(<Form {...defaultProps} />)
const nameInput = screen.getByLabelText(/name/i)
expect(nameInput).toHaveAttribute('type', 'text')
})
})
describe('Props', () => {
it('should apply custom className to form', () => {
const { container } = render(<Form {...defaultProps} className="custom-form-class" />)
expect(container.querySelector('form')).toHaveClass('custom-form-class')
})
it('should apply itemClassName to form items', () => {
const { container } = render(<Form {...defaultProps} itemClassName="custom-item-class" />)
const items = container.querySelectorAll('.custom-item-class')
expect(items.length).toBe(3)
})
it('should apply fieldLabelClassName to labels', () => {
const { container } = render(<Form {...defaultProps} fieldLabelClassName="custom-label-class" />)
const labels = container.querySelectorAll('label.custom-label-class')
expect(labels.length).toBe(3)
})
it('should apply inputClassName to inputs', () => {
render(<Form {...defaultProps} inputClassName="custom-input-class" />)
const inputs = screen.getAllByRole('textbox')
inputs.forEach((input) => {
expect(input).toHaveClass('custom-input-class')
})
})
it('should display initial values', () => {
const valueWithData: CreateExternalAPIReq = {
name: 'Test API',
settings: {
endpoint: 'https://api.example.com',
api_key: 'secret-key',
},
}
render(<Form {...defaultProps} value={valueWithData} />)
expect(screen.getByLabelText(/name/i)).toHaveValue('Test API')
expect(screen.getByLabelText(/api endpoint/i)).toHaveValue('https://api.example.com')
expect(screen.getByLabelText(/api key/i)).toHaveValue('secret-key')
})
})
describe('User Interactions', () => {
it('should call onChange when name field changes', () => {
const onChange = vi.fn()
render(<Form {...defaultProps} onChange={onChange} />)
const nameInput = screen.getByLabelText(/name/i)
fireEvent.change(nameInput, { target: { value: 'New API Name' } })
expect(onChange).toHaveBeenCalledWith({
name: 'New API Name',
settings: { endpoint: '', api_key: '' },
})
})
it('should call onChange when endpoint field changes', () => {
const onChange = vi.fn()
render(<Form {...defaultProps} onChange={onChange} />)
const endpointInput = screen.getByLabelText(/api endpoint/i)
fireEvent.change(endpointInput, { target: { value: 'https://new-api.example.com' } })
expect(onChange).toHaveBeenCalledWith({
name: '',
settings: { endpoint: 'https://new-api.example.com', api_key: '' },
})
})
it('should call onChange when api_key field changes', () => {
const onChange = vi.fn()
render(<Form {...defaultProps} onChange={onChange} />)
const apiKeyInput = screen.getByLabelText(/api key/i)
fireEvent.change(apiKeyInput, { target: { value: 'new-secret-key' } })
expect(onChange).toHaveBeenCalledWith({
name: '',
settings: { endpoint: '', api_key: 'new-secret-key' },
})
})
it('should update settings without affecting name', () => {
const onChange = vi.fn()
const initialValue: CreateExternalAPIReq = {
name: 'Existing Name',
settings: { endpoint: '', api_key: '' },
}
render(<Form {...defaultProps} value={initialValue} onChange={onChange} />)
const endpointInput = screen.getByLabelText(/api endpoint/i)
fireEvent.change(endpointInput, { target: { value: 'https://api.example.com' } })
expect(onChange).toHaveBeenCalledWith({
name: 'Existing Name',
settings: { endpoint: 'https://api.example.com', api_key: '' },
})
})
})
describe('Edge Cases', () => {
it('should handle empty formSchemas', () => {
const { container } = render(<Form {...defaultProps} formSchemas={[]} />)
expect(container.querySelector('form')).toBeInTheDocument()
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
it('should handle optional field (required: false)', () => {
const schemasWithOptional: FormSchema[] = [
{
variable: 'description',
type: 'text',
label: { en_US: 'Description' },
required: false,
},
]
render(<Form {...defaultProps} formSchemas={schemasWithOptional} />)
expect(screen.queryByText('*')).not.toBeInTheDocument()
})
it('should fallback to en_US label when current language label is not available', () => {
const schemasWithEnOnly: FormSchema[] = [
{
variable: 'test',
type: 'text',
label: { en_US: 'Test Field' },
required: false,
},
]
render(<Form {...defaultProps} formSchemas={schemasWithEnOnly} />)
expect(screen.getByLabelText(/test field/i)).toBeInTheDocument()
})
it('should preserve existing settings when updating one field', () => {
const onChange = vi.fn()
const initialValue: CreateExternalAPIReq = {
name: '',
settings: { endpoint: 'https://existing.com', api_key: 'existing-key' },
}
render(<Form {...defaultProps} value={initialValue} onChange={onChange} />)
const endpointInput = screen.getByLabelText(/api endpoint/i)
fireEvent.change(endpointInput, { target: { value: 'https://new.com' } })
expect(onChange).toHaveBeenCalledWith({
name: '',
settings: { endpoint: 'https://new.com', api_key: 'existing-key' },
})
})
})
})

View File

@ -1,424 +0,0 @@
import type { CreateExternalAPIReq } from '../declarations'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Import mocked service
import { createExternalAPI } from '@/service/datasets'
import AddExternalAPIModal from './index'
// Mock API service
vi.mock('@/service/datasets', () => ({
createExternalAPI: vi.fn(),
}))
// Mock toast context
const mockNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
useToastContext: () => ({
notify: mockNotify,
}),
}))
describe('AddExternalAPIModal', () => {
const defaultProps = {
onSave: vi.fn(),
onCancel: vi.fn(),
isEditMode: false,
}
const initialData: CreateExternalAPIReq = {
name: 'Test API',
settings: {
endpoint: 'https://api.example.com',
api_key: 'test-key-12345',
},
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<AddExternalAPIModal {...defaultProps} />)
expect(screen.getByText('dataset.createExternalAPI')).toBeInTheDocument()
})
it('should render create title when not in edit mode', () => {
render(<AddExternalAPIModal {...defaultProps} isEditMode={false} />)
expect(screen.getByText('dataset.createExternalAPI')).toBeInTheDocument()
})
it('should render edit title when in edit mode', () => {
render(<AddExternalAPIModal {...defaultProps} isEditMode={true} data={initialData} />)
expect(screen.getByText('dataset.editExternalAPIFormTitle')).toBeInTheDocument()
})
it('should render form fields', () => {
render(<AddExternalAPIModal {...defaultProps} />)
expect(screen.getByLabelText(/name/i)).toBeInTheDocument()
expect(screen.getByLabelText(/api endpoint/i)).toBeInTheDocument()
expect(screen.getByLabelText(/api key/i)).toBeInTheDocument()
})
it('should render cancel and save buttons', () => {
render(<AddExternalAPIModal {...defaultProps} />)
expect(screen.getByText('dataset.externalAPIForm.cancel')).toBeInTheDocument()
expect(screen.getByText('dataset.externalAPIForm.save')).toBeInTheDocument()
})
it('should render encryption notice', () => {
render(<AddExternalAPIModal {...defaultProps} />)
expect(screen.getByText('PKCS1_OAEP')).toBeInTheDocument()
})
it('should render close button', () => {
render(<AddExternalAPIModal {...defaultProps} />)
// Close button is rendered in a portal
const closeButton = document.body.querySelector('.action-btn')
expect(closeButton).toBeInTheDocument()
})
})
describe('Edit Mode with Dataset Bindings', () => {
it('should show warning when editing with dataset bindings', () => {
const datasetBindings = [
{ id: 'ds-1', name: 'Dataset 1' },
{ id: 'ds-2', name: 'Dataset 2' },
]
render(
<AddExternalAPIModal
{...defaultProps}
isEditMode={true}
data={initialData}
datasetBindings={datasetBindings}
/>,
)
expect(screen.getByText('dataset.editExternalAPIFormWarning.front')).toBeInTheDocument()
// Verify the count is displayed in the warning section
const warningElement = screen.getByText('dataset.editExternalAPIFormWarning.front').parentElement
expect(warningElement?.textContent).toContain('2')
})
it('should not show warning when no dataset bindings', () => {
render(
<AddExternalAPIModal
{...defaultProps}
isEditMode={true}
data={initialData}
datasetBindings={[]}
/>,
)
expect(screen.queryByText('dataset.editExternalAPIFormWarning.front')).not.toBeInTheDocument()
})
})
describe('Form Interactions', () => {
it('should update form values when input changes', () => {
render(<AddExternalAPIModal {...defaultProps} />)
const nameInput = screen.getByLabelText(/name/i)
fireEvent.change(nameInput, { target: { value: 'New API Name' } })
expect(nameInput).toHaveValue('New API Name')
})
it('should initialize form with data in edit mode', () => {
render(<AddExternalAPIModal {...defaultProps} isEditMode={true} data={initialData} />)
expect(screen.getByLabelText(/name/i)).toHaveValue('Test API')
expect(screen.getByLabelText(/api endpoint/i)).toHaveValue('https://api.example.com')
expect(screen.getByLabelText(/api key/i)).toHaveValue('test-key-12345')
})
it('should disable save button when form has empty inputs', () => {
render(<AddExternalAPIModal {...defaultProps} />)
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')
expect(saveButton).toBeDisabled()
})
it('should enable save button when all fields are filled', () => {
render(<AddExternalAPIModal {...defaultProps} />)
const nameInput = screen.getByLabelText(/name/i)
const endpointInput = screen.getByLabelText(/api endpoint/i)
const apiKeyInput = screen.getByLabelText(/api key/i)
fireEvent.change(nameInput, { target: { value: 'Test' } })
fireEvent.change(endpointInput, { target: { value: 'https://test.com' } })
fireEvent.change(apiKeyInput, { target: { value: 'key12345' } })
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')
expect(saveButton).not.toBeDisabled()
})
})
describe('Create Mode - Save', () => {
it('should create API and call onSave on success', async () => {
const mockResponse = {
id: 'new-api-123',
tenant_id: 'tenant-1',
name: 'Test',
description: '',
settings: { endpoint: 'https://test.com', api_key: 'key12345' },
dataset_bindings: [],
created_by: 'user-1',
created_at: '2021-01-01T00:00:00Z',
}
vi.mocked(createExternalAPI).mockResolvedValue(mockResponse)
const onSave = vi.fn()
const onCancel = vi.fn()
render(<AddExternalAPIModal {...defaultProps} onSave={onSave} onCancel={onCancel} />)
const nameInput = screen.getByLabelText(/name/i)
const endpointInput = screen.getByLabelText(/api endpoint/i)
const apiKeyInput = screen.getByLabelText(/api key/i)
fireEvent.change(nameInput, { target: { value: 'Test' } })
fireEvent.change(endpointInput, { target: { value: 'https://test.com' } })
fireEvent.change(apiKeyInput, { target: { value: 'key12345' } })
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
fireEvent.click(saveButton)
await waitFor(() => {
expect(createExternalAPI).toHaveBeenCalledWith({
body: {
name: 'Test',
settings: { endpoint: 'https://test.com', api_key: 'key12345' },
},
})
expect(mockNotify).toHaveBeenCalledWith({
type: 'success',
message: 'External API saved successfully',
})
expect(onSave).toHaveBeenCalledWith(mockResponse)
expect(onCancel).toHaveBeenCalled()
})
})
it('should show error notification when API key is too short', async () => {
render(<AddExternalAPIModal {...defaultProps} />)
const nameInput = screen.getByLabelText(/name/i)
const endpointInput = screen.getByLabelText(/api endpoint/i)
const apiKeyInput = screen.getByLabelText(/api key/i)
fireEvent.change(nameInput, { target: { value: 'Test' } })
fireEvent.change(endpointInput, { target: { value: 'https://test.com' } })
fireEvent.change(apiKeyInput, { target: { value: 'key' } }) // Less than 5 characters
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
fireEvent.click(saveButton)
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'common.apiBasedExtension.modal.apiKey.lengthError',
})
})
})
it('should handle create API error', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(createExternalAPI).mockRejectedValue(new Error('Create failed'))
render(<AddExternalAPIModal {...defaultProps} />)
const nameInput = screen.getByLabelText(/name/i)
const endpointInput = screen.getByLabelText(/api endpoint/i)
const apiKeyInput = screen.getByLabelText(/api key/i)
fireEvent.change(nameInput, { target: { value: 'Test' } })
fireEvent.change(endpointInput, { target: { value: 'https://test.com' } })
fireEvent.change(apiKeyInput, { target: { value: 'key12345' } })
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
fireEvent.click(saveButton)
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'Failed to save/update External API',
})
})
consoleSpy.mockRestore()
})
})
describe('Edit Mode - Save', () => {
it('should call onEdit directly when editing without dataset bindings', async () => {
const onEdit = vi.fn().mockResolvedValue(undefined)
const onCancel = vi.fn()
render(
<AddExternalAPIModal
{...defaultProps}
isEditMode={true}
data={initialData}
datasetBindings={[]}
onEdit={onEdit}
onCancel={onCancel}
/>,
)
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
fireEvent.click(saveButton)
await waitFor(() => {
// When no datasetBindings, onEdit is called directly with original form data
expect(onEdit).toHaveBeenCalledWith({
name: 'Test API',
settings: {
endpoint: 'https://api.example.com',
api_key: 'test-key-12345',
},
})
})
})
it('should show confirm dialog when editing with dataset bindings', async () => {
const datasetBindings = [{ id: 'ds-1', name: 'Dataset 1' }]
const onEdit = vi.fn().mockResolvedValue(undefined)
render(
<AddExternalAPIModal
{...defaultProps}
isEditMode={true}
data={initialData}
datasetBindings={datasetBindings}
onEdit={onEdit}
/>,
)
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
fireEvent.click(saveButton)
await waitFor(() => {
expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument()
})
})
it('should proceed with save after confirming in edit mode with bindings', async () => {
vi.mocked(createExternalAPI).mockResolvedValue({
id: 'api-123',
tenant_id: 'tenant-1',
name: 'Test API',
description: '',
settings: { endpoint: 'https://api.example.com', api_key: 'test-key-12345' },
dataset_bindings: [],
created_by: 'user-1',
created_at: '2021-01-01T00:00:00Z',
})
const datasetBindings = [{ id: 'ds-1', name: 'Dataset 1' }]
const onCancel = vi.fn()
render(
<AddExternalAPIModal
{...defaultProps}
isEditMode={true}
data={initialData}
datasetBindings={datasetBindings}
onCancel={onCancel}
/>,
)
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
fireEvent.click(saveButton)
await waitFor(() => {
expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument()
})
const confirmButton = screen.getByRole('button', { name: /confirm/i })
fireEvent.click(confirmButton)
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'success' }),
)
})
})
it('should close confirm dialog when cancel is clicked', async () => {
const datasetBindings = [{ id: 'ds-1', name: 'Dataset 1' }]
render(
<AddExternalAPIModal
{...defaultProps}
isEditMode={true}
data={initialData}
datasetBindings={datasetBindings}
/>,
)
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
fireEvent.click(saveButton)
await waitFor(() => {
expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument()
})
// There are multiple cancel buttons, find the one in the confirm dialog
const cancelButtons = screen.getAllByRole('button', { name: /cancel/i })
const confirmDialogCancelButton = cancelButtons[cancelButtons.length - 1]
fireEvent.click(confirmDialogCancelButton)
await waitFor(() => {
// Confirm button should be gone after canceling
expect(screen.queryAllByRole('button', { name: /confirm/i })).toHaveLength(0)
})
})
})
describe('Cancel', () => {
it('should call onCancel when cancel button is clicked', () => {
const onCancel = vi.fn()
render(<AddExternalAPIModal {...defaultProps} onCancel={onCancel} />)
const cancelButton = screen.getByText('dataset.externalAPIForm.cancel').closest('button')!
fireEvent.click(cancelButton)
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should call onCancel when close button is clicked', () => {
const onCancel = vi.fn()
render(<AddExternalAPIModal {...defaultProps} onCancel={onCancel} />)
// Close button is rendered in a portal
const closeButton = document.body.querySelector('.action-btn')!
fireEvent.click(closeButton)
expect(onCancel).toHaveBeenCalledTimes(1)
})
})
describe('Edge Cases', () => {
it('should handle undefined data in edit mode', () => {
render(<AddExternalAPIModal {...defaultProps} isEditMode={true} data={undefined} />)
expect(screen.getByLabelText(/name/i)).toHaveValue('')
})
it('should handle null datasetBindings', () => {
render(
<AddExternalAPIModal
{...defaultProps}
isEditMode={true}
data={initialData}
datasetBindings={undefined}
/>,
)
expect(screen.queryByText('dataset.editExternalAPIFormWarning.front')).not.toBeInTheDocument()
})
it('should render documentation link in encryption notice', () => {
render(<AddExternalAPIModal {...defaultProps} />)
const link = screen.getByRole('link', { name: 'PKCS1_OAEP' })
expect(link).toHaveAttribute('href', 'https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html')
expect(link).toHaveAttribute('target', '_blank')
})
})
})

View File

@ -1,207 +0,0 @@
import type { ExternalAPIItem } from '@/models/datasets'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ExternalAPIPanel from './index'
// Mock external contexts (only mock context providers, not base components)
const mockSetShowExternalKnowledgeAPIModal = vi.fn()
const mockMutateExternalKnowledgeApis = vi.fn()
let mockIsLoading = false
let mockExternalKnowledgeApiList: ExternalAPIItem[] = []
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowExternalKnowledgeAPIModal: mockSetShowExternalKnowledgeAPIModal,
}),
}))
vi.mock('@/context/external-knowledge-api-context', () => ({
useExternalKnowledgeApi: () => ({
externalKnowledgeApiList: mockExternalKnowledgeApiList,
mutateExternalKnowledgeApis: mockMutateExternalKnowledgeApis,
isLoading: mockIsLoading,
}),
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
}))
// Mock the ExternalKnowledgeAPICard to avoid mocking its internal dependencies
vi.mock('../external-knowledge-api-card', () => ({
default: ({ api }: { api: ExternalAPIItem }) => (
<div data-testid={`api-card-${api.id}`}>{api.name}</div>
),
}))
// i18n mock returns 'namespace.key' format
describe('ExternalAPIPanel', () => {
const defaultProps = {
onClose: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
mockIsLoading = false
mockExternalKnowledgeApiList = []
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<ExternalAPIPanel {...defaultProps} />)
expect(screen.getByText('dataset.externalAPIPanelTitle')).toBeInTheDocument()
})
it('should render panel title and description', () => {
render(<ExternalAPIPanel {...defaultProps} />)
expect(screen.getByText('dataset.externalAPIPanelTitle')).toBeInTheDocument()
expect(screen.getByText('dataset.externalAPIPanelDescription')).toBeInTheDocument()
})
it('should render documentation link', () => {
render(<ExternalAPIPanel {...defaultProps} />)
const docLink = screen.getByText('dataset.externalAPIPanelDocumentation')
expect(docLink).toBeInTheDocument()
expect(docLink.closest('a')).toHaveAttribute('href', 'https://docs.example.com/guides/knowledge-base/connect-external-knowledge-base')
})
it('should render create button', () => {
render(<ExternalAPIPanel {...defaultProps} />)
expect(screen.getByText('dataset.createExternalAPI')).toBeInTheDocument()
})
it('should render close button', () => {
const { container } = render(<ExternalAPIPanel {...defaultProps} />)
const closeButton = container.querySelector('[class*="action-button"]') || screen.getAllByRole('button')[0]
expect(closeButton).toBeInTheDocument()
})
})
describe('Loading State', () => {
it('should render loading indicator when isLoading is true', () => {
mockIsLoading = true
const { container } = render(<ExternalAPIPanel {...defaultProps} />)
// Loading component should be rendered
const loadingElement = container.querySelector('[class*="loading"]')
|| container.querySelector('.animate-spin')
|| screen.queryByRole('status')
expect(loadingElement || container.textContent).toBeTruthy()
})
})
describe('API List Rendering', () => {
it('should render empty list when no APIs exist', () => {
mockExternalKnowledgeApiList = []
render(<ExternalAPIPanel {...defaultProps} />)
expect(screen.queryByTestId(/api-card-/)).not.toBeInTheDocument()
})
it('should render API cards when APIs exist', () => {
mockExternalKnowledgeApiList = [
{
id: 'api-1',
tenant_id: 'tenant-1',
name: 'Test API 1',
description: '',
settings: { endpoint: 'https://api1.example.com', api_key: 'key1' },
dataset_bindings: [],
created_by: 'user-1',
created_at: '2021-01-01T00:00:00Z',
},
{
id: 'api-2',
tenant_id: 'tenant-1',
name: 'Test API 2',
description: '',
settings: { endpoint: 'https://api2.example.com', api_key: 'key2' },
dataset_bindings: [],
created_by: 'user-1',
created_at: '2021-01-01T00:00:00Z',
},
]
render(<ExternalAPIPanel {...defaultProps} />)
expect(screen.getByTestId('api-card-api-1')).toBeInTheDocument()
expect(screen.getByTestId('api-card-api-2')).toBeInTheDocument()
expect(screen.getByText('Test API 1')).toBeInTheDocument()
expect(screen.getByText('Test API 2')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onClose when close button is clicked', () => {
const onClose = vi.fn()
render(<ExternalAPIPanel onClose={onClose} />)
// Find the close button (ActionButton with close icon)
const buttons = screen.getAllByRole('button')
const closeButton = buttons.find(btn => btn.querySelector('svg[class*="ri-close"]'))
|| buttons[0]
fireEvent.click(closeButton)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should open external API modal when create button is clicked', async () => {
render(<ExternalAPIPanel {...defaultProps} />)
const createButton = screen.getByText('dataset.createExternalAPI').closest('button')!
fireEvent.click(createButton)
await waitFor(() => {
expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalledTimes(1)
expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalledWith(
expect.objectContaining({
payload: { name: '', settings: { endpoint: '', api_key: '' } },
datasetBindings: [],
isEditMode: false,
}),
)
})
})
it('should call mutateExternalKnowledgeApis in onSaveCallback', async () => {
render(<ExternalAPIPanel {...defaultProps} />)
const createButton = screen.getByText('dataset.createExternalAPI').closest('button')!
fireEvent.click(createButton)
const callArgs = mockSetShowExternalKnowledgeAPIModal.mock.calls[0][0]
callArgs.onSaveCallback()
expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled()
})
it('should call mutateExternalKnowledgeApis in onCancelCallback', async () => {
render(<ExternalAPIPanel {...defaultProps} />)
const createButton = screen.getByText('dataset.createExternalAPI').closest('button')!
fireEvent.click(createButton)
const callArgs = mockSetShowExternalKnowledgeAPIModal.mock.calls[0][0]
callArgs.onCancelCallback()
expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled()
})
})
describe('Edge Cases', () => {
it('should handle single API in list', () => {
mockExternalKnowledgeApiList = [
{
id: 'single-api',
tenant_id: 'tenant-1',
name: 'Single API',
description: '',
settings: { endpoint: 'https://single.example.com', api_key: 'key' },
dataset_bindings: [],
created_by: 'user-1',
created_at: '2021-01-01T00:00:00Z',
},
]
render(<ExternalAPIPanel {...defaultProps} />)
expect(screen.getByTestId('api-card-single-api')).toBeInTheDocument()
})
it('should render documentation link with correct target', () => {
render(<ExternalAPIPanel {...defaultProps} />)
const docLink = screen.getByText('dataset.externalAPIPanelDocumentation').closest('a')
expect(docLink).toHaveAttribute('target', '_blank')
})
})
})

View File

@ -1,382 +0,0 @@
import type { ExternalAPIItem } from '@/models/datasets'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Import mocked services
import { checkUsageExternalAPI, deleteExternalAPI, fetchExternalAPI } from '@/service/datasets'
import ExternalKnowledgeAPICard from './index'
// Mock API services
vi.mock('@/service/datasets', () => ({
fetchExternalAPI: vi.fn(),
updateExternalAPI: vi.fn(),
deleteExternalAPI: vi.fn(),
checkUsageExternalAPI: vi.fn(),
}))
// Mock contexts
const mockSetShowExternalKnowledgeAPIModal = vi.fn()
const mockMutateExternalKnowledgeApis = vi.fn()
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowExternalKnowledgeAPIModal: mockSetShowExternalKnowledgeAPIModal,
}),
}))
vi.mock('@/context/external-knowledge-api-context', () => ({
useExternalKnowledgeApi: () => ({
mutateExternalKnowledgeApis: mockMutateExternalKnowledgeApis,
}),
}))
describe('ExternalKnowledgeAPICard', () => {
const mockApi: ExternalAPIItem = {
id: 'api-123',
tenant_id: 'tenant-1',
name: 'Test External API',
description: 'Test API description',
settings: {
endpoint: 'https://api.example.com/knowledge',
api_key: 'secret-key-123',
},
dataset_bindings: [],
created_by: 'user-1',
created_at: '2021-01-01T00:00:00Z',
}
const defaultProps = {
api: mockApi,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<ExternalKnowledgeAPICard {...defaultProps} />)
expect(screen.getByText('Test External API')).toBeInTheDocument()
})
it('should render API name', () => {
render(<ExternalKnowledgeAPICard {...defaultProps} />)
expect(screen.getByText('Test External API')).toBeInTheDocument()
})
it('should render API endpoint', () => {
render(<ExternalKnowledgeAPICard {...defaultProps} />)
expect(screen.getByText('https://api.example.com/knowledge')).toBeInTheDocument()
})
it('should render edit and delete buttons', () => {
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const buttons = container.querySelectorAll('button')
expect(buttons.length).toBe(2)
})
it('should render API connection icon', () => {
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const icon = container.querySelector('svg')
expect(icon).toBeInTheDocument()
})
})
describe('User Interactions - Edit', () => {
it('should fetch API details and open modal when edit button is clicked', async () => {
const mockResponse: ExternalAPIItem = {
id: 'api-123',
tenant_id: 'tenant-1',
name: 'Test External API',
description: 'Test API description',
settings: {
endpoint: 'https://api.example.com/knowledge',
api_key: 'secret-key-123',
},
dataset_bindings: [{ id: 'ds-1', name: 'Dataset 1' }],
created_by: 'user-1',
created_at: '2021-01-01T00:00:00Z',
}
vi.mocked(fetchExternalAPI).mockResolvedValue(mockResponse)
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const buttons = container.querySelectorAll('button')
const editButton = buttons[0]
fireEvent.click(editButton)
await waitFor(() => {
expect(fetchExternalAPI).toHaveBeenCalledWith({ apiTemplateId: 'api-123' })
expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalledWith(
expect.objectContaining({
payload: {
name: 'Test External API',
settings: {
endpoint: 'https://api.example.com/knowledge',
api_key: 'secret-key-123',
},
},
isEditMode: true,
datasetBindings: [{ id: 'ds-1', name: 'Dataset 1' }],
}),
)
})
})
it('should handle fetch error gracefully', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(fetchExternalAPI).mockRejectedValue(new Error('Fetch failed'))
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const buttons = container.querySelectorAll('button')
const editButton = buttons[0]
fireEvent.click(editButton)
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith(
'Error fetching external knowledge API data:',
expect.any(Error),
)
})
consoleSpy.mockRestore()
})
it('should call mutate on save callback', async () => {
const mockResponse: ExternalAPIItem = {
id: 'api-123',
tenant_id: 'tenant-1',
name: 'Test External API',
description: 'Test API description',
settings: {
endpoint: 'https://api.example.com/knowledge',
api_key: 'secret-key-123',
},
dataset_bindings: [],
created_by: 'user-1',
created_at: '2021-01-01T00:00:00Z',
}
vi.mocked(fetchExternalAPI).mockResolvedValue(mockResponse)
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const editButton = container.querySelectorAll('button')[0]
fireEvent.click(editButton)
await waitFor(() => {
expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalled()
})
// Simulate save callback
const modalCall = mockSetShowExternalKnowledgeAPIModal.mock.calls[0][0]
modalCall.onSaveCallback()
expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled()
})
it('should call mutate on cancel callback', async () => {
const mockResponse: ExternalAPIItem = {
id: 'api-123',
tenant_id: 'tenant-1',
name: 'Test External API',
description: 'Test API description',
settings: {
endpoint: 'https://api.example.com/knowledge',
api_key: 'secret-key-123',
},
dataset_bindings: [],
created_by: 'user-1',
created_at: '2021-01-01T00:00:00Z',
}
vi.mocked(fetchExternalAPI).mockResolvedValue(mockResponse)
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const editButton = container.querySelectorAll('button')[0]
fireEvent.click(editButton)
await waitFor(() => {
expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalled()
})
// Simulate cancel callback
const modalCall = mockSetShowExternalKnowledgeAPIModal.mock.calls[0][0]
modalCall.onCancelCallback()
expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled()
})
})
describe('User Interactions - Delete', () => {
it('should check usage and show confirm dialog when delete button is clicked', async () => {
vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 })
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const buttons = container.querySelectorAll('button')
const deleteButton = buttons[1]
fireEvent.click(deleteButton)
await waitFor(() => {
expect(checkUsageExternalAPI).toHaveBeenCalledWith({ apiTemplateId: 'api-123' })
})
// Confirm dialog should be shown
await waitFor(() => {
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
})
})
it('should show usage count in confirm dialog when API is in use', async () => {
vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: true, count: 3 })
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const deleteButton = container.querySelectorAll('button')[1]
fireEvent.click(deleteButton)
await waitFor(() => {
expect(screen.getByText(/3/)).toBeInTheDocument()
})
})
it('should delete API and refresh list when confirmed', async () => {
vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 })
vi.mocked(deleteExternalAPI).mockResolvedValue({ result: 'success' })
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const deleteButton = container.querySelectorAll('button')[1]
fireEvent.click(deleteButton)
await waitFor(() => {
expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument()
})
const confirmButton = screen.getByRole('button', { name: /confirm/i })
fireEvent.click(confirmButton)
await waitFor(() => {
expect(deleteExternalAPI).toHaveBeenCalledWith({ apiTemplateId: 'api-123' })
expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled()
})
})
it('should close confirm dialog when cancel is clicked', async () => {
vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 })
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const deleteButton = container.querySelectorAll('button')[1]
fireEvent.click(deleteButton)
await waitFor(() => {
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
})
const cancelButton = screen.getByRole('button', { name: /cancel/i })
fireEvent.click(cancelButton)
await waitFor(() => {
expect(screen.queryByRole('button', { name: /confirm/i })).not.toBeInTheDocument()
})
})
it('should handle delete error gracefully', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 })
vi.mocked(deleteExternalAPI).mockRejectedValue(new Error('Delete failed'))
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const deleteButton = container.querySelectorAll('button')[1]
fireEvent.click(deleteButton)
await waitFor(() => {
expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument()
})
const confirmButton = screen.getByRole('button', { name: /confirm/i })
fireEvent.click(confirmButton)
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith(
'Error deleting external knowledge API:',
expect.any(Error),
)
})
consoleSpy.mockRestore()
})
it('should handle check usage error gracefully', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(checkUsageExternalAPI).mockRejectedValue(new Error('Check failed'))
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const deleteButton = container.querySelectorAll('button')[1]
fireEvent.click(deleteButton)
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith(
'Error checking external API usage:',
expect.any(Error),
)
})
consoleSpy.mockRestore()
})
})
describe('Hover State', () => {
it('should apply hover styles when delete button is hovered', () => {
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const deleteButton = container.querySelectorAll('button')[1]
const cardContainer = container.querySelector('[class*="shadows-shadow"]')
fireEvent.mouseEnter(deleteButton)
expect(cardContainer).toHaveClass('border-state-destructive-border')
expect(cardContainer).toHaveClass('bg-state-destructive-hover')
fireEvent.mouseLeave(deleteButton)
expect(cardContainer).not.toHaveClass('border-state-destructive-border')
})
})
describe('Edge Cases', () => {
it('should handle API with empty endpoint', () => {
const apiWithEmptyEndpoint: ExternalAPIItem = {
...mockApi,
settings: { endpoint: '', api_key: 'key' },
}
render(<ExternalKnowledgeAPICard api={apiWithEmptyEndpoint} />)
expect(screen.getByText('Test External API')).toBeInTheDocument()
})
it('should handle delete response with unsuccessful result', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 })
vi.mocked(deleteExternalAPI).mockResolvedValue({ result: 'error' })
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const deleteButton = container.querySelectorAll('button')[1]
fireEvent.click(deleteButton)
await waitFor(() => {
expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument()
})
const confirmButton = screen.getByRole('button', { name: /confirm/i })
fireEvent.click(confirmButton)
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith('Failed to delete external API')
})
consoleSpy.mockRestore()
})
})
})

View File

@ -1,92 +0,0 @@
import { RiArrowRightUpLine, RiBookOpenLine } from '@remixicon/react'
import Link from 'next/link'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import Switch from '@/app/components/base/switch'
import Indicator from '@/app/components/header/indicator'
import { useSelector as useAppContextSelector } from '@/context/app-context'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url'
import { useDisableDatasetServiceApi, useEnableDatasetServiceApi } from '@/service/knowledge/use-dataset'
import { cn } from '@/utils/classnames'
type CardProps = {
apiEnabled: boolean
}
const Card = ({
apiEnabled,
}: CardProps) => {
const { t } = useTranslation()
const datasetId = useDatasetDetailContextWithSelector(state => state.dataset?.id)
const mutateDatasetRes = useDatasetDetailContextWithSelector(state => state.mutateDatasetRes)
const { mutateAsync: enableDatasetServiceApi } = useEnableDatasetServiceApi()
const { mutateAsync: disableDatasetServiceApi } = useDisableDatasetServiceApi()
const isCurrentWorkspaceManager = useAppContextSelector(state => state.isCurrentWorkspaceManager)
const apiReferenceUrl = useDatasetApiAccessUrl()
const onToggle = useCallback(async (state: boolean) => {
let result: 'success' | 'fail'
if (state)
result = (await enableDatasetServiceApi(datasetId ?? '')).result
else
result = (await disableDatasetServiceApi(datasetId ?? '')).result
if (result === 'success')
mutateDatasetRes?.()
}, [datasetId, enableDatasetServiceApi, mutateDatasetRes, disableDatasetServiceApi])
return (
<div className="w-[208px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg">
<div className="p-1">
<div className="p-2">
<div className="mb-1.5 flex justify-between">
<div className="flex items-center gap-1">
<Indicator
className="shrink-0"
color={apiEnabled ? 'green' : 'yellow'}
/>
<div
className={cn(
'system-xs-semibold-uppercase',
apiEnabled ? 'text-text-success' : 'text-text-warning',
)}
>
{apiEnabled
? t('serviceApi.enabled', { ns: 'dataset' })
: t('serviceApi.disabled', { ns: 'dataset' })}
</div>
</div>
<Switch
defaultValue={apiEnabled}
onChange={onToggle}
disabled={!isCurrentWorkspaceManager}
/>
</div>
<div className="system-xs-regular text-text-tertiary">
{t('appMenus.apiAccessTip', { ns: 'common' })}
</div>
</div>
</div>
<div className="h-px bg-divider-subtle"></div>
<div className="p-1">
<Link
href={apiReferenceUrl}
target="_blank"
rel="noopener noreferrer"
className="flex h-8 items-center space-x-[7px] rounded-lg px-2 text-text-tertiary hover:bg-state-base-hover"
>
<RiBookOpenLine className="size-3.5 shrink-0" />
<div className="system-sm-regular grow truncate">
{t('overview.apiInfo.doc', { ns: 'appOverview' })}
</div>
<RiArrowRightUpLine className="size-3.5 shrink-0" />
</Link>
</div>
</div>
)
}
export default React.memo(Card)

View File

@ -1,65 +0,0 @@
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ApiAggregate } from '@/app/components/base/icons/src/vender/knowledge'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import Indicator from '@/app/components/header/indicator'
import { cn } from '@/utils/classnames'
import Card from './card'
type ApiAccessProps = {
expand: boolean
apiEnabled: boolean
}
const ApiAccess = ({
expand,
apiEnabled,
}: ApiAccessProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const handleToggle = () => {
setOpen(!open)
}
return (
<div className="p-3 pt-2">
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="top-start"
offset={{
mainAxis: 4,
crossAxis: -4,
}}
>
<PortalToFollowElemTrigger
className="w-full"
onClick={handleToggle}
>
<div className={cn(
'relative flex h-8 cursor-pointer items-center gap-2 rounded-lg border border-components-panel-border px-3',
!expand && 'w-8 justify-center',
open ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
)}
>
<ApiAggregate className="size-4 shrink-0 text-text-secondary" />
{expand && <div className="system-sm-medium grow text-text-secondary">{t('appMenus.apiAccess', { ns: 'common' })}</div>}
<Indicator
className={cn('shrink-0', !expand && 'absolute -right-px -top-px')}
color={apiEnabled ? 'green' : 'yellow'}
/>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[10]">
<Card
apiEnabled={apiEnabled}
/>
</PortalToFollowElemContent>
</PortalToFollowElem>
</div>
)
}
export default React.memo(ApiAccess)

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,8 @@
import type { RelatedAppResponse } from '@/models/datasets'
import * as React from 'react'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import ApiAccess from './api-access'
import { useDatasetApiBaseUrl } from '@/service/knowledge/use-dataset'
import ServiceApi from './service-api'
import Statistics from './statistics'
type IExtraInfoProps = {
@ -16,6 +17,7 @@ const ExtraInfo = ({
expand,
}: IExtraInfoProps) => {
const apiEnabled = useDatasetDetailContextWithSelector(state => state.dataset?.enable_api)
const { data: apiBaseInfo } = useDatasetApiBaseUrl()
return (
<>
@ -26,8 +28,9 @@ const ExtraInfo = ({
relatedApps={relatedApps}
/>
)}
<ApiAccess
<ServiceApi
expand={expand}
apiBaseUrl={apiBaseInfo?.api_base_url ?? ''}
apiEnabled={apiEnabled ?? false}
/>
</>

View File

@ -6,22 +6,45 @@ import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import CopyFeedback from '@/app/components/base/copy-feedback'
import { ApiAggregate } from '@/app/components/base/icons/src/vender/knowledge'
import Switch from '@/app/components/base/switch'
import SecretKeyModal from '@/app/components/develop/secret-key/secret-key-modal'
import Indicator from '@/app/components/header/indicator'
import { useSelector as useAppContextSelector } from '@/context/app-context'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url'
import { useDisableDatasetServiceApi, useEnableDatasetServiceApi } from '@/service/knowledge/use-dataset'
import { cn } from '@/utils/classnames'
type CardProps = {
apiEnabled: boolean
apiBaseUrl: string
}
const Card = ({
apiEnabled,
apiBaseUrl,
}: CardProps) => {
const { t } = useTranslation()
const datasetId = useDatasetDetailContextWithSelector(state => state.dataset?.id)
const mutateDatasetRes = useDatasetDetailContextWithSelector(state => state.mutateDatasetRes)
const { mutateAsync: enableDatasetServiceApi } = useEnableDatasetServiceApi()
const { mutateAsync: disableDatasetServiceApi } = useDisableDatasetServiceApi()
const [isSecretKeyModalVisible, setIsSecretKeyModalVisible] = useState(false)
const isCurrentWorkspaceManager = useAppContextSelector(state => state.isCurrentWorkspaceManager)
const apiReferenceUrl = useDatasetApiAccessUrl()
const onToggle = useCallback(async (state: boolean) => {
let result: 'success' | 'fail'
if (state)
result = (await enableDatasetServiceApi(datasetId ?? '')).result
else
result = (await disableDatasetServiceApi(datasetId ?? '')).result
if (result === 'success')
mutateDatasetRes?.()
}, [datasetId, enableDatasetServiceApi, disableDatasetServiceApi])
const handleOpenSecretKeyModal = useCallback(() => {
setIsSecretKeyModalVisible(true)
}, [])
@ -45,16 +68,24 @@ const Card = ({
<div className="flex items-center gap-x-1">
<Indicator
className="shrink-0"
color={
apiBaseUrl ? 'green' : 'yellow'
}
color={apiEnabled ? 'green' : 'yellow'}
/>
<div
className="system-xs-semibold-uppercase text-text-success"
className={cn(
'system-xs-semibold-uppercase',
apiEnabled ? 'text-text-success' : 'text-text-warning',
)}
>
{t('serviceApi.enabled', { ns: 'dataset' })}
{apiEnabled
? t('serviceApi.enabled', { ns: 'dataset' })
: t('serviceApi.disabled', { ns: 'dataset' })}
</div>
</div>
<Switch
defaultValue={apiEnabled}
onChange={onToggle}
disabled={!isCurrentWorkspaceManager}
/>
</div>
<div className="flex flex-col">
<div className="system-xs-regular leading-6 text-text-tertiary">

View File

@ -1,17 +1,22 @@
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ApiAggregate } from '@/app/components/base/icons/src/vender/knowledge'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import Indicator from '@/app/components/header/indicator'
import { cn } from '@/utils/classnames'
import Card from './card'
type ServiceApiProps = {
expand: boolean
apiBaseUrl: string
apiEnabled: boolean
}
const ServiceApi = ({
expand,
apiBaseUrl,
apiEnabled,
}: ServiceApiProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
@ -21,7 +26,7 @@ const ServiceApi = ({
}
return (
<div>
<div className="p-3 pt-2">
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
@ -36,21 +41,22 @@ const ServiceApi = ({
onClick={handleToggle}
>
<div className={cn(
'relative flex h-8 cursor-pointer items-center gap-2 rounded-lg border-[0.5px] border-components-button-secondary-border-hover bg-components-button-secondary-bg px-3',
open ? 'bg-components-button-secondary-bg-hover' : 'hover:bg-components-button-secondary-bg-hover',
'relative flex h-8 cursor-pointer items-center gap-2 rounded-lg border border-components-panel-border px-3',
!expand && 'w-8 justify-center',
open ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
)}
>
<ApiAggregate className="size-4 shrink-0 text-text-secondary" />
{expand && <div className="system-sm-medium grow text-text-secondary">{t('serviceApi.title', { ns: 'dataset' })}</div>}
<Indicator
className={cn('shrink-0')}
color={
apiBaseUrl ? 'green' : 'yellow'
}
className={cn('shrink-0', !expand && 'absolute -right-px -top-px')}
color={apiEnabled ? 'green' : 'yellow'}
/>
<div className="system-sm-medium grow text-text-secondary">{t('serviceApi.title', { ns: 'dataset' })}</div>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[10]">
<Card
apiEnabled={apiEnabled}
apiBaseUrl={apiBaseUrl}
/>
</PortalToFollowElemContent>

File diff suppressed because it is too large Load Diff

View File

@ -1,125 +0,0 @@
import type { DataSet } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import CornerLabels from './corner-labels'
describe('CornerLabels', () => {
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
name: 'Test Dataset',
description: 'Test description',
provider: 'vendor',
permission: DatasetPermission.allTeamMembers,
data_source_type: DataSourceType.FILE,
indexing_technique: IndexingType.QUALIFIED,
embedding_available: true,
app_count: 5,
document_count: 10,
word_count: 1000,
created_at: 1609459200,
updated_at: 1609545600,
tags: [],
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
created_by: 'user-1',
doc_form: ChunkingMode.text,
runtime_mode: 'general',
...overrides,
} as DataSet)
describe('Rendering', () => {
it('should render without crashing when embedding is available', () => {
const dataset = createMockDataset({ embedding_available: true })
const { container } = render(<CornerLabels dataset={dataset} />)
// Should render null when embedding is available and not pipeline
expect(container.firstChild).toBeNull()
})
it('should render unavailable label when embedding is not available', () => {
const dataset = createMockDataset({ embedding_available: false })
render(<CornerLabels dataset={dataset} />)
expect(screen.getByText(/unavailable/i)).toBeInTheDocument()
})
it('should render pipeline label when runtime_mode is rag_pipeline', () => {
const dataset = createMockDataset({
embedding_available: true,
runtime_mode: 'rag_pipeline',
})
render(<CornerLabels dataset={dataset} />)
expect(screen.getByText(/pipeline/i)).toBeInTheDocument()
})
})
describe('Props', () => {
it('should not render when embedding is available and not pipeline', () => {
const dataset = createMockDataset({
embedding_available: true,
runtime_mode: 'general',
})
const { container } = render(<CornerLabels dataset={dataset} />)
expect(container.firstChild).toBeNull()
})
it('should prioritize unavailable label over pipeline label', () => {
const dataset = createMockDataset({
embedding_available: false,
runtime_mode: 'rag_pipeline',
})
render(<CornerLabels dataset={dataset} />)
// Should show unavailable since embedding_available is checked first
expect(screen.getByText(/unavailable/i)).toBeInTheDocument()
expect(screen.queryByText(/pipeline/i)).not.toBeInTheDocument()
})
})
describe('Styles', () => {
it('should have correct positioning for unavailable label', () => {
const dataset = createMockDataset({ embedding_available: false })
const { container } = render(<CornerLabels dataset={dataset} />)
const labelContainer = container.firstChild as HTMLElement
expect(labelContainer).toHaveClass('absolute', 'right-0', 'top-0', 'z-10')
})
it('should have correct positioning for pipeline label', () => {
const dataset = createMockDataset({
embedding_available: true,
runtime_mode: 'rag_pipeline',
})
const { container } = render(<CornerLabels dataset={dataset} />)
const labelContainer = container.firstChild as HTMLElement
expect(labelContainer).toHaveClass('absolute', 'right-0', 'top-0', 'z-10')
})
})
describe('Edge Cases', () => {
it('should handle undefined runtime_mode', () => {
const dataset = createMockDataset({
embedding_available: true,
runtime_mode: undefined,
})
const { container } = render(<CornerLabels dataset={dataset} />)
expect(container.firstChild).toBeNull()
})
it('should handle empty string runtime_mode', () => {
const dataset = createMockDataset({
embedding_available: true,
runtime_mode: '' as DataSet['runtime_mode'],
})
const { container } = render(<CornerLabels dataset={dataset} />)
expect(container.firstChild).toBeNull()
})
it('should handle all false conditions', () => {
const dataset = createMockDataset({
embedding_available: true,
runtime_mode: 'general',
})
const { container } = render(<CornerLabels dataset={dataset} />)
expect(container.firstChild).toBeNull()
})
})
})

View File

@ -1,177 +0,0 @@
import type { DataSet } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import DatasetCardFooter from './dataset-card-footer'
// Mock the useFormatTimeFromNow hook
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: vi.fn((timestamp: number) => {
const date = new Date(timestamp)
return `${date.toLocaleDateString()}`
}),
}),
}))
describe('DatasetCardFooter', () => {
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
name: 'Test Dataset',
description: 'Test description',
provider: 'vendor',
permission: DatasetPermission.allTeamMembers,
data_source_type: DataSourceType.FILE,
indexing_technique: IndexingType.QUALIFIED,
embedding_available: true,
app_count: 5,
document_count: 10,
word_count: 1000,
created_at: 1609459200,
updated_at: 1609545600,
tags: [],
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
created_by: 'user-1',
doc_form: ChunkingMode.text,
total_available_documents: 10,
...overrides,
} as DataSet)
describe('Rendering', () => {
it('should render without crashing', () => {
const dataset = createMockDataset()
render(<DatasetCardFooter dataset={dataset} />)
expect(screen.getByText('10')).toBeInTheDocument()
})
it('should render document count', () => {
const dataset = createMockDataset({ document_count: 25, total_available_documents: 25 })
render(<DatasetCardFooter dataset={dataset} />)
expect(screen.getByText('25')).toBeInTheDocument()
})
it('should render app count for non-external provider', () => {
const dataset = createMockDataset({ app_count: 8, provider: 'vendor' })
render(<DatasetCardFooter dataset={dataset} />)
expect(screen.getByText('8')).toBeInTheDocument()
})
it('should not render app count for external provider', () => {
const dataset = createMockDataset({ app_count: 8, provider: 'external' })
render(<DatasetCardFooter dataset={dataset} />)
// App count should not be rendered
const appCounts = screen.queryAllByText('8')
expect(appCounts.length).toBe(0)
})
it('should render update time', () => {
const dataset = createMockDataset()
render(<DatasetCardFooter dataset={dataset} />)
// Check for "updated" text with i18n key
expect(screen.getByText(/updated/)).toBeInTheDocument()
})
})
describe('Props', () => {
it('should show partial document count when total_available_documents < document_count', () => {
const dataset = createMockDataset({
document_count: 20,
total_available_documents: 15,
})
render(<DatasetCardFooter dataset={dataset} />)
expect(screen.getByText('15 / 20')).toBeInTheDocument()
})
it('should show full document count when all documents are available', () => {
const dataset = createMockDataset({
document_count: 20,
total_available_documents: 20,
})
render(<DatasetCardFooter dataset={dataset} />)
expect(screen.getByText('20')).toBeInTheDocument()
})
it('should handle zero documents', () => {
const dataset = createMockDataset({
document_count: 0,
total_available_documents: 0,
})
render(<DatasetCardFooter dataset={dataset} />)
expect(screen.getByText('0')).toBeInTheDocument()
})
})
describe('Styles', () => {
it('should have correct base styling when embedding is available', () => {
const dataset = createMockDataset({ embedding_available: true })
const { container } = render(<DatasetCardFooter dataset={dataset} />)
const footer = container.firstChild as HTMLElement
expect(footer).toHaveClass('flex', 'items-center', 'gap-x-3', 'px-4')
})
it('should have opacity class when embedding is not available', () => {
const dataset = createMockDataset({ embedding_available: false })
const { container } = render(<DatasetCardFooter dataset={dataset} />)
const footer = container.firstChild as HTMLElement
expect(footer).toHaveClass('opacity-30')
})
it('should not have opacity class when embedding is available', () => {
const dataset = createMockDataset({ embedding_available: true })
const { container } = render(<DatasetCardFooter dataset={dataset} />)
const footer = container.firstChild as HTMLElement
expect(footer).not.toHaveClass('opacity-30')
})
})
describe('Icons', () => {
it('should render document icon', () => {
const dataset = createMockDataset()
const { container } = render(<DatasetCardFooter dataset={dataset} />)
// RiFileTextFill icon
const icons = container.querySelectorAll('svg')
expect(icons.length).toBeGreaterThanOrEqual(1)
})
it('should render robot icon for non-external provider', () => {
const dataset = createMockDataset({ provider: 'vendor' })
const { container } = render(<DatasetCardFooter dataset={dataset} />)
// Should have both file and robot icons
const icons = container.querySelectorAll('svg')
expect(icons.length).toBe(2)
})
})
describe('Edge Cases', () => {
it('should handle undefined total_available_documents', () => {
const dataset = createMockDataset({
document_count: 10,
total_available_documents: undefined,
})
render(<DatasetCardFooter dataset={dataset} />)
// Should show 0 / 10 since total_available_documents defaults to 0
expect(screen.getByText('0 / 10')).toBeInTheDocument()
})
it('should handle very large numbers', () => {
const dataset = createMockDataset({
document_count: 999999,
total_available_documents: 999999,
app_count: 888888,
})
render(<DatasetCardFooter dataset={dataset} />)
expect(screen.getByText('999999')).toBeInTheDocument()
expect(screen.getByText('888888')).toBeInTheDocument()
})
it('should handle zero app count', () => {
const dataset = createMockDataset({ app_count: 0, document_count: 5, total_available_documents: 5 })
render(<DatasetCardFooter dataset={dataset} />)
// Both document count and app count are shown
const zeros = screen.getAllByText('0')
expect(zeros.length).toBeGreaterThanOrEqual(1)
})
})
})

View File

@ -1,254 +0,0 @@
import type { DataSet } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
import DatasetCardHeader from './dataset-card-header'
// Mock useFormatTimeFromNow hook
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: (timestamp: number) => {
const date = new Date(timestamp)
return date.toLocaleDateString()
},
}),
}))
// Mock useKnowledge hook
vi.mock('@/hooks/use-knowledge', () => ({
useKnowledge: () => ({
formatIndexingTechniqueAndMethod: (technique: string, _method: string) => {
if (technique === 'high_quality')
return 'High Quality'
if (technique === 'economy')
return 'Economy'
return ''
},
}),
}))
describe('DatasetCardHeader', () => {
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
name: 'Test Dataset',
description: 'Test description',
indexing_status: 'completed',
provider: 'vendor',
permission: DatasetPermission.allTeamMembers,
data_source_type: DataSourceType.FILE,
indexing_technique: IndexingType.QUALIFIED,
embedding_available: true,
app_count: 5,
document_count: 10,
total_document_count: 10,
word_count: 1000,
updated_at: 1609545600,
updated_by: 'user-1',
tags: [],
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
created_by: 'user-1',
doc_form: ChunkingMode.text,
runtime_mode: 'general',
is_published: true,
enable_api: true,
is_multimodal: false,
built_in_field_enabled: false,
icon_info: {
icon: '📙',
icon_type: 'emoji' as const,
icon_background: '#FFF4ED',
icon_url: '',
},
retrieval_model_dict: {
search_method: RETRIEVE_METHOD.semantic,
} as DataSet['retrieval_model_dict'],
retrieval_model: {
search_method: RETRIEVE_METHOD.semantic,
} as DataSet['retrieval_model'],
external_knowledge_info: {
external_knowledge_id: '',
external_knowledge_api_id: '',
external_knowledge_api_name: '',
external_knowledge_api_endpoint: '',
},
external_retrieval_model: {
top_k: 3,
score_threshold: 0.5,
score_threshold_enabled: false,
},
author_name: 'Test User',
...overrides,
})
describe('Rendering', () => {
it('should render without crashing', () => {
const dataset = createMockDataset()
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
it('should render dataset name', () => {
const dataset = createMockDataset({ name: 'Custom Dataset' })
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText('Custom Dataset')).toBeInTheDocument()
})
it('should render author name', () => {
const dataset = createMockDataset({ author_name: 'John Doe' })
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText('John Doe')).toBeInTheDocument()
})
it('should render edit time', () => {
const dataset = createMockDataset()
render(<DatasetCardHeader dataset={dataset} />)
// Should contain the formatted time
expect(screen.getByText(/segment\.editedAt/)).toBeInTheDocument()
})
})
describe('Props', () => {
it('should show external knowledge base text for external provider', () => {
const dataset = createMockDataset({ provider: 'external' })
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText(/externalKnowledgeBase/)).toBeInTheDocument()
})
it('should show chunking mode for text_model doc_form', () => {
const dataset = createMockDataset({ doc_form: ChunkingMode.text })
render(<DatasetCardHeader dataset={dataset} />)
// text_model maps to 'general' in DOC_FORM_TEXT
expect(screen.getByText(/chunkingMode\.general/)).toBeInTheDocument()
})
it('should show multimodal text when is_multimodal is true', () => {
const dataset = createMockDataset({ is_multimodal: true })
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText(/multimodal/)).toBeInTheDocument()
})
it('should not show multimodal when is_multimodal is false', () => {
const dataset = createMockDataset({ is_multimodal: false })
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.queryByText(/^multimodal$/)).not.toBeInTheDocument()
})
})
describe('Icon', () => {
it('should render AppIcon component', () => {
const dataset = createMockDataset()
const { container } = render(<DatasetCardHeader dataset={dataset} />)
// AppIcon should be rendered
const iconContainer = container.querySelector('.relative.shrink-0')
expect(iconContainer).toBeInTheDocument()
})
it('should use default icon when icon_info is missing', () => {
const dataset = createMockDataset({ icon_info: undefined })
render(<DatasetCardHeader dataset={dataset} />)
// Should still render without crashing
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
it('should render chunking mode icon for published pipeline', () => {
const dataset = createMockDataset({
doc_form: ChunkingMode.text,
runtime_mode: 'rag_pipeline',
is_published: true,
})
const { container } = render(<DatasetCardHeader dataset={dataset} />)
// Should have the icon badge
const iconBadge = container.querySelector('.absolute.-bottom-1.-right-1')
expect(iconBadge).toBeInTheDocument()
})
})
describe('Styles', () => {
it('should have opacity class when embedding is not available', () => {
const dataset = createMockDataset({ embedding_available: false })
const { container } = render(<DatasetCardHeader dataset={dataset} />)
const header = container.firstChild as HTMLElement
expect(header).toHaveClass('opacity-30')
})
it('should not have opacity class when embedding is available', () => {
const dataset = createMockDataset({ embedding_available: true })
const { container } = render(<DatasetCardHeader dataset={dataset} />)
const header = container.firstChild as HTMLElement
expect(header).not.toHaveClass('opacity-30')
})
it('should have correct base styling', () => {
const dataset = createMockDataset()
const { container } = render(<DatasetCardHeader dataset={dataset} />)
const header = container.firstChild as HTMLElement
expect(header).toHaveClass('flex', 'items-center', 'gap-x-3', 'px-4')
})
})
describe('DocModeInfo', () => {
it('should show doc mode info when all conditions are met', () => {
const dataset = createMockDataset({
doc_form: ChunkingMode.text,
indexing_technique: IndexingType.QUALIFIED,
retrieval_model_dict: { search_method: RETRIEVE_METHOD.semantic } as DataSet['retrieval_model_dict'],
runtime_mode: 'general',
})
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText(/chunkingMode/)).toBeInTheDocument()
})
it('should not show doc mode info for unpublished pipeline', () => {
const dataset = createMockDataset({
runtime_mode: 'rag_pipeline',
is_published: false,
})
render(<DatasetCardHeader dataset={dataset} />)
// DocModeInfo should not be rendered since isShowDocModeInfo is false
expect(screen.queryByText(/High Quality/)).not.toBeInTheDocument()
})
it('should show doc mode info for published pipeline', () => {
const dataset = createMockDataset({
doc_form: ChunkingMode.text,
indexing_technique: IndexingType.QUALIFIED,
retrieval_model_dict: { search_method: RETRIEVE_METHOD.semantic } as DataSet['retrieval_model_dict'],
runtime_mode: 'rag_pipeline',
is_published: true,
})
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText(/chunkingMode/)).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle missing author_name', () => {
const dataset = createMockDataset({ author_name: undefined })
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
it('should handle empty name', () => {
const dataset = createMockDataset({ name: '' })
render(<DatasetCardHeader dataset={dataset} />)
// Should render without crashing
const { container } = render(<DatasetCardHeader dataset={dataset} />)
expect(container).toBeInTheDocument()
})
it('should handle missing retrieval_model_dict', () => {
const dataset = createMockDataset({ retrieval_model_dict: undefined })
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
it('should handle undefined doc_form', () => {
const dataset = createMockDataset({ doc_form: undefined })
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
})
})

View File

@ -49,7 +49,7 @@ const DocModeInfo = ({
return (
<div className="system-2xs-medium-uppercase flex items-center gap-x-3 text-text-tertiary">
{!!dataset.doc_form && (
{dataset.doc_form && (
<span
className="min-w-0 max-w-full truncate"
title={t(`chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`, { ns: 'dataset' })}

View File

@ -1,237 +0,0 @@
import type { DataSet } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import DatasetCardModals from './dataset-card-modals'
// Mock RenameDatasetModal since it's from a different feature folder
vi.mock('../../../rename-modal', () => ({
default: ({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess?: () => void }) => (
show
? (
<div data-testid="rename-modal">
<button onClick={onClose}>Close Rename</button>
<button onClick={onSuccess}>Success</button>
</div>
)
: null
),
}))
describe('DatasetCardModals', () => {
const mockDataset: DataSet = {
id: 'dataset-1',
name: 'Test Dataset',
description: 'Test description',
indexing_status: 'completed',
provider: 'vendor',
permission: DatasetPermission.allTeamMembers,
data_source_type: DataSourceType.FILE,
indexing_technique: IndexingType.QUALIFIED,
embedding_available: true,
app_count: 5,
document_count: 10,
total_document_count: 10,
word_count: 1000,
updated_at: 1609545600,
updated_by: 'user-1',
tags: [],
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
created_by: 'user-1',
doc_form: ChunkingMode.text,
runtime_mode: 'general',
enable_api: true,
is_multimodal: false,
built_in_field_enabled: false,
icon_info: {
icon: '📙',
icon_type: 'emoji' as const,
icon_background: '#FFF4ED',
icon_url: '',
},
retrieval_model_dict: {} as DataSet['retrieval_model_dict'],
retrieval_model: {} as DataSet['retrieval_model'],
external_knowledge_info: {
external_knowledge_id: '',
external_knowledge_api_id: '',
external_knowledge_api_name: '',
external_knowledge_api_endpoint: '',
},
external_retrieval_model: {
top_k: 3,
score_threshold: 0.5,
score_threshold_enabled: false,
},
}
const defaultProps = {
dataset: mockDataset,
modalState: {
showRenameModal: false,
showConfirmDelete: false,
confirmMessage: '',
},
onCloseRename: vi.fn(),
onCloseConfirm: vi.fn(),
onConfirmDelete: vi.fn(),
onSuccess: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing when no modals are shown', () => {
const { container } = render(<DatasetCardModals {...defaultProps} />)
// Should render empty fragment
expect(container.innerHTML).toBe('')
})
it('should render rename modal when showRenameModal is true', () => {
render(
<DatasetCardModals
{...defaultProps}
modalState={{ ...defaultProps.modalState, showRenameModal: true }}
/>,
)
expect(screen.getByTestId('rename-modal')).toBeInTheDocument()
})
it('should render confirm modal when showConfirmDelete is true', () => {
render(
<DatasetCardModals
{...defaultProps}
modalState={{
...defaultProps.modalState,
showConfirmDelete: true,
confirmMessage: 'Are you sure?',
}}
/>,
)
// Confirm modal should be rendered
expect(screen.getByText('Are you sure?')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should pass dataset to rename modal', () => {
render(
<DatasetCardModals
{...defaultProps}
modalState={{ ...defaultProps.modalState, showRenameModal: true }}
/>,
)
expect(screen.getByTestId('rename-modal')).toBeInTheDocument()
})
it('should display confirmMessage in confirm modal', () => {
const confirmMessage = 'This is a custom confirm message'
render(
<DatasetCardModals
{...defaultProps}
modalState={{
...defaultProps.modalState,
showConfirmDelete: true,
confirmMessage,
}}
/>,
)
expect(screen.getByText(confirmMessage)).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onCloseRename when closing rename modal', () => {
const onCloseRename = vi.fn()
render(
<DatasetCardModals
{...defaultProps}
onCloseRename={onCloseRename}
modalState={{ ...defaultProps.modalState, showRenameModal: true }}
/>,
)
fireEvent.click(screen.getByText('Close Rename'))
expect(onCloseRename).toHaveBeenCalledTimes(1)
})
it('should call onConfirmDelete when confirming deletion', () => {
const onConfirmDelete = vi.fn()
render(
<DatasetCardModals
{...defaultProps}
onConfirmDelete={onConfirmDelete}
modalState={{
...defaultProps.modalState,
showConfirmDelete: true,
confirmMessage: 'Delete?',
}}
/>,
)
// Find and click the confirm button
const confirmButton = screen.getByRole('button', { name: /confirm|ok|delete/i })
|| screen.getAllByRole('button').find(btn => btn.textContent?.toLowerCase().includes('confirm'))
if (confirmButton)
fireEvent.click(confirmButton)
expect(onConfirmDelete).toHaveBeenCalledTimes(1)
})
it('should call onCloseConfirm when canceling deletion', () => {
const onCloseConfirm = vi.fn()
render(
<DatasetCardModals
{...defaultProps}
onCloseConfirm={onCloseConfirm}
modalState={{
...defaultProps.modalState,
showConfirmDelete: true,
confirmMessage: 'Delete?',
}}
/>,
)
// Find and click the cancel button
const cancelButton = screen.getByRole('button', { name: /cancel/i })
fireEvent.click(cancelButton)
expect(onCloseConfirm).toHaveBeenCalledTimes(1)
})
})
describe('Edge Cases', () => {
it('should handle both modals being true (render both)', () => {
render(
<DatasetCardModals
{...defaultProps}
modalState={{
showRenameModal: true,
showConfirmDelete: true,
confirmMessage: 'Delete this dataset?',
}}
/>,
)
expect(screen.getByTestId('rename-modal')).toBeInTheDocument()
expect(screen.getByText('Delete this dataset?')).toBeInTheDocument()
})
it('should handle empty confirmMessage', () => {
render(
<DatasetCardModals
{...defaultProps}
modalState={{
...defaultProps.modalState,
showConfirmDelete: true,
confirmMessage: '',
}}
/>,
)
// Should still render confirm modal
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
})
})
})

View File

@ -1,107 +0,0 @@
import type { DataSet } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import Description from './description'
describe('Description', () => {
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
name: 'Test Dataset',
description: 'This is a test description',
provider: 'vendor',
permission: DatasetPermission.allTeamMembers,
data_source_type: DataSourceType.FILE,
indexing_technique: IndexingType.QUALIFIED,
embedding_available: true,
app_count: 5,
document_count: 10,
word_count: 1000,
created_at: 1609459200,
updated_at: 1609545600,
tags: [],
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
created_by: 'user-1',
doc_form: ChunkingMode.text,
...overrides,
} as DataSet)
describe('Rendering', () => {
it('should render without crashing', () => {
const dataset = createMockDataset()
render(<Description dataset={dataset} />)
expect(screen.getByText('This is a test description')).toBeInTheDocument()
})
it('should render the description text', () => {
const dataset = createMockDataset({ description: 'Custom description text' })
render(<Description dataset={dataset} />)
expect(screen.getByText('Custom description text')).toBeInTheDocument()
})
it('should set title attribute for tooltip', () => {
const dataset = createMockDataset({ description: 'Tooltip description' })
render(<Description dataset={dataset} />)
const descDiv = screen.getByTitle('Tooltip description')
expect(descDiv).toBeInTheDocument()
})
})
describe('Props', () => {
it('should display dataset description', () => {
const description = 'A very detailed description of this dataset'
const dataset = createMockDataset({ description })
render(<Description dataset={dataset} />)
expect(screen.getByText(description)).toBeInTheDocument()
})
})
describe('Styles', () => {
it('should have correct base styling when embedding is available', () => {
const dataset = createMockDataset({ embedding_available: true })
render(<Description dataset={dataset} />)
const descDiv = screen.getByTitle(dataset.description)
expect(descDiv).toHaveClass('system-xs-regular', 'line-clamp-2', 'h-10', 'px-4', 'py-1', 'text-text-tertiary')
})
it('should have opacity class when embedding is not available', () => {
const dataset = createMockDataset({ embedding_available: false })
render(<Description dataset={dataset} />)
const descDiv = screen.getByTitle(dataset.description)
expect(descDiv).toHaveClass('opacity-30')
})
it('should not have opacity class when embedding is available', () => {
const dataset = createMockDataset({ embedding_available: true })
render(<Description dataset={dataset} />)
const descDiv = screen.getByTitle(dataset.description)
expect(descDiv).not.toHaveClass('opacity-30')
})
})
describe('Edge Cases', () => {
it('should handle empty description', () => {
const dataset = createMockDataset({ description: '' })
render(<Description dataset={dataset} />)
const descDiv = screen.getByTitle('')
expect(descDiv).toBeInTheDocument()
expect(descDiv).toHaveTextContent('')
})
it('should handle very long description', () => {
const longDescription = 'A'.repeat(500)
const dataset = createMockDataset({ description: longDescription })
render(<Description dataset={dataset} />)
expect(screen.getByText(longDescription)).toBeInTheDocument()
})
it('should handle description with special characters', () => {
const description = '<script>alert("XSS")</script> & "quotes" \'single\''
const dataset = createMockDataset({ description })
render(<Description dataset={dataset} />)
expect(screen.getByText(description)).toBeInTheDocument()
})
})
})

View File

@ -1,162 +0,0 @@
import type { DataSet } from '@/models/datasets'
import { fireEvent, render } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import OperationsPopover from './operations-popover'
describe('OperationsPopover', () => {
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
name: 'Test Dataset',
description: 'Test description',
provider: 'vendor',
permission: DatasetPermission.allTeamMembers,
data_source_type: DataSourceType.FILE,
indexing_technique: IndexingType.QUALIFIED,
embedding_available: true,
app_count: 5,
document_count: 10,
word_count: 1000,
updated_at: 1609545600,
tags: [],
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
created_by: 'user-1',
doc_form: ChunkingMode.text,
runtime_mode: 'general',
...overrides,
} as DataSet)
const defaultProps = {
dataset: createMockDataset(),
isCurrentWorkspaceDatasetOperator: false,
openRenameModal: vi.fn(),
handleExportPipeline: vi.fn(),
detectIsUsedByApp: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(<OperationsPopover {...defaultProps} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render the more icon button', () => {
const { container } = render(<OperationsPopover {...defaultProps} />)
const moreIcon = container.querySelector('svg')
expect(moreIcon).toBeInTheDocument()
})
it('should render in hidden state initially (group-hover)', () => {
const { container } = render(<OperationsPopover {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('hidden', 'group-hover:block')
})
})
describe('Props', () => {
it('should show delete option when not workspace dataset operator', () => {
render(<OperationsPopover {...defaultProps} isCurrentWorkspaceDatasetOperator={false} />)
// Click to open popover
const triggerButton = document.querySelector('[class*="cursor-pointer"]')
if (triggerButton)
fireEvent.click(triggerButton)
// showDelete should be true (inverse of isCurrentWorkspaceDatasetOperator)
// This means delete operation will be visible
})
it('should hide delete option when is workspace dataset operator', () => {
render(<OperationsPopover {...defaultProps} isCurrentWorkspaceDatasetOperator={true} />)
// Click to open popover
const triggerButton = document.querySelector('[class*="cursor-pointer"]')
if (triggerButton)
fireEvent.click(triggerButton)
// showDelete should be false
})
it('should show export pipeline when runtime_mode is rag_pipeline', () => {
const dataset = createMockDataset({ runtime_mode: 'rag_pipeline' })
render(<OperationsPopover {...defaultProps} dataset={dataset} />)
// Click to open popover
const triggerButton = document.querySelector('[class*="cursor-pointer"]')
if (triggerButton)
fireEvent.click(triggerButton)
// showExportPipeline should be true
})
it('should hide export pipeline when runtime_mode is not rag_pipeline', () => {
const dataset = createMockDataset({ runtime_mode: 'general' })
render(<OperationsPopover {...defaultProps} dataset={dataset} />)
// Click to open popover
const triggerButton = document.querySelector('[class*="cursor-pointer"]')
if (triggerButton)
fireEvent.click(triggerButton)
// showExportPipeline should be false
})
})
describe('Styles', () => {
it('should have correct positioning styles', () => {
const { container } = render(<OperationsPopover {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('absolute', 'right-2', 'top-2', 'z-[15]')
})
it('should have icon with correct size classes', () => {
const { container } = render(<OperationsPopover {...defaultProps} />)
const icon = container.querySelector('svg')
expect(icon).toHaveClass('h-5', 'w-5', 'text-text-tertiary')
})
})
describe('User Interactions', () => {
it('should pass openRenameModal to Operations', () => {
const openRenameModal = vi.fn()
render(<OperationsPopover {...defaultProps} openRenameModal={openRenameModal} />)
// The openRenameModal should be passed to Operations component
expect(openRenameModal).not.toHaveBeenCalled() // Initially not called
})
it('should pass handleExportPipeline to Operations', () => {
const handleExportPipeline = vi.fn()
render(<OperationsPopover {...defaultProps} handleExportPipeline={handleExportPipeline} />)
expect(handleExportPipeline).not.toHaveBeenCalled()
})
it('should pass detectIsUsedByApp to Operations', () => {
const detectIsUsedByApp = vi.fn()
render(<OperationsPopover {...defaultProps} detectIsUsedByApp={detectIsUsedByApp} />)
expect(detectIsUsedByApp).not.toHaveBeenCalled()
})
})
describe('Edge Cases', () => {
it('should handle dataset with external provider', () => {
const dataset = createMockDataset({ provider: 'external' })
const { container } = render(<OperationsPopover {...defaultProps} dataset={dataset} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should handle dataset with undefined runtime_mode', () => {
const dataset = createMockDataset({ runtime_mode: undefined })
const { container } = render(<OperationsPopover {...defaultProps} dataset={dataset} />)
expect(container.firstChild).toBeInTheDocument()
})
})
})

View File

@ -1,198 +0,0 @@
import type { Tag } from '@/app/components/base/tag-management/constant'
import type { DataSet } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { useRef } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import TagArea from './tag-area'
// Mock TagSelector as it's a complex component from base
vi.mock('@/app/components/base/tag-management/selector', () => ({
default: ({ value, selectedTags, onCacheUpdate, onChange }: {
value: string[]
selectedTags: Tag[]
onCacheUpdate: (tags: Tag[]) => void
onChange?: () => void
}) => (
<div data-testid="tag-selector">
<div data-testid="tag-values">{value.join(',')}</div>
<div data-testid="selected-count">
{selectedTags.length}
{' '}
tags
</div>
<button onClick={() => onCacheUpdate([{ id: 'new-tag', name: 'New Tag', type: 'knowledge', binding_count: 0 }])}>
Update Tags
</button>
<button onClick={onChange}>
Trigger Change
</button>
</div>
),
}))
describe('TagArea', () => {
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
name: 'Test Dataset',
description: 'Test description',
provider: 'vendor',
permission: DatasetPermission.allTeamMembers,
data_source_type: DataSourceType.FILE,
indexing_technique: IndexingType.QUALIFIED,
embedding_available: true,
app_count: 5,
document_count: 10,
word_count: 1000,
updated_at: 1609545600,
tags: [],
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
created_by: 'user-1',
doc_form: ChunkingMode.text,
...overrides,
} as DataSet)
const mockTags: Tag[] = [
{ id: 'tag-1', name: 'Tag 1', type: 'knowledge', binding_count: 0 },
{ id: 'tag-2', name: 'Tag 2', type: 'knowledge', binding_count: 0 },
]
const defaultProps = {
dataset: createMockDataset(),
tags: mockTags,
setTags: vi.fn(),
onSuccess: vi.fn(),
isHoveringTagSelector: false,
onClick: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<TagArea {...defaultProps} />)
expect(screen.getByTestId('tag-selector')).toBeInTheDocument()
})
it('should render TagSelector with correct value', () => {
render(<TagArea {...defaultProps} />)
expect(screen.getByTestId('tag-values')).toHaveTextContent('tag-1,tag-2')
})
it('should display selected tags count', () => {
render(<TagArea {...defaultProps} />)
expect(screen.getByTestId('selected-count')).toHaveTextContent('2 tags')
})
})
describe('Props', () => {
it('should pass dataset id to TagSelector', () => {
const dataset = createMockDataset({ id: 'custom-dataset-id' })
render(<TagArea {...defaultProps} dataset={dataset} />)
expect(screen.getByTestId('tag-selector')).toBeInTheDocument()
})
it('should render with empty tags', () => {
render(<TagArea {...defaultProps} tags={[]} />)
expect(screen.getByTestId('selected-count')).toHaveTextContent('0 tags')
})
it('should forward ref correctly', () => {
const TestComponent = () => {
const ref = useRef<HTMLDivElement>(null)
return <TagArea {...defaultProps} ref={ref} />
}
render(<TestComponent />)
expect(screen.getByTestId('tag-selector')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onClick when container is clicked', () => {
const onClick = vi.fn()
const { container } = render(<TagArea {...defaultProps} onClick={onClick} />)
const wrapper = container.firstChild as HTMLElement
fireEvent.click(wrapper)
expect(onClick).toHaveBeenCalledTimes(1)
})
it('should call setTags when tags are updated', () => {
const setTags = vi.fn()
render(<TagArea {...defaultProps} setTags={setTags} />)
fireEvent.click(screen.getByText('Update Tags'))
expect(setTags).toHaveBeenCalledWith([{ id: 'new-tag', name: 'New Tag', type: 'knowledge', binding_count: 0 }])
})
it('should call onSuccess when onChange is triggered', () => {
const onSuccess = vi.fn()
render(<TagArea {...defaultProps} onSuccess={onSuccess} />)
fireEvent.click(screen.getByText('Trigger Change'))
expect(onSuccess).toHaveBeenCalledTimes(1)
})
})
describe('Styles', () => {
it('should have opacity class when embedding is not available', () => {
const dataset = createMockDataset({ embedding_available: false })
const { container } = render(<TagArea {...defaultProps} dataset={dataset} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('opacity-30')
})
it('should not have opacity class when embedding is available', () => {
const dataset = createMockDataset({ embedding_available: true })
const { container } = render(<TagArea {...defaultProps} dataset={dataset} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).not.toHaveClass('opacity-30')
})
it('should show mask when not hovering and has tags', () => {
const { container } = render(<TagArea {...defaultProps} isHoveringTagSelector={false} tags={mockTags} />)
const maskDiv = container.querySelector('.bg-tag-selector-mask-bg')
expect(maskDiv).toBeInTheDocument()
expect(maskDiv).not.toHaveClass('hidden')
})
it('should hide mask when hovering', () => {
const { container } = render(<TagArea {...defaultProps} isHoveringTagSelector={true} />)
// When hovering, the mask div should have 'hidden' class
const maskDiv = container.querySelector('.absolute.right-0.top-0')
expect(maskDiv).toHaveClass('hidden')
})
it('should make TagSelector visible when tags exist', () => {
const { container } = render(<TagArea {...defaultProps} tags={mockTags} />)
const tagSelectorWrapper = container.querySelector('.visible')
expect(tagSelectorWrapper).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle undefined onSuccess', () => {
render(<TagArea {...defaultProps} onSuccess={undefined} />)
// Should not throw when clicking Trigger Change
expect(() => fireEvent.click(screen.getByText('Trigger Change'))).not.toThrow()
})
it('should handle many tags', () => {
const manyTags: Tag[] = Array.from({ length: 20 }, (_, i) => ({
id: `tag-${i}`,
name: `Tag ${i}`,
type: 'knowledge' as const,
binding_count: 0,
}))
render(<TagArea {...defaultProps} tags={manyTags} />)
expect(screen.getByTestId('selected-count')).toHaveTextContent('20 tags')
})
})
})

View File

@ -1,427 +0,0 @@
import type { DataSet } from '@/models/datasets'
import { act, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import { useDatasetCardState } from './use-dataset-card-state'
// Mock Toast
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
}))
// Mock service hooks
const mockCheckUsage = vi.fn()
const mockDeleteDataset = vi.fn()
const mockExportPipeline = vi.fn()
vi.mock('@/service/use-dataset-card', () => ({
useCheckDatasetUsage: () => ({ mutateAsync: mockCheckUsage }),
useDeleteDataset: () => ({ mutateAsync: mockDeleteDataset }),
}))
vi.mock('@/service/use-pipeline', () => ({
useExportPipelineDSL: () => ({ mutateAsync: mockExportPipeline }),
}))
describe('useDatasetCardState', () => {
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
name: 'Test Dataset',
description: 'Test description',
provider: 'vendor',
permission: DatasetPermission.allTeamMembers,
data_source_type: DataSourceType.FILE,
indexing_technique: IndexingType.QUALIFIED,
embedding_available: true,
app_count: 5,
document_count: 10,
word_count: 1000,
created_at: 1609459200,
updated_at: 1609545600,
tags: [{ id: 'tag-1', name: 'Tag 1', type: 'knowledge', binding_count: 0 }],
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
created_by: 'user-1',
doc_form: ChunkingMode.text,
pipeline_id: 'pipeline-1',
...overrides,
} as DataSet)
beforeEach(() => {
vi.clearAllMocks()
mockCheckUsage.mockResolvedValue({ is_using: false })
mockDeleteDataset.mockResolvedValue({})
mockExportPipeline.mockResolvedValue({ data: 'yaml content' })
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('Initial State', () => {
it('should return tags from dataset', () => {
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
expect(result.current.tags).toEqual(dataset.tags)
})
it('should have initial modal state closed', () => {
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
expect(result.current.modalState.showRenameModal).toBe(false)
expect(result.current.modalState.showConfirmDelete).toBe(false)
expect(result.current.modalState.confirmMessage).toBe('')
})
it('should not be exporting initially', () => {
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
expect(result.current.exporting).toBe(false)
})
})
describe('Tags State', () => {
it('should update tags when setTags is called', () => {
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
act(() => {
result.current.setTags([{ id: 'tag-2', name: 'Tag 2', type: 'knowledge', binding_count: 0 }])
})
expect(result.current.tags).toEqual([{ id: 'tag-2', name: 'Tag 2', type: 'knowledge', binding_count: 0 }])
})
it('should sync tags when dataset tags change', () => {
const dataset = createMockDataset()
const { result, rerender } = renderHook(
({ dataset }) => useDatasetCardState({ dataset, onSuccess: vi.fn() }),
{ initialProps: { dataset } },
)
const newTags = [{ id: 'tag-3', name: 'Tag 3', type: 'knowledge', binding_count: 0 }]
const updatedDataset = createMockDataset({ tags: newTags })
rerender({ dataset: updatedDataset })
expect(result.current.tags).toEqual(newTags)
})
})
describe('Modal Handlers', () => {
it('should open rename modal when openRenameModal is called', () => {
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
act(() => {
result.current.openRenameModal()
})
expect(result.current.modalState.showRenameModal).toBe(true)
})
it('should close rename modal when closeRenameModal is called', () => {
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
act(() => {
result.current.openRenameModal()
})
act(() => {
result.current.closeRenameModal()
})
expect(result.current.modalState.showRenameModal).toBe(false)
})
it('should close confirm delete modal when closeConfirmDelete is called', () => {
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
// First trigger show confirm delete
act(() => {
result.current.detectIsUsedByApp()
})
waitFor(() => {
expect(result.current.modalState.showConfirmDelete).toBe(true)
})
act(() => {
result.current.closeConfirmDelete()
})
expect(result.current.modalState.showConfirmDelete).toBe(false)
})
})
describe('detectIsUsedByApp', () => {
it('should check usage and show confirm modal with not-in-use message', async () => {
mockCheckUsage.mockResolvedValue({ is_using: false })
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
await act(async () => {
await result.current.detectIsUsedByApp()
})
expect(mockCheckUsage).toHaveBeenCalledWith('dataset-1')
expect(result.current.modalState.showConfirmDelete).toBe(true)
expect(result.current.modalState.confirmMessage).toContain('deleteDatasetConfirmContent')
})
it('should show in-use message when dataset is used by app', async () => {
mockCheckUsage.mockResolvedValue({ is_using: true })
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
await act(async () => {
await result.current.detectIsUsedByApp()
})
expect(result.current.modalState.confirmMessage).toContain('datasetUsedByApp')
})
})
describe('onConfirmDelete', () => {
it('should delete dataset and call onSuccess', async () => {
const onSuccess = vi.fn()
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess }),
)
await act(async () => {
await result.current.onConfirmDelete()
})
expect(mockDeleteDataset).toHaveBeenCalledWith('dataset-1')
expect(onSuccess).toHaveBeenCalled()
})
it('should close confirm modal after delete', async () => {
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
// First open confirm modal
await act(async () => {
await result.current.detectIsUsedByApp()
})
await act(async () => {
await result.current.onConfirmDelete()
})
expect(result.current.modalState.showConfirmDelete).toBe(false)
})
})
describe('handleExportPipeline', () => {
it('should not export if pipeline_id is missing', async () => {
const dataset = createMockDataset({ pipeline_id: undefined })
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
await act(async () => {
await result.current.handleExportPipeline()
})
expect(mockExportPipeline).not.toHaveBeenCalled()
})
it('should export pipeline with correct parameters', async () => {
const dataset = createMockDataset({ pipeline_id: 'pipeline-1', name: 'Test Pipeline' })
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
await act(async () => {
await result.current.handleExportPipeline(true)
})
expect(mockExportPipeline).toHaveBeenCalledWith({
pipelineId: 'pipeline-1',
include: true,
})
})
})
describe('Edge Cases', () => {
it('should handle empty tags array', () => {
const dataset = createMockDataset({ tags: [] })
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
expect(result.current.tags).toEqual([])
})
it('should handle undefined onSuccess', async () => {
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset }),
)
// Should not throw when onSuccess is undefined
await act(async () => {
await result.current.onConfirmDelete()
})
expect(mockDeleteDataset).toHaveBeenCalled()
})
})
describe('Error Handling', () => {
it('should show error toast when export pipeline fails', async () => {
const Toast = await import('@/app/components/base/toast')
mockExportPipeline.mockRejectedValue(new Error('Export failed'))
const dataset = createMockDataset({ pipeline_id: 'pipeline-1' })
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
await act(async () => {
await result.current.handleExportPipeline()
})
expect(Toast.default.notify).toHaveBeenCalledWith({
type: 'error',
message: expect.any(String),
})
})
it('should handle Response error in detectIsUsedByApp', async () => {
const Toast = await import('@/app/components/base/toast')
const mockResponse = new Response(JSON.stringify({ message: 'API Error' }), {
status: 400,
})
mockCheckUsage.mockRejectedValue(mockResponse)
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
await act(async () => {
await result.current.detectIsUsedByApp()
})
expect(Toast.default.notify).toHaveBeenCalledWith({
type: 'error',
message: expect.stringContaining('API Error'),
})
})
it('should handle generic Error in detectIsUsedByApp', async () => {
const Toast = await import('@/app/components/base/toast')
mockCheckUsage.mockRejectedValue(new Error('Network error'))
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
await act(async () => {
await result.current.detectIsUsedByApp()
})
expect(Toast.default.notify).toHaveBeenCalledWith({
type: 'error',
message: 'Network error',
})
})
it('should handle error without message in detectIsUsedByApp', async () => {
const Toast = await import('@/app/components/base/toast')
mockCheckUsage.mockRejectedValue({})
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
await act(async () => {
await result.current.detectIsUsedByApp()
})
expect(Toast.default.notify).toHaveBeenCalledWith({
type: 'error',
message: 'Unknown error',
})
})
it('should handle exporting state correctly', async () => {
const dataset = createMockDataset({ pipeline_id: 'pipeline-1' })
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
// Exporting should initially be false
expect(result.current.exporting).toBe(false)
// Export should work when not exporting
await act(async () => {
await result.current.handleExportPipeline()
})
expect(mockExportPipeline).toHaveBeenCalled()
})
it('should reset exporting state after export completes', async () => {
const dataset = createMockDataset({ pipeline_id: 'pipeline-1' })
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
await act(async () => {
await result.current.handleExportPipeline()
})
expect(result.current.exporting).toBe(false)
})
it('should reset exporting state even when export fails', async () => {
mockExportPipeline.mockRejectedValue(new Error('Export failed'))
const dataset = createMockDataset({ pipeline_id: 'pipeline-1' })
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
await act(async () => {
await result.current.handleExportPipeline()
})
expect(result.current.exporting).toBe(false)
})
})
})

View File

@ -1,256 +0,0 @@
import type { DataSet } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
import DatasetCard from './index'
// Mock next/navigation
const mockPush = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
}))
// Mock ahooks useHover
vi.mock('ahooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('ahooks')>()
return {
...actual,
useHover: () => false,
}
})
// Mock app context
vi.mock('@/context/app-context', () => ({
useSelector: () => false,
}))
// Mock the useDatasetCardState hook
vi.mock('./hooks/use-dataset-card-state', () => ({
useDatasetCardState: () => ({
tags: [],
setTags: vi.fn(),
modalState: {
showRenameModal: false,
showConfirmDelete: false,
confirmMessage: '',
},
openRenameModal: vi.fn(),
closeRenameModal: vi.fn(),
closeConfirmDelete: vi.fn(),
handleExportPipeline: vi.fn(),
detectIsUsedByApp: vi.fn(),
onConfirmDelete: vi.fn(),
}),
}))
// Mock the RenameDatasetModal
vi.mock('../../rename-modal', () => ({
default: () => null,
}))
// Mock useFormatTimeFromNow hook
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: (timestamp: number) => {
const date = new Date(timestamp)
return date.toLocaleDateString()
},
}),
}))
// Mock useKnowledge hook
vi.mock('@/hooks/use-knowledge', () => ({
useKnowledge: () => ({
formatIndexingTechniqueAndMethod: () => 'High Quality',
}),
}))
describe('DatasetCard', () => {
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
name: 'Test Dataset',
description: 'Test description',
provider: 'vendor',
permission: DatasetPermission.allTeamMembers,
data_source_type: DataSourceType.FILE,
indexing_technique: IndexingType.QUALIFIED,
embedding_available: true,
app_count: 5,
document_count: 10,
word_count: 1000,
created_at: 1609459200,
updated_at: 1609545600,
tags: [],
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
created_by: 'user-1',
doc_form: ChunkingMode.text,
runtime_mode: 'general',
is_published: true,
total_available_documents: 10,
icon_info: {
icon: '📙',
icon_type: 'emoji' as const,
icon_background: '#FFF4ED',
icon_url: '',
},
retrieval_model_dict: {
search_method: RETRIEVE_METHOD.semantic,
},
author_name: 'Test User',
...overrides,
} as DataSet)
const defaultProps = {
dataset: createMockDataset(),
onSuccess: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<DatasetCard {...defaultProps} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
it('should render dataset name', () => {
const dataset = createMockDataset({ name: 'Custom Dataset Name' })
render(<DatasetCard {...defaultProps} dataset={dataset} />)
expect(screen.getByText('Custom Dataset Name')).toBeInTheDocument()
})
it('should render dataset description', () => {
const dataset = createMockDataset({ description: 'Custom Description' })
render(<DatasetCard {...defaultProps} dataset={dataset} />)
expect(screen.getByText('Custom Description')).toBeInTheDocument()
})
it('should render document count', () => {
render(<DatasetCard {...defaultProps} />)
expect(screen.getByText('10')).toBeInTheDocument()
})
it('should render app count', () => {
render(<DatasetCard {...defaultProps} />)
expect(screen.getByText('5')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should handle external provider', () => {
const dataset = createMockDataset({ provider: 'external' })
render(<DatasetCard {...defaultProps} dataset={dataset} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
it('should handle rag_pipeline runtime mode', () => {
const dataset = createMockDataset({ runtime_mode: 'rag_pipeline', is_published: true })
render(<DatasetCard {...defaultProps} dataset={dataset} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should navigate to documents page on click for regular dataset', () => {
const dataset = createMockDataset({ provider: 'vendor' })
render(<DatasetCard {...defaultProps} dataset={dataset} />)
const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]')
fireEvent.click(card!)
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/documents')
})
it('should navigate to hitTesting page on click for external provider', () => {
const dataset = createMockDataset({ provider: 'external' })
render(<DatasetCard {...defaultProps} dataset={dataset} />)
const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]')
fireEvent.click(card!)
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/hitTesting')
})
it('should navigate to pipeline page when pipeline is unpublished', () => {
const dataset = createMockDataset({ runtime_mode: 'rag_pipeline', is_published: false })
render(<DatasetCard {...defaultProps} dataset={dataset} />)
const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]')
fireEvent.click(card!)
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/pipeline')
})
})
describe('Styles', () => {
it('should have correct card styling', () => {
render(<DatasetCard {...defaultProps} />)
const card = screen.getByText('Test Dataset').closest('.group')
expect(card).toHaveClass('h-[190px]', 'cursor-pointer', 'flex-col', 'rounded-xl')
})
it('should have data-disable-nprogress attribute', () => {
render(<DatasetCard {...defaultProps} />)
const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]')
expect(card).toHaveAttribute('data-disable-nprogress', 'true')
})
})
describe('Edge Cases', () => {
it('should handle dataset without description', () => {
const dataset = createMockDataset({ description: '' })
render(<DatasetCard {...defaultProps} dataset={dataset} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
it('should handle embedding not available', () => {
const dataset = createMockDataset({ embedding_available: false })
render(<DatasetCard {...defaultProps} dataset={dataset} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
it('should handle undefined onSuccess', () => {
render(<DatasetCard dataset={createMockDataset()} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
})
describe('Tag Area Click', () => {
it('should stop propagation and prevent default when tag area is clicked', () => {
render(<DatasetCard {...defaultProps} />)
// Find tag area element (it's inside the card)
const tagAreaWrapper = document.querySelector('[class*="px-3"]')
if (tagAreaWrapper) {
const stopPropagationSpy = vi.fn()
const preventDefaultSpy = vi.fn()
const clickEvent = new MouseEvent('click', { bubbles: true })
Object.defineProperty(clickEvent, 'stopPropagation', { value: stopPropagationSpy })
Object.defineProperty(clickEvent, 'preventDefault', { value: preventDefaultSpy })
tagAreaWrapper.dispatchEvent(clickEvent)
expect(stopPropagationSpy).toHaveBeenCalled()
expect(preventDefaultSpy).toHaveBeenCalled()
}
})
it('should not navigate when clicking on tag area', () => {
render(<DatasetCard {...defaultProps} />)
// Click on tag area should not trigger card navigation
const tagArea = document.querySelector('[class*="px-3"]')
if (tagArea) {
fireEvent.click(tagArea)
// mockPush should NOT be called when clicking tag area
// (stopPropagation prevents it from reaching the card click handler)
}
})
})
})

View File

@ -1,87 +0,0 @@
import { RiEditLine } from '@remixicon/react'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import OperationItem from './operation-item'
describe('OperationItem', () => {
const defaultProps = {
Icon: RiEditLine,
name: 'Edit',
}
describe('Rendering', () => {
it('should render without crashing', () => {
render(<OperationItem {...defaultProps} />)
expect(screen.getByText('Edit')).toBeInTheDocument()
})
it('should render the icon', () => {
const { container } = render(<OperationItem {...defaultProps} />)
const icon = container.querySelector('svg')
expect(icon).toBeInTheDocument()
expect(icon).toHaveClass('size-4', 'text-text-tertiary')
})
it('should render the name text', () => {
render(<OperationItem {...defaultProps} />)
const nameSpan = screen.getByText('Edit')
expect(nameSpan).toHaveClass('system-md-regular', 'text-text-secondary')
})
})
describe('Props', () => {
it('should render different name', () => {
render(<OperationItem {...defaultProps} name="Delete" />)
expect(screen.getByText('Delete')).toBeInTheDocument()
})
it('should be callable without handleClick', () => {
render(<OperationItem {...defaultProps} />)
const item = screen.getByText('Edit').closest('div')
expect(() => fireEvent.click(item!)).not.toThrow()
})
})
describe('User Interactions', () => {
it('should call handleClick when clicked', () => {
const handleClick = vi.fn()
render(<OperationItem {...defaultProps} handleClick={handleClick} />)
const item = screen.getByText('Edit').closest('div')
fireEvent.click(item!)
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('should prevent default and stop propagation on click', () => {
const handleClick = vi.fn()
render(<OperationItem {...defaultProps} handleClick={handleClick} />)
const item = screen.getByText('Edit').closest('div')
const clickEvent = new MouseEvent('click', { bubbles: true })
const preventDefaultSpy = vi.spyOn(clickEvent, 'preventDefault')
const stopPropagationSpy = vi.spyOn(clickEvent, 'stopPropagation')
item!.dispatchEvent(clickEvent)
expect(preventDefaultSpy).toHaveBeenCalled()
expect(stopPropagationSpy).toHaveBeenCalled()
})
})
describe('Styles', () => {
it('should have correct container styling', () => {
render(<OperationItem {...defaultProps} />)
const item = screen.getByText('Edit').closest('div')
expect(item).toHaveClass('flex', 'cursor-pointer', 'items-center', 'gap-x-1', 'rounded-lg')
})
})
describe('Edge Cases', () => {
it('should handle empty name', () => {
render(<OperationItem {...defaultProps} name="" />)
const container = document.querySelector('.cursor-pointer')
expect(container).toBeInTheDocument()
})
})
})

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