mirror of
https://github.com/langgenius/dify.git
synced 2026-05-03 17:08:03 +08:00
Compare commits
14 Commits
build/clea
...
revert-307
| Author | SHA1 | Date | |
|---|---|---|---|
| 3fa3666540 | |||
| 4d1be51966 | |||
| c441cc3570 | |||
| 3ebe53ada1 | |||
| 76b64dda52 | |||
| a715c015e7 | |||
| 45b8d033be | |||
| cb51a449d3 | |||
| 62ac02a568 | |||
| 2d4289a925 | |||
| 88780c7eb7 | |||
| 0f1db88dcb | |||
| 92dbc94f2f | |||
| 9f09414dbe |
3
.github/labeler.yml
vendored
Normal file
3
.github/labeler.yml
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
web:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: 'web/**'
|
||||
14
.github/workflows/labeler.yml
vendored
Normal file
14
.github/workflows/labeler.yml
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
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
|
||||
5
.github/workflows/style.yml
vendored
5
.github/workflows/style.yml
vendored
@ -117,6 +117,11 @@ 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
|
||||
|
||||
@ -0,0 +1,52 @@
|
||||
## 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).
|
||||
18
api/agent-notes/services/dataset_service.py.md
Normal file
18
api/agent-notes/services/dataset_service.py.md
Normal file
@ -0,0 +1,18 @@
|
||||
## 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.
|
||||
35
api/agent-notes/services/file_service.py.md
Normal file
35
api/agent-notes/services/file_service.py.md
Normal file
@ -0,0 +1,35 @@
|
||||
## 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.
|
||||
@ -0,0 +1,28 @@
|
||||
## 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.
|
||||
@ -0,0 +1,18 @@
|
||||
## Purpose
|
||||
|
||||
Unit tests for `api/services/file_service.py` helper methods that are not covered by higher-level controller tests.
|
||||
|
||||
## What’s 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.
|
||||
@ -1,4 +1,3 @@
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal, TypeAlias
|
||||
@ -68,48 +67,6 @@ 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)
|
||||
@ -118,11 +75,6 @@ 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")
|
||||
@ -133,11 +85,6 @@ 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")
|
||||
@ -146,11 +93,6 @@ 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")
|
||||
|
||||
@ -2,10 +2,12 @@ import json
|
||||
import logging
|
||||
from argparse import ArgumentTypeError
|
||||
from collections.abc import Sequence
|
||||
from typing import Literal, cast
|
||||
from contextlib import ExitStack
|
||||
from typing import Any, Literal, cast
|
||||
from uuid import UUID
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask import request
|
||||
from flask import request, send_file
|
||||
from flask_restx import Resource, fields, marshal, marshal_with
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import asc, desc, select
|
||||
@ -42,6 +44,7 @@ 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,
|
||||
@ -65,6 +68,9 @@ 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)
|
||||
@ -104,6 +110,12 @@ 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.")
|
||||
@ -120,6 +132,7 @@ register_schema_models(
|
||||
RetrievalModel,
|
||||
DocumentRetryPayload,
|
||||
DocumentRenamePayload,
|
||||
DocumentBatchDownloadZipPayload,
|
||||
)
|
||||
|
||||
|
||||
@ -853,6 +866,62 @@ 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")
|
||||
|
||||
@ -320,18 +320,17 @@ 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=args.get("description"))
|
||||
raise InvokeRateLimitError(description=error_object.get("message"))
|
||||
case InvokeAuthorizationError.__name__:
|
||||
raise InvokeAuthorizationError(description=args.get("description"))
|
||||
raise InvokeAuthorizationError(description=error_object.get("message"))
|
||||
case InvokeBadRequestError.__name__:
|
||||
raise InvokeBadRequestError(description=args.get("description"))
|
||||
raise InvokeBadRequestError(description=error_object.get("message"))
|
||||
case InvokeConnectionError.__name__:
|
||||
raise InvokeConnectionError(description=args.get("description"))
|
||||
raise InvokeConnectionError(description=error_object.get("message"))
|
||||
case InvokeServerUnavailableError.__name__:
|
||||
raise InvokeServerUnavailableError(description=args.get("description"))
|
||||
raise InvokeServerUnavailableError(description=error_object.get("message"))
|
||||
case CredentialsValidateFailedError.__name__:
|
||||
raise CredentialsValidateFailedError(error_object.get("message"))
|
||||
case EndpointSetupFailedError.__name__:
|
||||
@ -339,11 +338,11 @@ class BasePluginClient:
|
||||
case TriggerProviderCredentialValidationError.__name__:
|
||||
raise TriggerProviderCredentialValidationError(error_object.get("message"))
|
||||
case TriggerPluginInvokeError.__name__:
|
||||
raise TriggerPluginInvokeError(description=error_object.get("description"))
|
||||
raise TriggerPluginInvokeError(description=error_object.get("message"))
|
||||
case TriggerInvokeError.__name__:
|
||||
raise TriggerInvokeError(error_object.get("message"))
|
||||
case EventIgnoreError.__name__:
|
||||
raise EventIgnoreError(description=error_object.get("description"))
|
||||
raise EventIgnoreError(description=error_object.get("message"))
|
||||
case _:
|
||||
raise PluginInvokeError(description=message)
|
||||
case PluginDaemonInternalServerError.__name__:
|
||||
|
||||
@ -13,10 +13,11 @@ import sqlalchemy as sa
|
||||
from redis.exceptions import LockNotOwnedError
|
||||
from sqlalchemy import exists, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import NotFound
|
||||
from werkzeug.exceptions import Forbidden, 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
|
||||
@ -73,6 +74,7 @@ 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
|
||||
@ -1162,6 +1164,7 @@ class DocumentService:
|
||||
Document.archived.is_(True),
|
||||
),
|
||||
}
|
||||
DOCUMENT_BATCH_DOWNLOAD_ZIP_FILENAME_EXTENSION = ".zip"
|
||||
|
||||
@classmethod
|
||||
def normalize_display_status(cls, status: str | None) -> str | None:
|
||||
@ -1288,6 +1291,143 @@ 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()
|
||||
|
||||
@ -2,7 +2,11 @@ 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
|
||||
@ -17,6 +21,7 @@ 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
|
||||
@ -167,6 +172,9 @@ 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()
|
||||
|
||||
@ -253,3 +261,101 @@ 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)
|
||||
|
||||
@ -83,7 +83,30 @@
|
||||
<p class="content1">Dear {{ to }},</p>
|
||||
<p class="content2">{{ inviter_name }} is pleased to invite you to join our workspace on Dify, a platform specifically designed for LLM application development. On Dify, you can explore, create, and collaborate to build and operate AI applications.</p>
|
||||
<p class="content2">Click the button below to log in to Dify and join the workspace.</p>
|
||||
<p style="text-align: center; margin: 0; margin-bottom: 32px;"><a style="color: #fff; text-decoration: none" class="button" href="{{ url }}">Login Here</a></p>
|
||||
<div style="text-align: center; margin-bottom: 32px;">
|
||||
<a href="{{ url }}"
|
||||
style="background-color:#2563eb;
|
||||
color:#ffffff !important;
|
||||
text-decoration:none;
|
||||
display:inline-block;
|
||||
font-weight:600;
|
||||
border-radius:4px;
|
||||
font-size:14px;
|
||||
line-height:18px;
|
||||
font-family: Helvetica, Arial, sans-serif;
|
||||
text-align:center;
|
||||
border-top: 10px solid #2563eb;
|
||||
border-bottom: 10px solid #2563eb;
|
||||
border-left: 20px solid #2563eb;
|
||||
border-right: 20px solid #2563eb;
|
||||
">Login Here</a>
|
||||
<p style="font-size: 12px; color: #666666; margin-top: 20px; margin-bottom: 0;">
|
||||
If the button doesn't work, copy and paste this link into your browser:<br>
|
||||
<a href="{{ url }}" style="color: #2563eb; text-decoration: underline; word-break: break-all;">
|
||||
{{ url }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<p class="content2">Best regards,</p>
|
||||
<p class="content2">Dify Team</p>
|
||||
</div>
|
||||
|
||||
@ -83,7 +83,30 @@
|
||||
<p class="content1">尊敬的 {{ to }},</p>
|
||||
<p class="content2">{{ inviter_name }} 现邀请您加入我们在 Dify 的工作区,这是一个专为 LLM 应用开发而设计的平台。在 Dify 上,您可以探索、创造和合作,构建和运营 AI 应用。</p>
|
||||
<p class="content2">点击下方按钮即可登录 Dify 并且加入空间。</p>
|
||||
<p style="text-align: center; margin: 0; margin-bottom: 32px;"><a style="color: #fff; text-decoration: none" class="button" href="{{ url }}">在此登录</a></p>
|
||||
<div style="text-align: center; margin-bottom: 32px;">
|
||||
<a href="{{ url }}"
|
||||
style="background-color:#2563eb;
|
||||
color:#ffffff !important;
|
||||
text-decoration:none;
|
||||
display:inline-block;
|
||||
font-weight:600;
|
||||
border-radius:4px;
|
||||
font-size:14px;
|
||||
line-height:18px;
|
||||
font-family: Helvetica, Arial, sans-serif;
|
||||
text-align:center;
|
||||
border-top: 10px solid #2563eb;
|
||||
border-bottom: 10px solid #2563eb;
|
||||
border-left: 20px solid #2563eb;
|
||||
border-right: 20px solid #2563eb;
|
||||
">在此登录</a>
|
||||
<p style="font-size: 12px; color: #666666; margin-top: 20px; margin-bottom: 0;">
|
||||
如果按钮无法使用,请将以下链接复制到浏览器打开:<br>
|
||||
<a href="{{ url }}" style="color: #2563eb; text-decoration: underline; word-break: break-all;">
|
||||
{{ url }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<p class="content2">此致,</p>
|
||||
<p class="content2">Dify 团队</p>
|
||||
</div>
|
||||
|
||||
@ -115,7 +115,30 @@
|
||||
We noticed you tried to sign up, but this email is already registered with an existing account.
|
||||
|
||||
Please log in here: </p>
|
||||
<a href="{{ login_url }}" class="button">Log In</a>
|
||||
<div style="text-align: center; margin-bottom: 20px;">
|
||||
<a href="{{ login_url }}"
|
||||
style="background-color:#2563eb;
|
||||
color:#ffffff !important;
|
||||
text-decoration:none;
|
||||
display:inline-block;
|
||||
font-weight:600;
|
||||
border-radius:4px;
|
||||
font-size:14px;
|
||||
line-height:18px;
|
||||
font-family: Helvetica, Arial, sans-serif;
|
||||
text-align:center;
|
||||
border-top: 10px solid #2563eb;
|
||||
border-bottom: 10px solid #2563eb;
|
||||
border-left: 20px solid #2563eb;
|
||||
border-right: 20px solid #2563eb;
|
||||
">Log In</a>
|
||||
<p style="font-size: 12px; color: #666666; margin-top: 20px; margin-bottom: 0;">
|
||||
If the button doesn't work, copy and paste this link into your browser:<br>
|
||||
<a href="{{ login_url }}" style="color: #2563eb; text-decoration: underline; word-break: break-all;">
|
||||
{{ login_url }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<p class="description">
|
||||
If you forgot your password, you can reset it here: <a href="{{ reset_password_url }}"
|
||||
class="reset-btn">Reset Password</a>
|
||||
|
||||
@ -115,7 +115,30 @@
|
||||
我们注意到您尝试注册,但此电子邮件已注册。
|
||||
|
||||
请在此登录: </p>
|
||||
<a href="{{ login_url }}" class="button">登录</a>
|
||||
<div style="text-align: center; margin-bottom: 20px;">
|
||||
<a href="{{ login_url }}"
|
||||
style="background-color:#2563eb;
|
||||
color:#ffffff !important;
|
||||
text-decoration:none;
|
||||
display:inline-block;
|
||||
font-weight:600;
|
||||
border-radius:4px;
|
||||
font-size:14px;
|
||||
line-height:18px;
|
||||
font-family: Helvetica, Arial, sans-serif;
|
||||
text-align:center;
|
||||
border-top: 10px solid #2563eb;
|
||||
border-bottom: 10px solid #2563eb;
|
||||
border-left: 20px solid #2563eb;
|
||||
border-right: 20px solid #2563eb;
|
||||
">登录</a>
|
||||
<p style="font-size: 12px; color: #666666; margin-top: 20px; margin-bottom: 0;">
|
||||
如果按钮无法使用,请将以下链接复制到浏览器打开:<br>
|
||||
<a href="{{ login_url }}" style="color: #2563eb; text-decoration: underline; word-break: break-all;">
|
||||
{{ login_url }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<p class="description">
|
||||
如果您忘记了密码,可以在此重置: <a href="{{ reset_password_url }}" class="reset-btn">重置密码</a>
|
||||
</p>
|
||||
|
||||
@ -92,12 +92,34 @@
|
||||
platform specifically designed for LLM application development. On {{application_title}}, you can explore,
|
||||
create, and collaborate to build and operate AI applications.</p>
|
||||
<p class="content2">Click the button below to log in to {{application_title}} and join the workspace.</p>
|
||||
<p style="text-align: center; margin: 0; margin-bottom: 32px;"><a style="color: #fff; text-decoration: none"
|
||||
class="button" href="{{ url }}">Login Here</a></p>
|
||||
<div style="text-align: center; margin-bottom: 32px;">
|
||||
<a href="{{ url }}"
|
||||
style="background-color:#2563eb;
|
||||
color:#ffffff !important;
|
||||
text-decoration:none;
|
||||
display:inline-block;
|
||||
font-weight:600;
|
||||
border-radius:4px;
|
||||
font-size:14px;
|
||||
line-height:18px;
|
||||
font-family: Helvetica, Arial, sans-serif;
|
||||
text-align:center;
|
||||
border-top: 10px solid #2563eb;
|
||||
border-bottom: 10px solid #2563eb;
|
||||
border-left: 20px solid #2563eb;
|
||||
border-right: 20px solid #2563eb;
|
||||
">Login Here</a>
|
||||
<p style="font-size: 12px; color: #666666; margin-top: 20px; margin-bottom: 0;">
|
||||
If the button doesn't work, copy and paste this link into your browser:<br>
|
||||
<a href="{{ url }}" style="color: #2563eb; text-decoration: underline; word-break: break-all;">
|
||||
{{ url }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<p class="content2">Best regards,</p>
|
||||
<p class="content2">{{application_title}} Team</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@ -81,7 +81,30 @@
|
||||
<p class="content1">尊敬的 {{ to }},</p>
|
||||
<p class="content2">{{ inviter_name }} 现邀请您加入我们在 {{application_title}} 的工作区,这是一个专为 LLM 应用开发而设计的平台。在 {{application_title}} 上,您可以探索、创造和合作,构建和运营 AI 应用。</p>
|
||||
<p class="content2">点击下方按钮即可登录 {{application_title}} 并且加入空间。</p>
|
||||
<p style="text-align: center; margin: 0; margin-bottom: 32px;"><a style="color: #fff; text-decoration: none" class="button" href="{{ url }}">在此登录</a></p>
|
||||
<div style="text-align: center; margin-bottom: 32px;">
|
||||
<a href="{{ url }}"
|
||||
style="background-color:#2563eb;
|
||||
color:#ffffff !important;
|
||||
text-decoration:none;
|
||||
display:inline-block;
|
||||
font-weight:600;
|
||||
border-radius:4px;
|
||||
font-size:14px;
|
||||
line-height:18px;
|
||||
font-family: Helvetica, Arial, sans-serif;
|
||||
text-align:center;
|
||||
border-top: 10px solid #2563eb;
|
||||
border-bottom: 10px solid #2563eb;
|
||||
border-left: 20px solid #2563eb;
|
||||
border-right: 20px solid #2563eb;
|
||||
">在此登录</a>
|
||||
<p style="font-size: 12px; color: #666666; margin-top: 20px; margin-bottom: 0;">
|
||||
如果按钮无法使用,请将以下链接复制到浏览器打开:<br>
|
||||
<a href="{{ url }}" style="color: #2563eb; text-decoration: underline; word-break: break-all;">
|
||||
{{ url }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<p class="content2">此致,</p>
|
||||
<p class="content2">{{application_title}} 团队</p>
|
||||
</div>
|
||||
|
||||
@ -111,7 +111,30 @@
|
||||
We noticed you tried to sign up, but this email is already registered with an existing account.
|
||||
|
||||
Please log in here: </p>
|
||||
<a href="{{ login_url }}" class="button">Log In</a>
|
||||
<div style="text-align: center; margin-bottom: 20px;">
|
||||
<a href="{{ login_url }}"
|
||||
style="background-color:#2563eb;
|
||||
color:#ffffff !important;
|
||||
text-decoration:none;
|
||||
display:inline-block;
|
||||
font-weight:600;
|
||||
border-radius:4px;
|
||||
font-size:14px;
|
||||
line-height:18px;
|
||||
font-family: Helvetica, Arial, sans-serif;
|
||||
text-align:center;
|
||||
border-top: 10px solid #2563eb;
|
||||
border-bottom: 10px solid #2563eb;
|
||||
border-left: 20px solid #2563eb;
|
||||
border-right: 20px solid #2563eb;
|
||||
">Log In</a>
|
||||
<p style="font-size: 12px; color: #666666; margin-top: 20px; margin-bottom: 0;">
|
||||
If the button doesn't work, copy and paste this link into your browser:<br>
|
||||
<a href="{{ login_url }}" style="color: #2563eb; text-decoration: underline; word-break: break-all;">
|
||||
{{ login_url }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<p class="description">
|
||||
If you forgot your password, you can reset it here: <a href="{{ reset_password_url }}"
|
||||
class="reset-btn">Reset Password</a>
|
||||
|
||||
@ -111,7 +111,30 @@
|
||||
我们注意到您尝试注册,但此电子邮件已注册。
|
||||
|
||||
请在此登录: </p>
|
||||
<a href="{{ login_url }}" class="button">登录</a>
|
||||
<div style="text-align: center; margin-bottom: 20px;">
|
||||
<a href="{{ login_url }}"
|
||||
style="background-color:#2563eb;
|
||||
color:#ffffff !important;
|
||||
text-decoration:none;
|
||||
display:inline-block;
|
||||
font-weight:600;
|
||||
border-radius:4px;
|
||||
font-size:14px;
|
||||
line-height:18px;
|
||||
font-family: Helvetica, Arial, sans-serif;
|
||||
text-align:center;
|
||||
border-top: 10px solid #2563eb;
|
||||
border-bottom: 10px solid #2563eb;
|
||||
border-left: 20px solid #2563eb;
|
||||
border-right: 20px solid #2563eb;
|
||||
">登录</a>
|
||||
<p style="font-size: 12px; color: #666666; margin-top: 20px; margin-bottom: 0;">
|
||||
如果按钮无法使用,请将以下链接复制到浏览器打开:<br>
|
||||
<a href="{{ login_url }}" style="color: #2563eb; text-decoration: underline; word-break: break-all;">
|
||||
{{ login_url }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<p class="description">
|
||||
如果您忘记了密码,可以在此重置: <a href="{{ reset_password_url }}" class="reset-btn">重置密码</a>
|
||||
</p>
|
||||
|
||||
@ -1,254 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
@ -0,0 +1,430 @@
|
||||
"""
|
||||
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")
|
||||
@ -346,6 +346,7 @@ 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)})
|
||||
@ -364,6 +365,7 @@ 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)})
|
||||
@ -382,6 +384,7 @@ 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)})
|
||||
@ -400,6 +403,7 @@ 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)})
|
||||
@ -418,6 +422,7 @@ 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)})
|
||||
|
||||
@ -0,0 +1,99 @@
|
||||
"""
|
||||
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
|
||||
4
web/.vscode/extensions.json
vendored
4
web/.vscode/extensions.json
vendored
@ -1,6 +1,8 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"kisstkondoros.vscode-codemetrics"
|
||||
"kisstkondoros.vscode-codemetrics",
|
||||
"johnsoncodehk.vscode-tsslint",
|
||||
"dbaeumer.vscode-eslint"
|
||||
]
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { FC } from 'react'
|
||||
import type { FC, MouseEvent } from 'react'
|
||||
import type { Resources } from './index'
|
||||
import Link from 'next/link'
|
||||
import { Fragment, useState } from 'react'
|
||||
@ -18,6 +18,8 @@ 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'
|
||||
|
||||
@ -36,6 +38,30 @@ 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}
|
||||
@ -49,6 +75,7 @@ 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>
|
||||
@ -57,7 +84,21 @@ 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">{data.documentName}</div>
|
||||
<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>
|
||||
</div>
|
||||
<div className="max-h-[450px] overflow-y-auto rounded-lg bg-components-panel-bg px-4 py-0.5">
|
||||
|
||||
@ -1,17 +0,0 @@
|
||||
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<g clip-path="url(#clip0_6305_73327)">
|
||||
<path d="M0.5 12.5C0.5 8.77247 0.5 6.9087 1.10896 5.43853C1.92092 3.47831 3.47831 1.92092 5.43853 1.10896C6.9087 0.5 8.77247 0.5 12.5 0.5C16.2275 0.5 18.0913 0.5 19.5615 1.10896C21.5217 1.92092 23.0791 3.47831 23.891 5.43853C24.5 6.9087 24.5 8.77247 24.5 12.5C24.5 16.2275 24.5 18.0913 23.891 19.5615C23.0791 21.5217 21.5217 23.0791 19.5615 23.891C18.0913 24.5 16.2275 24.5 12.5 24.5C8.77247 24.5 6.9087 24.5 5.43853 23.891C3.47831 23.0791 1.92092 21.5217 1.10896 19.5615C0.5 18.0913 0.5 16.2275 0.5 12.5Z" fill="white"/>
|
||||
<rect width="24" height="24" transform="translate(0.5 0.5)" fill="url(#pattern0_6305_73327)"/>
|
||||
<rect width="24" height="24" transform="translate(0.5 0.5)" fill="white" fill-opacity="0.01"/>
|
||||
</g>
|
||||
<path d="M12.5 0.25C14.3603 0.25 15.7684 0.250313 16.8945 0.327148C18.0228 0.404144 18.8867 0.558755 19.6572 0.87793C21.6787 1.71525 23.2847 3.32133 24.1221 5.34277C24.4412 6.11333 24.5959 6.97723 24.6729 8.10547C24.7497 9.23161 24.75 10.6397 24.75 12.5C24.75 14.3603 24.7497 15.7684 24.6729 16.8945C24.5959 18.0228 24.4412 18.8867 24.1221 19.6572C23.2847 21.6787 21.6787 23.2847 19.6572 24.1221C18.8867 24.4412 18.0228 24.5959 16.8945 24.6729C15.7684 24.7497 14.3603 24.75 12.5 24.75C10.6397 24.75 9.23161 24.7497 8.10547 24.6729C6.97723 24.5959 6.11333 24.4412 5.34277 24.1221C3.32133 23.2847 1.71525 21.6787 0.87793 19.6572C0.558755 18.8867 0.404144 18.0228 0.327148 16.8945C0.250313 15.7684 0.25 14.3603 0.25 12.5C0.25 10.6397 0.250313 9.23161 0.327148 8.10547C0.404144 6.97723 0.558755 6.11333 0.87793 5.34277C1.71525 3.32133 3.32133 1.71525 5.34277 0.87793C6.11333 0.558755 6.97723 0.404144 8.10547 0.327148C9.23161 0.250313 10.6397 0.25 12.5 0.25Z" stroke="#101828" stroke-opacity="0.08" stroke-width="0.5"/>
|
||||
<defs>
|
||||
<pattern id="pattern0_6305_73327" patternContentUnits="objectBoundingBox" width="1" height="1">
|
||||
<use xlink:href="#image0_6305_73327" transform="scale(0.00625)"/>
|
||||
</pattern>
|
||||
<clipPath id="clip0_6305_73327">
|
||||
<path d="M0.5 12.5C0.5 8.77247 0.5 6.9087 1.10896 5.43853C1.92092 3.47831 3.47831 1.92092 5.43853 1.10896C6.9087 0.5 8.77247 0.5 12.5 0.5C16.2275 0.5 18.0913 0.5 19.5615 1.10896C21.5217 1.92092 23.0791 3.47831 23.891 5.43853C24.5 6.9087 24.5 8.77247 24.5 12.5C24.5 16.2275 24.5 18.0913 23.891 19.5615C23.0791 21.5217 21.5217 23.0791 19.5615 23.891C18.0913 24.5 16.2275 24.5 12.5 24.5C8.77247 24.5 6.9087 24.5 5.43853 23.891C3.47831 23.0791 1.92092 21.5217 1.10896 19.5615C0.5 18.0913 0.5 16.2275 0.5 12.5Z" fill="white"/>
|
||||
</clipPath>
|
||||
<image id="image0_6305_73327" width="160" height="160" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAKAAAACgCAYAAACLz2ctAAAACXBIWXMAACxLAAAsSwGlPZapAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAm6SURBVHgB7Z09jFVFGIZn/Wf9I5FSKIVSKYndErolVJJQQUKliRWFNhY2UGxFItUmUJFARZaOQGekA0poF8slUcFFRVnPu9cDw/HuPWdmvpnvu3PeJ9m4MQH23H3P/DzzzczCVoMjRInXHCGKMIBEFQaQqMIAElUYQKIKA0hUYQCJKgwgUYUBJKowgEQVBpCowgASVRhAogoDSFRhAIkqDCBRhQEkqjCARBUGkKjCABJVGECiCgNIVGEAiSpvuJGyubnlLq0+cQ/u/739fSyLiwvu8JF33PKxXY6EM9oW8Mrl393dO8+Swgfw59euPXU//finI+GMMoCPNp43gflr+3u0YBJcubzZhPG5I2GMMoA/nH/84vvjJxbd/gPpIxG0hDdvsBUMZXQBRFf5cP2f7e8Pff729tfysUUnwa0bf7iNDbaCIYwugNeb8VpLO3FAC4ggpoJW8GoztiTDGVUAEb62hULg9ux5+fjoiiXGg5jYYGZNhjGaAGLicbPpIsFHTfC62gThW2p0igTXr206MozRBHDt2uYL5XK0CZ/f+rXA5037/6GgBaSWGcYoAuhrF7R+O4330Ap+cUJmQkItM4xRBNDXLkd7Viw+O/gWtUxBqg/gNO3SB7VMOaoP4DTt0gdaQIkJCbVMP1UXI8zSLn2gq77dtJ6pa8XQMheaIcCuxfLvOiZVe/e97ixTbQD7tEsfrZbxW9BYEEINMPw4880HImPaXFTbBQ/RLn1IaRlNLq4+cZapMoBDtUsfUpUymuCzWBNoxXNRZQBDtMss/DHkPIPZuFUnWV0AY7TLNCataB0eD0OR60ZbweoCGKNdpoExZE0OD1L84bq9IomqAuh3mUsJEwh/DFkTWB60RjUB7GqXwwki2R9D1gSKJKyVilUTQAntAvwxZI1Y0zJVBFBKuwCrg3UprGmZKgLY3WQUSy3apQ9LWmbuA9jVLiinisEfQ9aOJS1jYpEQA+NHG3HjLkntklp4ME9Ay+CF3btPNwLqAYQakGh5qF3CwWePYgVNVLtgdJ9S3d6Ci2+9atUufVjQMqotoN99Yuz26cE3XQzrzRgQQV46Eq5f0O0eFtoNNy/cu/PM3b0zafG1JyNqAeyWqz+4/8ydPI29ueGN8qHm6+dmmQmnXYV2Kah4kdiUPk/4L772GFClC54240zdxIN9HBZNvzWkliulUPnXd1roT9nEg6pffFkvwNREcrlSiuIBnDXjTN3Ec+r0e+5p83fcGonPC0VquVKS4j9BX0VGytkqeKvRrWCpiZvCX0VyuVKSogGEdmlnX7NIOVul7VZqX9MNRWq5UpqiARwaipSzVTCrxYoIJjTcFD5BarkyB8UCGDrBSDlbBa0gJiSXOCHZRmq5MgdFAhiz0E8tI4M17dKlyE8Tu7+CWiYNi9qlS/YApiz0U8ukYVG7dMn+E6W2QNQycVjVLl2yLwRKjMGgZfZHlg2h20ELiEnN/gNxxQ7ziD/mtqRdumQPIE5nSt3k02qZmLe41TII4Bhr/sC9xr1aUi8+C1sNLiMIzsXV9DPyEKSzKx9GVcuAlXO/zc2MGF3muZXdLhX/ma2ekpV9DIhWy8KRt1KnnpZAqsv0n9nqyf1FpkUWjrxttYx1JFcq/Ge2enJ/kQBauYkIWsb6kWvSKxX+M1u0AcXEEDyU9k1ErZaxSo6VCv+ZJ2LaVitYLICSv/zUahmLrWDOlQr/ma2d3F9UjVu4iajVMtbIuVLhP/NkU7qdCUnRAEr+8iWqZaxQYqXCf2b4UCtKqvjiILXM/ym1UmFRy6isTlPLvKRkgahFLaMSQGqZl5Qej1rTMmr1OdQyOgWi1rSMWgDHrmVStUtKmZslLaNaoThmLZN6jDBmshLPrK1lVAM4Vi0jdYxwyhjOipZRr9Eeo5aROkY4dQxnQcuY2CQwJi2Teoxw94BxqWfW0jImAjgmLZN6jHCX1DGctpYxs01qDFpmOWHiMWt3YcoYTlvLmAlg7VomdeKB8vpZSD1zaS1jaqOoFS2TY202Vbv0hUJKRZXWMqYCaEXLSM3MW0rd3jSPWsbcVvkatQwG+rGE3N40j1rG3lkNzo6WkahSSXmhYu51mzctYzKAVrQMxoKpExJp7dKHpJYpcXWZ2X2KuDNE4g1stUxMK9TOzGNPW82lXfrAn3u6+djtitzEjwAiyCWurTUbwKuCt3tLnC0Teo9cbu3SB168VGIvDgrBZBc8RDuEoKFlcmuXEhw/8a7LjbkAouvJccB4SS1Tw6XZ+PlLFMuaC2Cut7+klimlXXKBF6hUjaSpAOa+Tr6ElimtXXIgtSI1BFMBXMssP0tomdLaRZrSZ0mbCeBkopD/AMmc1TJa2kWSo4W3J5gJYMk7PXJUy2hrFwmgXUqfJW0igKW1A1rA2JPzd9Iy1C5xqAcwl3bpI6VypDvRoHaJRz2AWm+/pJahdolHNYDa2iFVy6A7pnZJQ3UteH1dpugRV0Hs3Rf3KLjCIEY7oOVGK0rtkoZqAGOvXHj171hwX379ftE3uB23Uruko9oFS+zF1TjgBy0XamPmXbvgs9O+wku9HAtT/++/+9XFgO6j9BvctlynTr+rrl1Snh9DFgxdtFEPID6Epf9q7kLR6D7Q+qVol0nFsszEA89v9RLCoZgQ0TGb0j9uglv6w29PpUrRLlL7bjWePwcmAojwhW5K/6qZeJQGLZcV7aLx/DkwdTTH0DGVhrVvx20WtIvWqkUOTD3FyQFdm8aBkhLaBRt8JLSL1XtOYjEVwCFaZl61y4Xzj50ES4qrFjkw9ySzKjI0tYuFaheN58+NuQC2WmYa1C51hQ+YbMunaRkN7YDqaWqXvJgM4DQto6EdsH+E2iUvZk9GQCt42xs7fXvmF6fB3oQja6ld+jH9VCcTuj4pYjcxUbsMY2GrwRkGv8gSpzR1weQBtYIAXfCZwLNl0GJLjP0QvhonHy3mA6jJhfNPmhZwEkJUvwydBEC7XFyN33/cgtn3uZXdrmbqHFgI4W9EH3q2DLVLGAzgDPyN6EM3MVG7hMEA9hByhQG1SzgMYA/+RvS+s2WoXcJhAAfgy+idtAy1SxwM4ED6rjBgtUscDOBA/PMBu2fLsNolHgYwAF/LtGfLULukwQAGME3LULukwZWQQBA8LLPhv+19GhKcbVY8xjT2a2ELGEhXy0gwJu3ShQGMAIFZFrpg+5NmcpPjeth5gV0wUYUtIFGFASSqMIBEFQaQqMIAElUYQKIKA0hUYQCJKgwgUYUBJKowgEQVBpCowgASVRhAogoDSFRhAIkqDCBRhQEkqjCARBUGkKjCABJVGECiCgNIVPkXGPWKHZj1nMYAAAAASUVORK5CYII="/>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 6.1 KiB |
@ -1,4 +0,0 @@
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="40" height="40" fill="white"/>
|
||||
<path d="M25.7926 10.1311H21.5089L29.3208 29.869H33.6045L25.7926 10.1311ZM13.4164 10.1311L5.60449 29.869H9.97273L11.5703 25.724H19.743L21.3405 29.869H25.7087L17.8969 10.1311H13.4164ZM12.9834 22.0583L15.6566 15.1217L18.3299 22.0583H12.9834Z" fill="black"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 403 B |
@ -1,4 +0,0 @@
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="40" height="40" fill="white"/>
|
||||
<path d="M36.6676 11.2917C36.3316 11.1277 36.1871 11.4402 35.9906 11.599C35.9242 11.6511 35.8668 11.7188 35.8108 11.7787C35.3199 12.3048 34.747 12.6485 33.9996 12.6068C32.9046 12.5469 31.971 12.8907 31.1455 13.7293C30.9696 12.6954 30.3863 12.0782 29.4996 11.6824C29.0348 11.4766 28.5647 11.2709 28.2406 10.823C28.0127 10.5053 27.9515 10.1511 27.8368 9.80214C27.7652 9.59121 27.6923 9.37506 27.4502 9.33861C27.1871 9.29694 27.0843 9.51829 26.9814 9.70318C26.5674 10.4584 26.4084 11.2917 26.4228 12.1355C26.4592 14.0313 27.26 15.5417 28.8486 16.6173C29.0296 16.7397 29.0764 16.8646 29.0191 17.0443C28.9111 17.4141 28.7822 17.7735 28.6676 18.1433C28.596 18.3803 28.4879 18.4323 28.2354 18.3282C27.363 17.9637 26.609 17.4246 25.9436 16.7709C24.8135 15.6771 23.7914 14.4689 22.5166 13.5235C22.2171 13.3021 21.919 13.0964 21.609 12.9011C20.3082 11.6355 21.7796 10.5964 22.1194 10.474C22.4762 10.3464 22.2431 9.9037 21.092 9.90891C19.9423 9.91413 18.889 10.2995 17.5478 10.8126C17.3512 10.8907 17.1455 10.948 16.9332 10.9922C15.7158 10.7631 14.4515 10.711 13.1298 10.8594C10.6428 11.1381 8.65587 12.3152 7.19493 14.3255C5.44102 16.7397 5.02826 19.4845 5.53347 22.349C6.06473 25.3646 7.60249 27.8646 9.96707 29.8178C12.4176 31.8413 15.2406 32.8334 18.4606 32.6433C20.4163 32.5313 22.5947 32.2683 25.0504 30.1875C25.6702 30.4949 26.3199 30.6173 27.3994 30.711C28.2302 30.7891 29.0296 30.6694 29.6494 30.5417C30.6194 30.3361 30.5518 29.4375 30.2015 29.2709C27.3578 27.9454 27.9814 28.4845 27.4136 28.0495C28.859 26.3361 31.0374 24.5574 31.889 18.797C31.9554 18.3386 31.898 18.0522 31.889 17.6798C31.8838 17.4558 31.9346 17.3673 32.1923 17.3413C32.9046 17.2605 33.596 17.0651 34.2314 16.7137C36.0739 15.7058 36.816 14.0522 36.9918 12.0678C37.0179 11.7657 36.9866 11.4506 36.6676 11.2917ZM20.613 29.1485C17.8564 26.9793 16.5204 26.2657 15.9684 26.297C15.4527 26.3255 15.5452 26.9167 15.6584 27.3022C15.777 27.6823 15.9319 27.9454 16.1494 28.2787C16.2991 28.5001 16.402 28.8307 15.9996 29.0755C15.1116 29.6277 13.5687 28.8907 13.4958 28.8542C11.7001 27.797 10.1988 26.3985 9.14025 24.487C8.11941 22.6459 7.52566 20.6719 7.42801 18.5651C7.40197 18.0547 7.5517 17.875 8.05691 17.7839C8.72227 17.6615 9.40978 17.6355 10.0751 17.7318C12.8876 18.1433 15.2822 19.4037 17.2887 21.3959C18.4346 22.5339 19.3018 23.8907 20.195 25.2162C21.1442 26.6251 22.1663 27.9662 23.4671 29.0651C23.9254 29.4506 24.2926 29.7449 24.6428 29.961C23.5856 30.0782 21.8199 30.1042 20.613 29.1485ZM21.9332 20.6407C21.9332 20.4141 22.1142 20.2345 22.342 20.2345C22.3928 20.2345 22.4398 20.2449 22.4814 20.2605C22.5374 20.2813 22.5895 20.3126 22.6299 20.3594C22.7027 20.4298 22.7444 20.5339 22.7444 20.6407C22.7444 20.8673 22.5635 21.047 22.3368 21.047C22.109 21.047 21.9332 20.8673 21.9332 20.6407ZM26.036 22.7501C25.7731 22.8569 25.51 22.9506 25.2575 22.961C24.8655 22.9793 24.4371 22.8203 24.204 22.6251C23.8434 22.323 23.5856 22.1537 23.4762 21.6225C23.4306 21.3959 23.4567 21.047 23.497 20.8465C23.5908 20.4141 23.4866 20.1381 23.1832 19.8855C22.9346 19.6798 22.6207 19.6251 22.2744 19.6251C22.1455 19.6251 22.027 19.5678 21.9384 19.5209C21.7939 19.4479 21.6754 19.2683 21.7887 19.047C21.8251 18.9766 22.001 18.8022 22.0426 18.7709C22.5114 18.5027 23.053 18.5913 23.5543 18.7918C24.0191 18.9818 24.3694 19.3307 24.8746 19.823C25.3915 20.4194 25.484 20.5861 25.7783 21.0313C26.01 21.3829 26.2223 21.7422 26.3668 22.1537C26.454 22.4089 26.3408 22.6198 26.036 22.7501Z" fill="#4D6BFE"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.5 KiB |
@ -1,105 +0,0 @@
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="40" height="40" fill="white"/>
|
||||
<mask id="mask0_3892_95663" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="6" y="6" width="28" height="29">
|
||||
<path d="M20 6C20.2936 6 20.5488 6.2005 20.6205 6.48556C20.8393 7.3566 21.1277 8.20866 21.4828 9.03356C22.4116 11.191 23.6854 13.0791 25.3032 14.6968C26.9218 16.3146 28.8095 17.5888 30.9664 18.5172C31.7941 18.8735 32.6436 19.16 33.5149 19.3795C33.6533 19.4143 33.7762 19.4942 33.8641 19.6067C33.9519 19.7192 33.9998 19.8578 34 20.0005C34 20.2941 33.7995 20.5492 33.5149 20.621C32.6437 20.8399 31.7915 21.1282 30.9664 21.4833C28.8095 22.4121 26.9209 23.6859 25.3032 25.3036C23.6854 26.9223 22.4116 28.8099 21.4828 30.9669C21.1278 31.7919 20.8394 32.6439 20.6205 33.5149C20.586 33.6534 20.5062 33.7764 20.3937 33.8644C20.2813 33.9524 20.1427 34.0003 20 34.0005C19.8572 34.0003 19.7186 33.9525 19.6062 33.8645C19.4937 33.7765 19.414 33.6535 19.3795 33.5149C19.1605 32.6439 18.872 31.7918 18.5167 30.9669C17.5884 28.8099 16.3151 26.9214 14.6964 25.3036C13.0782 23.6859 11.1906 22.4121 9.03309 21.4833C8.20814 21.1283 7.35608 20.8399 6.48509 20.621C6.34667 20.5864 6.22377 20.5065 6.13589 20.3941C6.04801 20.2817 6.00018 20.1432 6 20.0005C6.00024 19.8578 6.04808 19.7192 6.13594 19.6067C6.2238 19.4942 6.34667 19.4143 6.48509 19.3795C7.35612 19.1607 8.20819 18.8723 9.03309 18.5172C11.1906 17.5888 13.0786 16.3146 14.6964 14.6968C16.3141 13.0791 17.5884 11.191 18.5167 9.03356C18.8719 8.20862 19.1604 7.35656 19.3795 6.48556C19.4508 6.2005 19.7064 6 20 6Z" fill="black"/>
|
||||
<path d="M20 6C20.2936 6 20.5488 6.2005 20.6205 6.48556C20.8393 7.3566 21.1277 8.20866 21.4828 9.03356C22.4116 11.191 23.6854 13.0791 25.3032 14.6968C26.9218 16.3146 28.8095 17.5888 30.9664 18.5172C31.7941 18.8735 32.6436 19.16 33.5149 19.3795C33.6533 19.4143 33.7762 19.4942 33.8641 19.6067C33.9519 19.7192 33.9998 19.8578 34 20.0005C34 20.2941 33.7995 20.5492 33.5149 20.621C32.6437 20.8399 31.7915 21.1282 30.9664 21.4833C28.8095 22.4121 26.9209 23.6859 25.3032 25.3036C23.6854 26.9223 22.4116 28.8099 21.4828 30.9669C21.1278 31.7919 20.8394 32.6439 20.6205 33.5149C20.586 33.6534 20.5062 33.7764 20.3937 33.8644C20.2813 33.9524 20.1427 34.0003 20 34.0005C19.8572 34.0003 19.7186 33.9525 19.6062 33.8645C19.4937 33.7765 19.414 33.6535 19.3795 33.5149C19.1605 32.6439 18.872 31.7918 18.5167 30.9669C17.5884 28.8099 16.3151 26.9214 14.6964 25.3036C13.0782 23.6859 11.1906 22.4121 9.03309 21.4833C8.20814 21.1283 7.35608 20.8399 6.48509 20.621C6.34667 20.5864 6.22377 20.5065 6.13589 20.3941C6.04801 20.2817 6.00018 20.1432 6 20.0005C6.00024 19.8578 6.04808 19.7192 6.13594 19.6067C6.2238 19.4942 6.34667 19.4143 6.48509 19.3795C7.35612 19.1607 8.20819 18.8723 9.03309 18.5172C11.1906 17.5888 13.0786 16.3146 14.6964 14.6968C16.3141 13.0791 17.5884 11.191 18.5167 9.03356C18.8719 8.20862 19.1604 7.35656 19.3795 6.48556C19.4508 6.2005 19.7064 6 20 6Z" fill="url(#paint0_linear_3892_95663)"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_3892_95663)">
|
||||
<g filter="url(#filter0_f_3892_95663)">
|
||||
<path d="M3.47232 27.8921C6.70753 29.0411 10.426 26.8868 11.7778 23.0804C13.1296 19.274 11.6028 15.2569 8.36763 14.108C5.13242 12.959 1.41391 15.1133 0.06211 18.9197C-1.28969 22.7261 0.23711 26.7432 3.47232 27.8921Z" fill="#FFE432"/>
|
||||
</g>
|
||||
<g filter="url(#filter1_f_3892_95663)">
|
||||
<path d="M17.8359 15.341C22.2806 15.341 25.8838 11.6588 25.8838 7.11644C25.8838 2.57412 22.2806 -1.10815 17.8359 -1.10815C13.3912 -1.10815 9.78809 2.57412 9.78809 7.11644C9.78809 11.6588 13.3912 15.341 17.8359 15.341Z" fill="#FC413D"/>
|
||||
</g>
|
||||
<g filter="url(#filter2_f_3892_95663)">
|
||||
<path d="M14.7081 41.6431C19.3478 41.4163 22.8707 36.3599 22.5768 30.3493C22.283 24.3387 18.2836 19.65 13.644 19.8769C9.00433 20.1037 5.48139 25.1601 5.77525 31.1707C6.06911 37.1813 10.0685 41.87 14.7081 41.6431Z" fill="#00B95C"/>
|
||||
</g>
|
||||
<g filter="url(#filter3_f_3892_95663)">
|
||||
<path d="M14.7081 41.6431C19.3478 41.4163 22.8707 36.3599 22.5768 30.3493C22.283 24.3387 18.2836 19.65 13.644 19.8769C9.00433 20.1037 5.48139 25.1601 5.77525 31.1707C6.06911 37.1813 10.0685 41.87 14.7081 41.6431Z" fill="#00B95C"/>
|
||||
</g>
|
||||
<g filter="url(#filter4_f_3892_95663)">
|
||||
<path d="M19.355 38.0071C23.2447 35.6405 24.2857 30.2506 21.6803 25.9684C19.0748 21.6862 13.8095 20.1334 9.91983 22.5C6.03016 24.8666 4.98909 30.2565 7.59454 34.5387C10.2 38.8209 15.4653 40.3738 19.355 38.0071Z" fill="#00B95C"/>
|
||||
</g>
|
||||
<g filter="url(#filter5_f_3892_95663)">
|
||||
<path d="M35.0759 24.5504C39.4477 24.5504 42.9917 21.1377 42.9917 16.9278C42.9917 12.7179 39.4477 9.30518 35.0759 9.30518C30.7042 9.30518 27.1602 12.7179 27.1602 16.9278C27.1602 21.1377 30.7042 24.5504 35.0759 24.5504Z" fill="#3186FF"/>
|
||||
</g>
|
||||
<g filter="url(#filter6_f_3892_95663)">
|
||||
<path d="M0.362818 23.6667C4.3882 26.7279 10.2688 25.7676 13.4976 21.5219C16.7264 17.2762 16.0806 11.3528 12.0552 8.29156C8.02982 5.23037 2.14917 6.19062 -1.07959 10.4364C-4.30835 14.6821 -3.66256 20.6055 0.362818 23.6667Z" fill="#FBBC04"/>
|
||||
</g>
|
||||
<g filter="url(#filter7_f_3892_95663)">
|
||||
<path d="M20.9877 28.1903C25.7924 31.4936 32.1612 30.5732 35.2128 26.1346C38.2644 21.696 36.8432 15.4199 32.0385 12.1166C27.2338 8.81334 20.865 9.73372 17.8134 14.1723C14.7618 18.611 16.183 24.887 20.9877 28.1903Z" fill="#3186FF"/>
|
||||
</g>
|
||||
<g filter="url(#filter8_f_3892_95663)">
|
||||
<path d="M29.7231 4.99175C30.9455 6.65415 29.3748 9.88535 26.2149 12.2096C23.0549 14.5338 19.5026 15.0707 18.2801 13.4088C17.0576 11.7468 18.6284 8.51514 21.7883 6.19092C24.9482 3.86717 28.5006 3.32982 29.7231 4.99175Z" fill="#749BFF"/>
|
||||
</g>
|
||||
<g filter="url(#filter9_f_3892_95663)">
|
||||
<path d="M19.6891 12.9486C24.5759 8.41581 26.2531 2.27858 23.4354 -0.759249C20.6176 -3.79708 14.3718 -2.58516 9.485 1.94765C4.59823 6.48046 2.92099 12.6177 5.73879 15.6555C8.55658 18.6933 14.8024 17.4814 19.6891 12.9486Z" fill="#FC413D"/>
|
||||
</g>
|
||||
<g filter="url(#filter10_f_3892_95663)">
|
||||
<path d="M9.6712 29.23C12.5757 31.3088 15.9102 31.6247 17.1191 29.9356C18.328 28.2465 16.9535 25.1921 14.049 23.1133C11.1446 21.0345 7.81003 20.7186 6.60113 22.4077C5.39223 24.0968 6.76675 27.1512 9.6712 29.23Z" fill="#FFEE48"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_f_3892_95663" x="-3.44095" y="10.7885" width="18.7217" height="20.4229" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="1.50514" result="effect1_foregroundBlur_3892_95663"/>
|
||||
</filter>
|
||||
<filter id="filter1_f_3892_95663" x="-4.76352" y="-15.6598" width="45.1989" height="45.5524" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="7.2758" result="effect1_foregroundBlur_3892_95663"/>
|
||||
</filter>
|
||||
<filter id="filter2_f_3892_95663" x="-6.61209" y="7.49899" width="41.5757" height="46.522" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="6.18495" result="effect1_foregroundBlur_3892_95663"/>
|
||||
</filter>
|
||||
<filter id="filter3_f_3892_95663" x="-6.61209" y="7.49899" width="41.5757" height="46.522" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="6.18495" result="effect1_foregroundBlur_3892_95663"/>
|
||||
</filter>
|
||||
<filter id="filter4_f_3892_95663" x="-6.21073" y="9.02316" width="41.6959" height="42.4608" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="6.18495" result="effect1_foregroundBlur_3892_95663"/>
|
||||
</filter>
|
||||
<filter id="filter5_f_3892_95663" x="15.405" y="-2.44994" width="39.3423" height="38.7556" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="5.87756" result="effect1_foregroundBlur_3892_95663"/>
|
||||
</filter>
|
||||
<filter id="filter6_f_3892_95663" x="-13.7886" y="-4.15284" width="39.9951" height="40.2639" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="5.32691" result="effect1_foregroundBlur_3892_95663"/>
|
||||
</filter>
|
||||
<filter id="filter7_f_3892_95663" x="6.6925" y="0.620963" width="39.6414" height="39.065" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="4.75678" result="effect1_foregroundBlur_3892_95663"/>
|
||||
</filter>
|
||||
<filter id="filter8_f_3892_95663" x="9.35225" y="-4.48661" width="29.2984" height="27.3739" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="4.25649" result="effect1_foregroundBlur_3892_95663"/>
|
||||
</filter>
|
||||
<filter id="filter9_f_3892_95663" x="-2.81919" y="-9.62339" width="34.8122" height="34.143" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="3.59514" result="effect1_foregroundBlur_3892_95663"/>
|
||||
</filter>
|
||||
<filter id="filter10_f_3892_95663" x="-2.73761" y="12.4221" width="29.1949" height="27.4994" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="4.44986" result="effect1_foregroundBlur_3892_95663"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_3892_95663" x1="13.9595" y1="24.7349" x2="28.5025" y2="12.4738" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#4893FC"/>
|
||||
<stop offset="0.27" stop-color="#4893FC"/>
|
||||
<stop offset="0.777" stop-color="#969DFF"/>
|
||||
<stop offset="1" stop-color="#BD99FE"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 10 KiB |
@ -1,11 +0,0 @@
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="40" height="40" fill="white"/>
|
||||
<g clip-path="url(#clip0_3892_95659)">
|
||||
<path d="M15.745 24.54L26.715 16.35C27.254 15.95 28.022 16.106 28.279 16.73C29.628 20.018 29.025 23.971 26.341 26.685C23.658 29.399 19.924 29.995 16.511 28.639L12.783 30.384C18.13 34.081 24.623 33.166 28.681 29.06C31.9 25.805 32.897 21.368 31.965 17.367L31.973 17.376C30.622 11.498 32.305 9.149 35.755 4.345L36 4L31.46 8.59V8.576L15.743 24.544M13.48 26.531C9.643 22.824 10.305 17.085 13.58 13.776C16 11.327 19.968 10.328 23.432 11.797L27.152 10.06C26.482 9.57 25.622 9.043 24.637 8.673C20.182 6.819 14.848 7.742 11.227 11.401C7.744 14.924 6.648 20.341 8.53 24.962C9.935 28.416 7.631 30.86 5.31 33.326C4.49 34.2 3.666 35.074 3 36L13.478 26.534" fill="black"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_3892_95659">
|
||||
<rect width="33" height="32" fill="white" transform="translate(3 4)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 981 B |
@ -1,17 +0,0 @@
|
||||
<svg width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<g clip-path="url(#clip0_3892_83671)">
|
||||
<path d="M1 13C1 9.27247 1 7.4087 1.60896 5.93853C2.42092 3.97831 3.97831 2.42092 5.93853 1.60896C7.4087 1 9.27247 1 13 1C16.7275 1 18.5913 1 20.0615 1.60896C22.0217 2.42092 23.5791 3.97831 24.391 5.93853C25 7.4087 25 9.27247 25 13C25 16.7275 25 18.5913 24.391 20.0615C23.5791 22.0217 22.0217 23.5791 20.0615 24.391C18.5913 25 16.7275 25 13 25C9.27247 25 7.4087 25 5.93853 24.391C3.97831 23.5791 2.42092 22.0217 1.60896 20.0615C1 18.5913 1 16.7275 1 13Z" fill="white"/>
|
||||
<rect width="24" height="24" transform="translate(1 1)" fill="url(#pattern0_3892_83671)"/>
|
||||
<rect width="24" height="24" transform="translate(1 1)" fill="white" fill-opacity="0.01"/>
|
||||
</g>
|
||||
<path d="M13 0.75C14.8603 0.75 16.2684 0.750313 17.3945 0.827148C18.5228 0.904144 19.3867 1.05876 20.1572 1.37793C22.1787 2.21525 23.7847 3.82133 24.6221 5.84277C24.9412 6.61333 25.0959 7.47723 25.1729 8.60547C25.2497 9.73161 25.25 11.1397 25.25 13C25.25 14.8603 25.2497 16.2684 25.1729 17.3945C25.0959 18.5228 24.9412 19.3867 24.6221 20.1572C23.7847 22.1787 22.1787 23.7847 20.1572 24.6221C19.3867 24.9412 18.5228 25.0959 17.3945 25.1729C16.2684 25.2497 14.8603 25.25 13 25.25C11.1397 25.25 9.73161 25.2497 8.60547 25.1729C7.47723 25.0959 6.61333 24.9412 5.84277 24.6221C3.82133 23.7847 2.21525 22.1787 1.37793 20.1572C1.05876 19.3867 0.904144 18.5228 0.827148 17.3945C0.750313 16.2684 0.75 14.8603 0.75 13C0.75 11.1397 0.750313 9.73161 0.827148 8.60547C0.904144 7.47723 1.05876 6.61333 1.37793 5.84277C2.21525 3.82133 3.82133 2.21525 5.84277 1.37793C6.61333 1.05876 7.47723 0.904144 8.60547 0.827148C9.73161 0.750313 11.1397 0.75 13 0.75Z" stroke="#101828" stroke-opacity="0.08" stroke-width="0.5"/>
|
||||
<defs>
|
||||
<pattern id="pattern0_3892_83671" patternContentUnits="objectBoundingBox" width="1" height="1">
|
||||
<use xlink:href="#image0_3892_83671" transform="scale(0.00625)"/>
|
||||
</pattern>
|
||||
<clipPath id="clip0_3892_83671">
|
||||
<path d="M1 13C1 9.27247 1 7.4087 1.60896 5.93853C2.42092 3.97831 3.97831 2.42092 5.93853 1.60896C7.4087 1 9.27247 1 13 1C16.7275 1 18.5913 1 20.0615 1.60896C22.0217 2.42092 23.5791 3.97831 24.391 5.93853C25 7.4087 25 9.27247 25 13C25 16.7275 25 18.5913 24.391 20.0615C23.5791 22.0217 22.0217 23.5791 20.0615 24.391C18.5913 25 16.7275 25 13 25C9.27247 25 7.4087 25 5.93853 24.391C3.97831 23.5791 2.42092 22.0217 1.60896 20.0615C1 18.5913 1 16.7275 1 13Z" fill="white"/>
|
||||
</clipPath>
|
||||
<image id="image0_3892_83671" width="160" height="160" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAKAAAACgCAYAAACLz2ctAAAACXBIWXMAACxLAAAsSwGlPZapAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAA0ySURBVHgB7Z3vsdM6E8Z93nm/QwdABUAFgQqACgIVABUAFcCpAKgAOoBUwKECOBUAFfj6yYzumNx4pV2vtKtkfzO+3CHESezHWu0frS7GiSEIjPjfEASGhAADU0KAgSkhwMCUEGBgSggwMCUEGJgSAgxMCQEGpoQAA1NCgIEpIcDAlBBgYEoIMDAlBBiYEgIMTAkBBqaEAANTQoCBKSHAwJQQYGBKCDAwJQQYmPL/4cz5/fv38PXr12G32w0/f/4crq6u9n+P/0/cvHlzuH379v7Pe/fuDZvNZv8n/i5Yx8U5LkyH6D5+/Dh8/vx5Lz4pEOGDBw+G58+fhxiljGfEly9fxkkweODUD5wX5w94nIUAf/z4UU14x4SIzwvKOHkBvn79epzmbk3ENz9evHgx/vr1awxoTnYOCCfi2bNnq+Z4a8G8cDLLMT8kOEkBwpN9+PDh3tngMvd4EzgPBC05H3j37t3eUTkknffw3PjsdMDROWnGE+PDhw8sk4s526tXr/YORM5k4vVPnz6NT58+HSeRskwypgJ4/yRG9vsnEe5NOj771DgpAUJ8JTcUAoXovn37Nq7h/fv3zZyb+XeHgE/F4z4ZAUJMJTdwMoXqzgFGJu6IqHHgM/HQ9cxJCBBhj5wA8PraES8HRtXWIsTRc+jnJHLBcDjmqbNDttvtMImv+oT+4uLiL+elFfD079y5M7x582bojrFzMLkfiNEBo1JtMB+zMMHHDjgsPY2GXYdhMOrhyV9iEt8wCXSo+fnSWCNG41TQcOvWrb9eQ0jm+vp6H07CwQ3/dBV/HDsG3uCwMBI8fvx4rAWcmNzIOyzM1eA5c50gzF3fvn3LGmXhLdee82rQrQBh9gbC4ahlhiAgbmoPD4PW9+EUVPQgwm4FSI1+EIk2kkqamhUy+I0lI2LNh1GDLgWIC9rK9MJcUmJfGnlgMmuD61Dy3eCYeC2M6FKA1PxLc8SRVNLUCHTnKIk/IpXnkS4FiCd6yeRpIAmrWAeDS0ToMX3XnQAp87t27icpXIVQvdzYnAjx4HqjOwFCZEsXWDoCpbAKx9ymggZvcyuYWup7e8sddyNA3GiIb8n8Sp9uSVgFE3+vk3p8L2r6gNc84V6AMG/wbHMi4U6yvYRVMGpri5mKkXqbC7oVIFcgKPQshZvFgPi1Y4v4vvOHStuJoa6dlrOmgTsBSlewlVYLU05Mi3le7sGCedcQYm4U9DKFcCVAbjm9xKyUjn7aIxInoK1VaEoJnWMxauJGgDnvTUuAORHUCKtIl4auTaNREYOaxRoczAWIkQEXY434tASILIYmWnWCUrOMa7t0TjwQHjAVIC7QUlhl6aLVFKDWvKhGJwYIWWI2qe/hoUjBtCQfxZypGxUFGgChwHJK8A81WVtOj8JRlMXfv39ffUE8il+nacq+ABdNlUqhliGUXPvamAkQNyp3ISEIiA7igwg9MzkNe+GhAru0ghm/aZqnsbprQYhPnjzZP7zUOpjE3bt3F1/78+fPYM5oADU5TsextQ3U+zRMsARJQPtYuVZpadXhAQeHglqumntvC5oLEBc65xFut9uj8zFPAsT3k3juubgirg9nXjwMdNiGinuepQBzTznEt4QXAR5mMUoOblyxtOJ5fhzzlikB4t9b01SAOdObKyiwFiA+QzI6SeOKGCkli91THxoQI+CMXJVGboSwEiC+FzdWmdJ4Gkjmh8ksexdgMy8YXiLltcEb9LaOdR5W4YQ+0nvRKUEDXBdcnynfzfKWJ9Huu0ZQ5zVnbEQuAV9CyxFQK4tRo4GQZH645prVpIkAcxUopZPzFgKs1U9au2WGNGwzPzysGW5igi8vLxdfg5nwYnphbpFpKM1ipC6mJSDrgHOXBpBzJLM8CVEUpHfTfXVsAOU5ckMTQ8URkHOksvw1DoImXLPsZYFSdQFS5pdbmetBgEtl+dIVddpmkBO2OYswzOQ9Ll4AbnWHpQBL43laAeQ1cEZlaxFWFyCVruJ6YRYClJblS7pZaYuh1JO3rI6uLkDKLHFpLcC1bTY8zA9LC36tPOLqAtRcx9tKgHhoNG+IRIjaZjk3N4TwLRYqVRfgUtJesjSwtgBrt9mQzA/ned215Kp3LBoYVRfg0o+VLIrxWA8ogSNAbbOc89RbZ0fMKqItusn3SsrrIpC9Noidyye37rRvJkDpvmvnTGrKviabggcfGZQlkAVqucFjdQEuPW0a6ahzBZVFqHLBru8SkLqj0nctR8EYAR0BUcDUljA3y9xSMZAbBVvdn+oCXEp4r9n+9Bi73W7onVRgwKmNnK+S41xPnJ8aBakCEk3MTDDQnGtgOWSXW1UdASMbqlyw0U6pswazDCFywPmXaDUPrC5A6inTHrUgQqlJ8gh+D/a4KzXLXAcC92ZJ4K3McHUBbjabxdfw1HIoqV/jLtz2zrzur8Qsf//+feBAibvFKFhdgHjClkZBPGGcHwkBlhZfQtzd7iB5BIgPIoQYKbPMHbVaWqhjNPGCNV1+3IBSkwSSWZaGLLyB345gshaUhWpiQcYGUG3CcEiS7pK8KtJ/mtU5UgaiAKEE7aWWS9exRUPzJiMgZYYB5mtcJJ4inJOWUf5esEyLNgtE51x+qTC4nmLwXyyzVc0EmEv/cAOpc5KnCCF6W9zeA2cxAgJqFEzhkzXAS06eYgixHMu0aFMBYgREl88lYIZfvnw5rAXmGE0tqc86Rm1vGb8Pn+GNJQE2GRnHxuS2khoG3ZVaksZC2uXwKO8vWbJp5QVrb3/GwaRDKtW1c5iFTDTXKFgsl+Q2sbQS4NK5WuyoZNYlH8sWczekxhoNbr89SXd6ye6bVgKkdlRqsUbEdJuGUjFYLtyeC7FkXcaarloWAsTDtXSu0u3P1mC+UY1lKwnNndElzco9CNB6HxEXW3Vx2t566beXRmXOHnDeBEidp1XzIhcClOyjpr2ZoMQs43tzvzs14rcWIPV7W60RNhdgiUdcMhJpIdkmgfvAeBCgVtPQtZhu1QXWRuHXrhA7BBkUpPS0sik4B4LiODxlZ6gyOCQMWn1XcwFSCe/SSPzaFWLHwPkgRCp9SIHvjvdKO5jWBNeLqkbfbrdDK8wFeH19vfgaCi+lK8Q0KjkgInS656akUqkYKnW8geuS65zftLJoNIbawTzNQ/CnZFusNY19pCGaksD5YDgHzKUlERFoibkAKU/s2LZT3LwuN2wjabULT5hz46wEmHuILbbu6mIEPEQSt8ttk5DSZ5xz4pB0T7UQYC7EhIeolec7x7UAc00icfMl+dbDC91i88E5LQVYOqK3Nr0JcwFSPf9KcpHSvC7MET5b0tl+bYFEKwHi95U8WFp72kkwF6BWF32MlhrbV1EmSutG1RYgJy8taRSqibkAqYsp7aKqLcS0KY0G1BJVDQFyphKYF1v0hZ7jOheMv5cgMcvHjtKwCgfKE9UQYOmhXfArxYUAqdDKGgFIwjZJ+NqT8pK4YisBYvsJL7gQIFUUqTFH4ZhlSViFghNX1CokaPlgrcWFAHOtO7QEQZVCaZd3ScrySz+fKqNv9fu0cCFAQI0SmrVph/PDGvO80vDH4chbChU5OCa81lsvcHAjwNxT7WFz5RySsnyJQHIZG4gfUxfPwku4ESCg5mle9rc9hqQsf818bEnkuEY4pwfvthRXAqSyItqmWAPJPE+6++acpc/z5mCU4EqAIFcOb7m16BzNFXUcqPmfRycjhzsBlnh42qviOEjKtTQX2C/FNT1PUSjcCRCUFJ+23umb22Zjbm610FiM5A2XAsTNLlmZppmjpZCUa9X4bpzi3V5wKUCAC1oyx6qxUD3RKqxSArV81aKSWQu3AgS46KUjj+aNl+SQa6a5ci3teoj3LeFagIAjwjQiYq7GDVzjJksKVHFo548P0aig8coF/jM4Jy0l5C61xHJKtO3FgeWGN27c+Ot1LAnFOa+urvYHF6z3rd0OGAvIqeWdPwp3UHLL2Am1WmZIjhp9C4+RS7lZltJr0Y0AE9wGk5qHdlhliZKQT4tNZFrQnQCBJBisddSOt2HumhvpIb5ewy6HdCnARI31H6UmWLt7KGddcg+VQaV0LcAE5mPwBiV9BtccGIXXxiAhJk5BQ48FBxQnIcA5GJkwf+J2XYWA0TgdI5GkkTnej8/OhWPwOh4YiI4zjfBYTq9BF2GYNSC8gh6E6UggRIPwBY5j3a+m9Jt405wU/pmDz0bIR9IPEedDf8GSDbt74+QFuAZp/FGTFrFGS8z7A3omdUvlbvmlQWpw6a2zqjpjUETL0I/XFWw1CAEy0dgPhBJez4UFEmIOKATzwsvLy/0OmJI8cgJzvEePHu3b4lru22tFCFCBVNCw2+3+9boPPd40j0uFEZvNZi++cxTdnBBgYEp4wYEpIcDAlBBgYEoIMDAlBBiYEgIMTAkBBqaEAANTQoCBKSHAwJQQYGBKCDAwJQQYmBICDEwJAQamhAADU0KAgSkhwMCUEGBgSggwMCUEGJgSAgxMCQEGpvwDojzI2oXtJzYAAAAASUVORK5CYII="/>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 7.1 KiB |
@ -1,36 +0,0 @@
|
||||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"width": "40",
|
||||
"height": "40",
|
||||
"viewBox": "0 0 40 40",
|
||||
"fill": "none",
|
||||
"xmlns": "http://www.w3.org/2000/svg"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "rect",
|
||||
"attributes": {
|
||||
"width": "40",
|
||||
"height": "40",
|
||||
"fill": "white"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M25.7926 10.1311H21.5089L29.3208 29.869H33.6045L25.7926 10.1311ZM13.4164 10.1311L5.60449 29.869H9.97273L11.5703 25.724H19.743L21.3405 29.869H25.7087L17.8969 10.1311H13.4164ZM12.9834 22.0583L15.6566 15.1217L18.3299 22.0583H12.9834Z",
|
||||
"fill": "black"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "AnthropicShortLight"
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
import * as React from 'react'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import data from './AnthropicShortLight.json'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'AnthropicShortLight'
|
||||
|
||||
export default Icon
|
||||
@ -1,36 +0,0 @@
|
||||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"width": "40",
|
||||
"height": "40",
|
||||
"viewBox": "0 0 40 40",
|
||||
"fill": "none",
|
||||
"xmlns": "http://www.w3.org/2000/svg"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "rect",
|
||||
"attributes": {
|
||||
"width": "40",
|
||||
"height": "40",
|
||||
"fill": "white"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M36.6676 11.2917C36.3316 11.1277 36.1871 11.4402 35.9906 11.599C35.9242 11.6511 35.8668 11.7188 35.8108 11.7787C35.3199 12.3048 34.747 12.6485 33.9996 12.6068C32.9046 12.5469 31.971 12.8907 31.1455 13.7293C30.9696 12.6954 30.3863 12.0782 29.4996 11.6824C29.0348 11.4766 28.5647 11.2709 28.2406 10.823C28.0127 10.5053 27.9515 10.1511 27.8368 9.80214C27.7652 9.59121 27.6923 9.37506 27.4502 9.33861C27.1871 9.29694 27.0843 9.51829 26.9814 9.70318C26.5674 10.4584 26.4084 11.2917 26.4228 12.1355C26.4592 14.0313 27.26 15.5417 28.8486 16.6173C29.0296 16.7397 29.0764 16.8646 29.0191 17.0443C28.9111 17.4141 28.7822 17.7735 28.6676 18.1433C28.596 18.3803 28.4879 18.4323 28.2354 18.3282C27.363 17.9637 26.609 17.4246 25.9436 16.7709C24.8135 15.6771 23.7914 14.4689 22.5166 13.5235C22.2171 13.3021 21.919 13.0964 21.609 12.9011C20.3082 11.6355 21.7796 10.5964 22.1194 10.474C22.4762 10.3464 22.2431 9.9037 21.092 9.90891C19.9423 9.91413 18.889 10.2995 17.5478 10.8126C17.3512 10.8907 17.1455 10.948 16.9332 10.9922C15.7158 10.7631 14.4515 10.711 13.1298 10.8594C10.6428 11.1381 8.65587 12.3152 7.19493 14.3255C5.44102 16.7397 5.02826 19.4845 5.53347 22.349C6.06473 25.3646 7.60249 27.8646 9.96707 29.8178C12.4176 31.8413 15.2406 32.8334 18.4606 32.6433C20.4163 32.5313 22.5947 32.2683 25.0504 30.1875C25.6702 30.4949 26.3199 30.6173 27.3994 30.711C28.2302 30.7891 29.0296 30.6694 29.6494 30.5417C30.6194 30.3361 30.5518 29.4375 30.2015 29.2709C27.3578 27.9454 27.9814 28.4845 27.4136 28.0495C28.859 26.3361 31.0374 24.5574 31.889 18.797C31.9554 18.3386 31.898 18.0522 31.889 17.6798C31.8838 17.4558 31.9346 17.3673 32.1923 17.3413C32.9046 17.2605 33.596 17.0651 34.2314 16.7137C36.0739 15.7058 36.816 14.0522 36.9918 12.0678C37.0179 11.7657 36.9866 11.4506 36.6676 11.2917ZM20.613 29.1485C17.8564 26.9793 16.5204 26.2657 15.9684 26.297C15.4527 26.3255 15.5452 26.9167 15.6584 27.3022C15.777 27.6823 15.9319 27.9454 16.1494 28.2787C16.2991 28.5001 16.402 28.8307 15.9996 29.0755C15.1116 29.6277 13.5687 28.8907 13.4958 28.8542C11.7001 27.797 10.1988 26.3985 9.14025 24.487C8.11941 22.6459 7.52566 20.6719 7.42801 18.5651C7.40197 18.0547 7.5517 17.875 8.05691 17.7839C8.72227 17.6615 9.40978 17.6355 10.0751 17.7318C12.8876 18.1433 15.2822 19.4037 17.2887 21.3959C18.4346 22.5339 19.3018 23.8907 20.195 25.2162C21.1442 26.6251 22.1663 27.9662 23.4671 29.0651C23.9254 29.4506 24.2926 29.7449 24.6428 29.961C23.5856 30.0782 21.8199 30.1042 20.613 29.1485ZM21.9332 20.6407C21.9332 20.4141 22.1142 20.2345 22.342 20.2345C22.3928 20.2345 22.4398 20.2449 22.4814 20.2605C22.5374 20.2813 22.5895 20.3126 22.6299 20.3594C22.7027 20.4298 22.7444 20.5339 22.7444 20.6407C22.7444 20.8673 22.5635 21.047 22.3368 21.047C22.109 21.047 21.9332 20.8673 21.9332 20.6407ZM26.036 22.7501C25.7731 22.8569 25.51 22.9506 25.2575 22.961C24.8655 22.9793 24.4371 22.8203 24.204 22.6251C23.8434 22.323 23.5856 22.1537 23.4762 21.6225C23.4306 21.3959 23.4567 21.047 23.497 20.8465C23.5908 20.4141 23.4866 20.1381 23.1832 19.8855C22.9346 19.6798 22.6207 19.6251 22.2744 19.6251C22.1455 19.6251 22.027 19.5678 21.9384 19.5209C21.7939 19.4479 21.6754 19.2683 21.7887 19.047C21.8251 18.9766 22.001 18.8022 22.0426 18.7709C22.5114 18.5027 23.053 18.5913 23.5543 18.7918C24.0191 18.9818 24.3694 19.3307 24.8746 19.823C25.3915 20.4194 25.484 20.5861 25.7783 21.0313C26.01 21.3829 26.2223 21.7422 26.3668 22.1537C26.454 22.4089 26.3408 22.6198 26.036 22.7501Z",
|
||||
"fill": "#4D6BFE"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "Deepseek"
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
import * as React from 'react'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import data from './Deepseek.json'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'Deepseek'
|
||||
|
||||
export default Icon
|
||||
@ -1,807 +0,0 @@
|
||||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"width": "40",
|
||||
"height": "40",
|
||||
"viewBox": "0 0 40 40",
|
||||
"fill": "none",
|
||||
"xmlns": "http://www.w3.org/2000/svg"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "rect",
|
||||
"attributes": {
|
||||
"width": "40",
|
||||
"height": "40",
|
||||
"fill": "white"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "mask",
|
||||
"attributes": {
|
||||
"id": "mask0_3892_95663",
|
||||
"style": "mask-type:alpha",
|
||||
"maskUnits": "userSpaceOnUse",
|
||||
"x": "6",
|
||||
"y": "6",
|
||||
"width": "28",
|
||||
"height": "29"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M20 6C20.2936 6 20.5488 6.2005 20.6205 6.48556C20.8393 7.3566 21.1277 8.20866 21.4828 9.03356C22.4116 11.191 23.6854 13.0791 25.3032 14.6968C26.9218 16.3146 28.8095 17.5888 30.9664 18.5172C31.7941 18.8735 32.6436 19.16 33.5149 19.3795C33.6533 19.4143 33.7762 19.4942 33.8641 19.6067C33.9519 19.7192 33.9998 19.8578 34 20.0005C34 20.2941 33.7995 20.5492 33.5149 20.621C32.6437 20.8399 31.7915 21.1282 30.9664 21.4833C28.8095 22.4121 26.9209 23.6859 25.3032 25.3036C23.6854 26.9223 22.4116 28.8099 21.4828 30.9669C21.1278 31.7919 20.8394 32.6439 20.6205 33.5149C20.586 33.6534 20.5062 33.7764 20.3937 33.8644C20.2813 33.9524 20.1427 34.0003 20 34.0005C19.8572 34.0003 19.7186 33.9525 19.6062 33.8645C19.4937 33.7765 19.414 33.6535 19.3795 33.5149C19.1605 32.6439 18.872 31.7918 18.5167 30.9669C17.5884 28.8099 16.3151 26.9214 14.6964 25.3036C13.0782 23.6859 11.1906 22.4121 9.03309 21.4833C8.20814 21.1283 7.35608 20.8399 6.48509 20.621C6.34667 20.5864 6.22377 20.5065 6.13589 20.3941C6.04801 20.2817 6.00018 20.1432 6 20.0005C6.00024 19.8578 6.04808 19.7192 6.13594 19.6067C6.2238 19.4942 6.34667 19.4143 6.48509 19.3795C7.35612 19.1607 8.20819 18.8723 9.03309 18.5172C11.1906 17.5888 13.0786 16.3146 14.6964 14.6968C16.3141 13.0791 17.5884 11.191 18.5167 9.03356C18.8719 8.20862 19.1604 7.35656 19.3795 6.48556C19.4508 6.2005 19.7064 6 20 6Z",
|
||||
"fill": "black"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M20 6C20.2936 6 20.5488 6.2005 20.6205 6.48556C20.8393 7.3566 21.1277 8.20866 21.4828 9.03356C22.4116 11.191 23.6854 13.0791 25.3032 14.6968C26.9218 16.3146 28.8095 17.5888 30.9664 18.5172C31.7941 18.8735 32.6436 19.16 33.5149 19.3795C33.6533 19.4143 33.7762 19.4942 33.8641 19.6067C33.9519 19.7192 33.9998 19.8578 34 20.0005C34 20.2941 33.7995 20.5492 33.5149 20.621C32.6437 20.8399 31.7915 21.1282 30.9664 21.4833C28.8095 22.4121 26.9209 23.6859 25.3032 25.3036C23.6854 26.9223 22.4116 28.8099 21.4828 30.9669C21.1278 31.7919 20.8394 32.6439 20.6205 33.5149C20.586 33.6534 20.5062 33.7764 20.3937 33.8644C20.2813 33.9524 20.1427 34.0003 20 34.0005C19.8572 34.0003 19.7186 33.9525 19.6062 33.8645C19.4937 33.7765 19.414 33.6535 19.3795 33.5149C19.1605 32.6439 18.872 31.7918 18.5167 30.9669C17.5884 28.8099 16.3151 26.9214 14.6964 25.3036C13.0782 23.6859 11.1906 22.4121 9.03309 21.4833C8.20814 21.1283 7.35608 20.8399 6.48509 20.621C6.34667 20.5864 6.22377 20.5065 6.13589 20.3941C6.04801 20.2817 6.00018 20.1432 6 20.0005C6.00024 19.8578 6.04808 19.7192 6.13594 19.6067C6.2238 19.4942 6.34667 19.4143 6.48509 19.3795C7.35612 19.1607 8.20819 18.8723 9.03309 18.5172C11.1906 17.5888 13.0786 16.3146 14.6964 14.6968C16.3141 13.0791 17.5884 11.191 18.5167 9.03356C18.8719 8.20862 19.1604 7.35656 19.3795 6.48556C19.4508 6.2005 19.7064 6 20 6Z",
|
||||
"fill": "url(#paint0_linear_3892_95663)"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "g",
|
||||
"attributes": {
|
||||
"mask": "url(#mask0_3892_95663)"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "g",
|
||||
"attributes": {
|
||||
"filter": "url(#filter0_f_3892_95663)"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M3.47232 27.8921C6.70753 29.0411 10.426 26.8868 11.7778 23.0804C13.1296 19.274 11.6028 15.2569 8.36763 14.108C5.13242 12.959 1.41391 15.1133 0.06211 18.9197C-1.28969 22.7261 0.23711 26.7432 3.47232 27.8921Z",
|
||||
"fill": "#FFE432"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "g",
|
||||
"attributes": {
|
||||
"filter": "url(#filter1_f_3892_95663)"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M17.8359 15.341C22.2806 15.341 25.8838 11.6588 25.8838 7.11644C25.8838 2.57412 22.2806 -1.10815 17.8359 -1.10815C13.3912 -1.10815 9.78809 2.57412 9.78809 7.11644C9.78809 11.6588 13.3912 15.341 17.8359 15.341Z",
|
||||
"fill": "#FC413D"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "g",
|
||||
"attributes": {
|
||||
"filter": "url(#filter2_f_3892_95663)"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M14.7081 41.6431C19.3478 41.4163 22.8707 36.3599 22.5768 30.3493C22.283 24.3387 18.2836 19.65 13.644 19.8769C9.00433 20.1037 5.48139 25.1601 5.77525 31.1707C6.06911 37.1813 10.0685 41.87 14.7081 41.6431Z",
|
||||
"fill": "#00B95C"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "g",
|
||||
"attributes": {
|
||||
"filter": "url(#filter3_f_3892_95663)"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M14.7081 41.6431C19.3478 41.4163 22.8707 36.3599 22.5768 30.3493C22.283 24.3387 18.2836 19.65 13.644 19.8769C9.00433 20.1037 5.48139 25.1601 5.77525 31.1707C6.06911 37.1813 10.0685 41.87 14.7081 41.6431Z",
|
||||
"fill": "#00B95C"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "g",
|
||||
"attributes": {
|
||||
"filter": "url(#filter4_f_3892_95663)"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M19.355 38.0071C23.2447 35.6405 24.2857 30.2506 21.6803 25.9684C19.0748 21.6862 13.8095 20.1334 9.91983 22.5C6.03016 24.8666 4.98909 30.2565 7.59454 34.5387C10.2 38.8209 15.4653 40.3738 19.355 38.0071Z",
|
||||
"fill": "#00B95C"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "g",
|
||||
"attributes": {
|
||||
"filter": "url(#filter5_f_3892_95663)"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M35.0759 24.5504C39.4477 24.5504 42.9917 21.1377 42.9917 16.9278C42.9917 12.7179 39.4477 9.30518 35.0759 9.30518C30.7042 9.30518 27.1602 12.7179 27.1602 16.9278C27.1602 21.1377 30.7042 24.5504 35.0759 24.5504Z",
|
||||
"fill": "#3186FF"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "g",
|
||||
"attributes": {
|
||||
"filter": "url(#filter6_f_3892_95663)"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M0.362818 23.6667C4.3882 26.7279 10.2688 25.7676 13.4976 21.5219C16.7264 17.2762 16.0806 11.3528 12.0552 8.29156C8.02982 5.23037 2.14917 6.19062 -1.07959 10.4364C-4.30835 14.6821 -3.66256 20.6055 0.362818 23.6667Z",
|
||||
"fill": "#FBBC04"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "g",
|
||||
"attributes": {
|
||||
"filter": "url(#filter7_f_3892_95663)"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M20.9877 28.1903C25.7924 31.4936 32.1612 30.5732 35.2128 26.1346C38.2644 21.696 36.8432 15.4199 32.0385 12.1166C27.2338 8.81334 20.865 9.73372 17.8134 14.1723C14.7618 18.611 16.183 24.887 20.9877 28.1903Z",
|
||||
"fill": "#3186FF"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "g",
|
||||
"attributes": {
|
||||
"filter": "url(#filter8_f_3892_95663)"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M29.7231 4.99175C30.9455 6.65415 29.3748 9.88535 26.2149 12.2096C23.0549 14.5338 19.5026 15.0707 18.2801 13.4088C17.0576 11.7468 18.6284 8.51514 21.7883 6.19092C24.9482 3.86717 28.5006 3.32982 29.7231 4.99175Z",
|
||||
"fill": "#749BFF"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "g",
|
||||
"attributes": {
|
||||
"filter": "url(#filter9_f_3892_95663)"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M19.6891 12.9486C24.5759 8.41581 26.2531 2.27858 23.4354 -0.759249C20.6176 -3.79708 14.3718 -2.58516 9.485 1.94765C4.59823 6.48046 2.92099 12.6177 5.73879 15.6555C8.55658 18.6933 14.8024 17.4814 19.6891 12.9486Z",
|
||||
"fill": "#FC413D"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "g",
|
||||
"attributes": {
|
||||
"filter": "url(#filter10_f_3892_95663)"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M9.6712 29.23C12.5757 31.3088 15.9102 31.6247 17.1191 29.9356C18.328 28.2465 16.9535 25.1921 14.049 23.1133C11.1446 21.0345 7.81003 20.7186 6.60113 22.4077C5.39223 24.0968 6.76675 27.1512 9.6712 29.23Z",
|
||||
"fill": "#FFEE48"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "defs",
|
||||
"attributes": {},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "filter",
|
||||
"attributes": {
|
||||
"id": "filter0_f_3892_95663",
|
||||
"x": "-3.44095",
|
||||
"y": "10.7885",
|
||||
"width": "18.7217",
|
||||
"height": "20.4229",
|
||||
"filterUnits": "userSpaceOnUse",
|
||||
"color-interpolation-filters": "sRGB"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feFlood",
|
||||
"attributes": {
|
||||
"flood-opacity": "0",
|
||||
"result": "BackgroundImageFix"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feBlend",
|
||||
"attributes": {
|
||||
"mode": "normal",
|
||||
"in": "SourceGraphic",
|
||||
"in2": "BackgroundImageFix",
|
||||
"result": "shape"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feGaussianBlur",
|
||||
"attributes": {
|
||||
"stdDeviation": "1.50514",
|
||||
"result": "effect1_foregroundBlur_3892_95663"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "filter",
|
||||
"attributes": {
|
||||
"id": "filter1_f_3892_95663",
|
||||
"x": "-4.76352",
|
||||
"y": "-15.6598",
|
||||
"width": "45.1989",
|
||||
"height": "45.5524",
|
||||
"filterUnits": "userSpaceOnUse",
|
||||
"color-interpolation-filters": "sRGB"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feFlood",
|
||||
"attributes": {
|
||||
"flood-opacity": "0",
|
||||
"result": "BackgroundImageFix"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feBlend",
|
||||
"attributes": {
|
||||
"mode": "normal",
|
||||
"in": "SourceGraphic",
|
||||
"in2": "BackgroundImageFix",
|
||||
"result": "shape"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feGaussianBlur",
|
||||
"attributes": {
|
||||
"stdDeviation": "7.2758",
|
||||
"result": "effect1_foregroundBlur_3892_95663"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "filter",
|
||||
"attributes": {
|
||||
"id": "filter2_f_3892_95663",
|
||||
"x": "-6.61209",
|
||||
"y": "7.49899",
|
||||
"width": "41.5757",
|
||||
"height": "46.522",
|
||||
"filterUnits": "userSpaceOnUse",
|
||||
"color-interpolation-filters": "sRGB"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feFlood",
|
||||
"attributes": {
|
||||
"flood-opacity": "0",
|
||||
"result": "BackgroundImageFix"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feBlend",
|
||||
"attributes": {
|
||||
"mode": "normal",
|
||||
"in": "SourceGraphic",
|
||||
"in2": "BackgroundImageFix",
|
||||
"result": "shape"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feGaussianBlur",
|
||||
"attributes": {
|
||||
"stdDeviation": "6.18495",
|
||||
"result": "effect1_foregroundBlur_3892_95663"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "filter",
|
||||
"attributes": {
|
||||
"id": "filter3_f_3892_95663",
|
||||
"x": "-6.61209",
|
||||
"y": "7.49899",
|
||||
"width": "41.5757",
|
||||
"height": "46.522",
|
||||
"filterUnits": "userSpaceOnUse",
|
||||
"color-interpolation-filters": "sRGB"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feFlood",
|
||||
"attributes": {
|
||||
"flood-opacity": "0",
|
||||
"result": "BackgroundImageFix"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feBlend",
|
||||
"attributes": {
|
||||
"mode": "normal",
|
||||
"in": "SourceGraphic",
|
||||
"in2": "BackgroundImageFix",
|
||||
"result": "shape"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feGaussianBlur",
|
||||
"attributes": {
|
||||
"stdDeviation": "6.18495",
|
||||
"result": "effect1_foregroundBlur_3892_95663"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "filter",
|
||||
"attributes": {
|
||||
"id": "filter4_f_3892_95663",
|
||||
"x": "-6.21073",
|
||||
"y": "9.02316",
|
||||
"width": "41.6959",
|
||||
"height": "42.4608",
|
||||
"filterUnits": "userSpaceOnUse",
|
||||
"color-interpolation-filters": "sRGB"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feFlood",
|
||||
"attributes": {
|
||||
"flood-opacity": "0",
|
||||
"result": "BackgroundImageFix"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feBlend",
|
||||
"attributes": {
|
||||
"mode": "normal",
|
||||
"in": "SourceGraphic",
|
||||
"in2": "BackgroundImageFix",
|
||||
"result": "shape"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feGaussianBlur",
|
||||
"attributes": {
|
||||
"stdDeviation": "6.18495",
|
||||
"result": "effect1_foregroundBlur_3892_95663"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "filter",
|
||||
"attributes": {
|
||||
"id": "filter5_f_3892_95663",
|
||||
"x": "15.405",
|
||||
"y": "-2.44994",
|
||||
"width": "39.3423",
|
||||
"height": "38.7556",
|
||||
"filterUnits": "userSpaceOnUse",
|
||||
"color-interpolation-filters": "sRGB"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feFlood",
|
||||
"attributes": {
|
||||
"flood-opacity": "0",
|
||||
"result": "BackgroundImageFix"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feBlend",
|
||||
"attributes": {
|
||||
"mode": "normal",
|
||||
"in": "SourceGraphic",
|
||||
"in2": "BackgroundImageFix",
|
||||
"result": "shape"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feGaussianBlur",
|
||||
"attributes": {
|
||||
"stdDeviation": "5.87756",
|
||||
"result": "effect1_foregroundBlur_3892_95663"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "filter",
|
||||
"attributes": {
|
||||
"id": "filter6_f_3892_95663",
|
||||
"x": "-13.7886",
|
||||
"y": "-4.15284",
|
||||
"width": "39.9951",
|
||||
"height": "40.2639",
|
||||
"filterUnits": "userSpaceOnUse",
|
||||
"color-interpolation-filters": "sRGB"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feFlood",
|
||||
"attributes": {
|
||||
"flood-opacity": "0",
|
||||
"result": "BackgroundImageFix"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feBlend",
|
||||
"attributes": {
|
||||
"mode": "normal",
|
||||
"in": "SourceGraphic",
|
||||
"in2": "BackgroundImageFix",
|
||||
"result": "shape"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feGaussianBlur",
|
||||
"attributes": {
|
||||
"stdDeviation": "5.32691",
|
||||
"result": "effect1_foregroundBlur_3892_95663"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "filter",
|
||||
"attributes": {
|
||||
"id": "filter7_f_3892_95663",
|
||||
"x": "6.6925",
|
||||
"y": "0.620963",
|
||||
"width": "39.6414",
|
||||
"height": "39.065",
|
||||
"filterUnits": "userSpaceOnUse",
|
||||
"color-interpolation-filters": "sRGB"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feFlood",
|
||||
"attributes": {
|
||||
"flood-opacity": "0",
|
||||
"result": "BackgroundImageFix"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feBlend",
|
||||
"attributes": {
|
||||
"mode": "normal",
|
||||
"in": "SourceGraphic",
|
||||
"in2": "BackgroundImageFix",
|
||||
"result": "shape"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feGaussianBlur",
|
||||
"attributes": {
|
||||
"stdDeviation": "4.75678",
|
||||
"result": "effect1_foregroundBlur_3892_95663"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "filter",
|
||||
"attributes": {
|
||||
"id": "filter8_f_3892_95663",
|
||||
"x": "9.35225",
|
||||
"y": "-4.48661",
|
||||
"width": "29.2984",
|
||||
"height": "27.3739",
|
||||
"filterUnits": "userSpaceOnUse",
|
||||
"color-interpolation-filters": "sRGB"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feFlood",
|
||||
"attributes": {
|
||||
"flood-opacity": "0",
|
||||
"result": "BackgroundImageFix"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feBlend",
|
||||
"attributes": {
|
||||
"mode": "normal",
|
||||
"in": "SourceGraphic",
|
||||
"in2": "BackgroundImageFix",
|
||||
"result": "shape"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feGaussianBlur",
|
||||
"attributes": {
|
||||
"stdDeviation": "4.25649",
|
||||
"result": "effect1_foregroundBlur_3892_95663"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "filter",
|
||||
"attributes": {
|
||||
"id": "filter9_f_3892_95663",
|
||||
"x": "-2.81919",
|
||||
"y": "-9.62339",
|
||||
"width": "34.8122",
|
||||
"height": "34.143",
|
||||
"filterUnits": "userSpaceOnUse",
|
||||
"color-interpolation-filters": "sRGB"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feFlood",
|
||||
"attributes": {
|
||||
"flood-opacity": "0",
|
||||
"result": "BackgroundImageFix"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feBlend",
|
||||
"attributes": {
|
||||
"mode": "normal",
|
||||
"in": "SourceGraphic",
|
||||
"in2": "BackgroundImageFix",
|
||||
"result": "shape"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feGaussianBlur",
|
||||
"attributes": {
|
||||
"stdDeviation": "3.59514",
|
||||
"result": "effect1_foregroundBlur_3892_95663"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "filter",
|
||||
"attributes": {
|
||||
"id": "filter10_f_3892_95663",
|
||||
"x": "-2.73761",
|
||||
"y": "12.4221",
|
||||
"width": "29.1949",
|
||||
"height": "27.4994",
|
||||
"filterUnits": "userSpaceOnUse",
|
||||
"color-interpolation-filters": "sRGB"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feFlood",
|
||||
"attributes": {
|
||||
"flood-opacity": "0",
|
||||
"result": "BackgroundImageFix"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feBlend",
|
||||
"attributes": {
|
||||
"mode": "normal",
|
||||
"in": "SourceGraphic",
|
||||
"in2": "BackgroundImageFix",
|
||||
"result": "shape"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feGaussianBlur",
|
||||
"attributes": {
|
||||
"stdDeviation": "4.44986",
|
||||
"result": "effect1_foregroundBlur_3892_95663"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "linearGradient",
|
||||
"attributes": {
|
||||
"id": "paint0_linear_3892_95663",
|
||||
"x1": "13.9595",
|
||||
"y1": "24.7349",
|
||||
"x2": "28.5025",
|
||||
"y2": "12.4738",
|
||||
"gradientUnits": "userSpaceOnUse"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "stop",
|
||||
"attributes": {
|
||||
"stop-color": "#4893FC"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "stop",
|
||||
"attributes": {
|
||||
"offset": "0.27",
|
||||
"stop-color": "#4893FC"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "stop",
|
||||
"attributes": {
|
||||
"offset": "0.777",
|
||||
"stop-color": "#969DFF"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "stop",
|
||||
"attributes": {
|
||||
"offset": "1",
|
||||
"stop-color": "#BD99FE"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "Gemini"
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
import * as React from 'react'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import data from './Gemini.json'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'Gemini'
|
||||
|
||||
export default Icon
|
||||
@ -1,72 +0,0 @@
|
||||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"width": "40",
|
||||
"height": "40",
|
||||
"viewBox": "0 0 40 40",
|
||||
"fill": "none",
|
||||
"xmlns": "http://www.w3.org/2000/svg"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "rect",
|
||||
"attributes": {
|
||||
"width": "40",
|
||||
"height": "40",
|
||||
"fill": "white"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "g",
|
||||
"attributes": {
|
||||
"clip-path": "url(#clip0_3892_95659)"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M15.745 24.54L26.715 16.35C27.254 15.95 28.022 16.106 28.279 16.73C29.628 20.018 29.025 23.971 26.341 26.685C23.658 29.399 19.924 29.995 16.511 28.639L12.783 30.384C18.13 34.081 24.623 33.166 28.681 29.06C31.9 25.805 32.897 21.368 31.965 17.367L31.973 17.376C30.622 11.498 32.305 9.149 35.755 4.345L36 4L31.46 8.59V8.576L15.743 24.544M13.48 26.531C9.643 22.824 10.305 17.085 13.58 13.776C16 11.327 19.968 10.328 23.432 11.797L27.152 10.06C26.482 9.57 25.622 9.043 24.637 8.673C20.182 6.819 14.848 7.742 11.227 11.401C7.744 14.924 6.648 20.341 8.53 24.962C9.935 28.416 7.631 30.86 5.31 33.326C4.49 34.2 3.666 35.074 3 36L13.478 26.534",
|
||||
"fill": "black"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "defs",
|
||||
"attributes": {},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "clipPath",
|
||||
"attributes": {
|
||||
"id": "clip0_3892_95659"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "rect",
|
||||
"attributes": {
|
||||
"width": "33",
|
||||
"height": "32",
|
||||
"fill": "white",
|
||||
"transform": "translate(3 4)"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "Grok"
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
import * as React from 'react'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import data from './Grok.json'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'Grok'
|
||||
|
||||
export default Icon
|
||||
File diff suppressed because one or more lines are too long
@ -1,20 +0,0 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
import * as React from 'react'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import data from './OpenaiBlue.json'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'OpenaiBlue'
|
||||
|
||||
export default Icon
|
||||
@ -1,128 +0,0 @@
|
||||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"width": "26",
|
||||
"height": "26",
|
||||
"viewBox": "0 0 26 26",
|
||||
"fill": "none",
|
||||
"xmlns": "http://www.w3.org/2000/svg",
|
||||
"xmlns:xlink": "http://www.w3.org/1999/xlink"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "g",
|
||||
"attributes": {
|
||||
"clip-path": "url(#clip0_3892_83671)"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M1 13C1 9.27247 1 7.4087 1.60896 5.93853C2.42092 3.97831 3.97831 2.42092 5.93853 1.60896C7.4087 1 9.27247 1 13 1C16.7275 1 18.5913 1 20.0615 1.60896C22.0217 2.42092 23.5791 3.97831 24.391 5.93853C25 7.4087 25 9.27247 25 13C25 16.7275 25 18.5913 24.391 20.0615C23.5791 22.0217 22.0217 23.5791 20.0615 24.391C18.5913 25 16.7275 25 13 25C9.27247 25 7.4087 25 5.93853 24.391C3.97831 23.5791 2.42092 22.0217 1.60896 20.0615C1 18.5913 1 16.7275 1 13Z",
|
||||
"fill": "white"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "rect",
|
||||
"attributes": {
|
||||
"width": "24",
|
||||
"height": "24",
|
||||
"transform": "translate(1 1)",
|
||||
"fill": "url(#pattern0_3892_83671)"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "rect",
|
||||
"attributes": {
|
||||
"width": "24",
|
||||
"height": "24",
|
||||
"transform": "translate(1 1)",
|
||||
"fill": "white",
|
||||
"fill-opacity": "0.01"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M13 0.75C14.8603 0.75 16.2684 0.750313 17.3945 0.827148C18.5228 0.904144 19.3867 1.05876 20.1572 1.37793C22.1787 2.21525 23.7847 3.82133 24.6221 5.84277C24.9412 6.61333 25.0959 7.47723 25.1729 8.60547C25.2497 9.73161 25.25 11.1397 25.25 13C25.25 14.8603 25.2497 16.2684 25.1729 17.3945C25.0959 18.5228 24.9412 19.3867 24.6221 20.1572C23.7847 22.1787 22.1787 23.7847 20.1572 24.6221C19.3867 24.9412 18.5228 25.0959 17.3945 25.1729C16.2684 25.2497 14.8603 25.25 13 25.25C11.1397 25.25 9.73161 25.2497 8.60547 25.1729C7.47723 25.0959 6.61333 24.9412 5.84277 24.6221C3.82133 23.7847 2.21525 22.1787 1.37793 20.1572C1.05876 19.3867 0.904144 18.5228 0.827148 17.3945C0.750313 16.2684 0.75 14.8603 0.75 13C0.75 11.1397 0.750313 9.73161 0.827148 8.60547C0.904144 7.47723 1.05876 6.61333 1.37793 5.84277C2.21525 3.82133 3.82133 2.21525 5.84277 1.37793C6.61333 1.05876 7.47723 0.904144 8.60547 0.827148C9.73161 0.750313 11.1397 0.75 13 0.75Z",
|
||||
"stroke": "#101828",
|
||||
"stroke-opacity": "0.08",
|
||||
"stroke-width": "0.5"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "defs",
|
||||
"attributes": {},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "pattern",
|
||||
"attributes": {
|
||||
"id": "pattern0_3892_83671",
|
||||
"patternContentUnits": "objectBoundingBox",
|
||||
"width": "1",
|
||||
"height": "1"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "use",
|
||||
"attributes": {
|
||||
"xlink:href": "#image0_3892_83671",
|
||||
"transform": "scale(0.00625)"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "clipPath",
|
||||
"attributes": {
|
||||
"id": "clip0_3892_83671"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M1 13C1 9.27247 1 7.4087 1.60896 5.93853C2.42092 3.97831 3.97831 2.42092 5.93853 1.60896C7.4087 1 9.27247 1 13 1C16.7275 1 18.5913 1 20.0615 1.60896C22.0217 2.42092 23.5791 3.97831 24.391 5.93853C25 7.4087 25 9.27247 25 13C25 16.7275 25 18.5913 24.391 20.0615C23.5791 22.0217 22.0217 23.5791 20.0615 24.391C18.5913 25 16.7275 25 13 25C9.27247 25 7.4087 25 5.93853 24.391C3.97831 23.5791 2.42092 22.0217 1.60896 20.0615C1 18.5913 1 16.7275 1 13Z",
|
||||
"fill": "white"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "image",
|
||||
"attributes": {
|
||||
"id": "image0_3892_83671",
|
||||
"width": "160",
|
||||
"height": "160",
|
||||
"preserveAspectRatio": "none",
|
||||
"xlink:href": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAKAAAACgCAYAAACLz2ctAAAACXBIWXMAACxLAAAsSwGlPZapAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAA0ySURBVHgB7Z3vsdM6E8Z93nm/QwdABUAFgQqACgIVABUAFcCpAKgAOoBUwKECOBUAFfj6yYzumNx4pV2vtKtkfzO+3CHESezHWu0frS7GiSEIjPjfEASGhAADU0KAgSkhwMCUEGBgSggwMCUEGJgSAgxMCQEGpoQAA1NCgIEpIcDAlBBgYEoIMDAlBBiYEgIMTAkBBqaEAANTQoCBKSHAwJQQYGBKCDAwJQQYmPL/4cz5/fv38PXr12G32w0/f/4crq6u9n+P/0/cvHlzuH379v7Pe/fuDZvNZv8n/i5Yx8U5LkyH6D5+/Dh8/vx5Lz4pEOGDBw+G58+fhxiljGfEly9fxkkweODUD5wX5w94nIUAf/z4UU14x4SIzwvKOHkBvn79epzmbk3ENz9evHgx/vr1awxoTnYOCCfi2bNnq+Z4a8G8cDLLMT8kOEkBwpN9+PDh3tngMvd4EzgPBC05H3j37t3eUTkknffw3PjsdMDROWnGE+PDhw8sk4s526tXr/YORM5k4vVPnz6NT58+HSeRskwypgJ4/yRG9vsnEe5NOj771DgpAUJ8JTcUAoXovn37Nq7h/fv3zZyb+XeHgE/F4z4ZAUJMJTdwMoXqzgFGJu6IqHHgM/HQ9cxJCBBhj5wA8PraES8HRtXWIsTRc+jnJHLBcDjmqbNDttvtMImv+oT+4uLiL+elFfD079y5M7x582bojrFzMLkfiNEBo1JtMB+zMMHHDjgsPY2GXYdhMOrhyV9iEt8wCXSo+fnSWCNG41TQcOvWrb9eQ0jm+vp6H07CwQ3/dBV/HDsG3uCwMBI8fvx4rAWcmNzIOyzM1eA5c50gzF3fvn3LGmXhLdee82rQrQBh9gbC4ahlhiAgbmoPD4PW9+EUVPQgwm4FSI1+EIk2kkqamhUy+I0lI2LNh1GDLgWIC9rK9MJcUmJfGnlgMmuD61Dy3eCYeC2M6FKA1PxLc8SRVNLUCHTnKIk/IpXnkS4FiCd6yeRpIAmrWAeDS0ToMX3XnQAp87t27icpXIVQvdzYnAjx4HqjOwFCZEsXWDoCpbAKx9ymggZvcyuYWup7e8sddyNA3GiIb8n8Sp9uSVgFE3+vk3p8L2r6gNc84V6AMG/wbHMi4U6yvYRVMGpri5mKkXqbC7oVIFcgKPQshZvFgPi1Y4v4vvOHStuJoa6dlrOmgTsBSlewlVYLU05Mi3le7sGCedcQYm4U9DKFcCVAbjm9xKyUjn7aIxInoK1VaEoJnWMxauJGgDnvTUuAORHUCKtIl4auTaNREYOaxRoczAWIkQEXY434tASILIYmWnWCUrOMa7t0TjwQHjAVIC7QUlhl6aLVFKDWvKhGJwYIWWI2qe/hoUjBtCQfxZypGxUFGgChwHJK8A81WVtOj8JRlMXfv39ffUE8il+nacq+ABdNlUqhliGUXPvamAkQNyp3ISEIiA7igwg9MzkNe+GhAru0ghm/aZqnsbprQYhPnjzZP7zUOpjE3bt3F1/78+fPYM5oADU5TsextQ3U+zRMsARJQPtYuVZpadXhAQeHglqumntvC5oLEBc65xFut9uj8zFPAsT3k3juubgirg9nXjwMdNiGinuepQBzTznEt4QXAR5mMUoOblyxtOJ5fhzzlikB4t9b01SAOdObKyiwFiA+QzI6SeOKGCkli91THxoQI+CMXJVGboSwEiC+FzdWmdJ4Gkjmh8ksexdgMy8YXiLltcEb9LaOdR5W4YQ+0nvRKUEDXBdcnynfzfKWJ9Huu0ZQ5zVnbEQuAV9CyxFQK4tRo4GQZH645prVpIkAcxUopZPzFgKs1U9au2WGNGwzPzysGW5igi8vLxdfg5nwYnphbpFpKM1ipC6mJSDrgHOXBpBzJLM8CVEUpHfTfXVsAOU5ckMTQ8URkHOksvw1DoImXLPsZYFSdQFS5pdbmetBgEtl+dIVddpmkBO2OYswzOQ9Ll4AbnWHpQBL43laAeQ1cEZlaxFWFyCVruJ6YRYClJblS7pZaYuh1JO3rI6uLkDKLHFpLcC1bTY8zA9LC36tPOLqAtRcx9tKgHhoNG+IRIjaZjk3N4TwLRYqVRfgUtJesjSwtgBrt9mQzA/ned215Kp3LBoYVRfg0o+VLIrxWA8ogSNAbbOc89RbZ0fMKqItusn3SsrrIpC9Noidyye37rRvJkDpvmvnTGrKviabggcfGZQlkAVqucFjdQEuPW0a6ahzBZVFqHLBru8SkLqj0nctR8EYAR0BUcDUljA3y9xSMZAbBVvdn+oCXEp4r9n+9Bi73W7onVRgwKmNnK+S41xPnJ8aBakCEk3MTDDQnGtgOWSXW1UdASMbqlyw0U6pswazDCFywPmXaDUPrC5A6inTHrUgQqlJ8gh+D/a4KzXLXAcC92ZJ4K3McHUBbjabxdfw1HIoqV/jLtz2zrzur8Qsf//+feBAibvFKFhdgHjClkZBPGGcHwkBlhZfQtzd7iB5BIgPIoQYKbPMHbVaWqhjNPGCNV1+3IBSkwSSWZaGLLyB345gshaUhWpiQcYGUG3CcEiS7pK8KtJ/mtU5UgaiAKEE7aWWS9exRUPzJiMgZYYB5mtcJJ4inJOWUf5esEyLNgtE51x+qTC4nmLwXyyzVc0EmEv/cAOpc5KnCCF6W9zeA2cxAgJqFEzhkzXAS06eYgixHMu0aFMBYgREl88lYIZfvnw5rAXmGE0tqc86Rm1vGb8Pn+GNJQE2GRnHxuS2khoG3ZVaksZC2uXwKO8vWbJp5QVrb3/GwaRDKtW1c5iFTDTXKFgsl+Q2sbQS4NK5WuyoZNYlH8sWczekxhoNbr89SXd6ye6bVgKkdlRqsUbEdJuGUjFYLtyeC7FkXcaarloWAsTDtXSu0u3P1mC+UY1lKwnNndElzco9CNB6HxEXW3Vx2t566beXRmXOHnDeBEidp1XzIhcClOyjpr2ZoMQs43tzvzs14rcWIPV7W60RNhdgiUdcMhJpIdkmgfvAeBCgVtPQtZhu1QXWRuHXrhA7BBkUpPS0sik4B4LiODxlZ6gyOCQMWn1XcwFSCe/SSPzaFWLHwPkgRCp9SIHvjvdKO5jWBNeLqkbfbrdDK8wFeH19vfgaCi+lK8Q0KjkgInS656akUqkYKnW8geuS65zftLJoNIbawTzNQ/CnZFusNY19pCGaksD5YDgHzKUlERFoibkAKU/s2LZT3LwuN2wjabULT5hz46wEmHuILbbu6mIEPEQSt8ttk5DSZ5xz4pB0T7UQYC7EhIeolec7x7UAc00icfMl+dbDC91i88E5LQVYOqK3Nr0JcwFSPf9KcpHSvC7MET5b0tl+bYFEKwHi95U8WFp72kkwF6BWF32MlhrbV1EmSutG1RYgJy8taRSqibkAqYsp7aKqLcS0KY0G1BJVDQFyphKYF1v0hZ7jOheMv5cgMcvHjtKwCgfKE9UQYOmhXfArxYUAqdDKGgFIwjZJ+NqT8pK4YisBYvsJL7gQIFUUqTFH4ZhlSViFghNX1CokaPlgrcWFAHOtO7QEQZVCaZd3ScrySz+fKqNv9fu0cCFAQI0SmrVph/PDGvO80vDH4chbChU5OCa81lsvcHAjwNxT7WFz5RySsnyJQHIZG4gfUxfPwku4ESCg5mle9rc9hqQsf818bEnkuEY4pwfvthRXAqSyItqmWAPJPE+6++acpc/z5mCU4EqAIFcOb7m16BzNFXUcqPmfRycjhzsBlnh42qviOEjKtTQX2C/FNT1PUSjcCRCUFJ+23umb22Zjbm610FiM5A2XAsTNLlmZppmjpZCUa9X4bpzi3V5wKUCAC1oyx6qxUD3RKqxSArV81aKSWQu3AgS46KUjj+aNl+SQa6a5ci3teoj3LeFagIAjwjQiYq7GDVzjJksKVHFo548P0aig8coF/jM4Jy0l5C61xHJKtO3FgeWGN27c+Ot1LAnFOa+urvYHF6z3rd0OGAvIqeWdPwp3UHLL2Am1WmZIjhp9C4+RS7lZltJr0Y0AE9wGk5qHdlhliZKQT4tNZFrQnQCBJBisddSOt2HumhvpIb5ewy6HdCnARI31H6UmWLt7KGddcg+VQaV0LcAE5mPwBiV9BtccGIXXxiAhJk5BQ48FBxQnIcA5GJkwf+J2XYWA0TgdI5GkkTnej8/OhWPwOh4YiI4zjfBYTq9BF2GYNSC8gh6E6UggRIPwBY5j3a+m9Jt405wU/pmDz0bIR9IPEedDf8GSDbt74+QFuAZp/FGTFrFGS8z7A3omdUvlbvmlQWpw6a2zqjpjUETL0I/XFWw1CAEy0dgPhBJez4UFEmIOKATzwsvLy/0OmJI8cgJzvEePHu3b4lru22tFCFCBVNCw2+3+9boPPd40j0uFEZvNZi++cxTdnBBgYEp4wYEpIcDAlBBgYEoIMDAlBBiYEgIMTAkBBqaEAANTQoCBKSHAwJQQYGBKCDAwJQQYmBICDEwJAQamhAADU0KAgSkhwMCUEGBgSggwMCUEGJgSAgxMCQEGpvwDojzI2oXtJzYAAAAASUVORK5CYII="
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "OpenaiSmall"
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
import * as React from 'react'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import data from './OpenaiSmall.json'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'OpenaiSmall'
|
||||
|
||||
export default Icon
|
||||
File diff suppressed because one or more lines are too long
@ -1,20 +0,0 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
import * as React from 'react'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import data from './OpenaiTeal.json'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'OpenaiTeal'
|
||||
|
||||
export default Icon
|
||||
File diff suppressed because one or more lines are too long
@ -1,20 +0,0 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
import * as React from 'react'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import data from './OpenaiViolet.json'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'OpenaiViolet'
|
||||
|
||||
export default Icon
|
||||
@ -1,128 +0,0 @@
|
||||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"width": "25",
|
||||
"height": "25",
|
||||
"viewBox": "0 0 25 25",
|
||||
"fill": "none",
|
||||
"xmlns": "http://www.w3.org/2000/svg",
|
||||
"xmlns:xlink": "http://www.w3.org/1999/xlink"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "g",
|
||||
"attributes": {
|
||||
"clip-path": "url(#clip0_6305_73327)"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M0.5 12.5C0.5 8.77247 0.5 6.9087 1.10896 5.43853C1.92092 3.47831 3.47831 1.92092 5.43853 1.10896C6.9087 0.5 8.77247 0.5 12.5 0.5C16.2275 0.5 18.0913 0.5 19.5615 1.10896C21.5217 1.92092 23.0791 3.47831 23.891 5.43853C24.5 6.9087 24.5 8.77247 24.5 12.5C24.5 16.2275 24.5 18.0913 23.891 19.5615C23.0791 21.5217 21.5217 23.0791 19.5615 23.891C18.0913 24.5 16.2275 24.5 12.5 24.5C8.77247 24.5 6.9087 24.5 5.43853 23.891C3.47831 23.0791 1.92092 21.5217 1.10896 19.5615C0.5 18.0913 0.5 16.2275 0.5 12.5Z",
|
||||
"fill": "white"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "rect",
|
||||
"attributes": {
|
||||
"width": "24",
|
||||
"height": "24",
|
||||
"transform": "translate(0.5 0.5)",
|
||||
"fill": "url(#pattern0_6305_73327)"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "rect",
|
||||
"attributes": {
|
||||
"width": "24",
|
||||
"height": "24",
|
||||
"transform": "translate(0.5 0.5)",
|
||||
"fill": "white",
|
||||
"fill-opacity": "0.01"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M12.5 0.25C14.3603 0.25 15.7684 0.250313 16.8945 0.327148C18.0228 0.404144 18.8867 0.558755 19.6572 0.87793C21.6787 1.71525 23.2847 3.32133 24.1221 5.34277C24.4412 6.11333 24.5959 6.97723 24.6729 8.10547C24.7497 9.23161 24.75 10.6397 24.75 12.5C24.75 14.3603 24.7497 15.7684 24.6729 16.8945C24.5959 18.0228 24.4412 18.8867 24.1221 19.6572C23.2847 21.6787 21.6787 23.2847 19.6572 24.1221C18.8867 24.4412 18.0228 24.5959 16.8945 24.6729C15.7684 24.7497 14.3603 24.75 12.5 24.75C10.6397 24.75 9.23161 24.7497 8.10547 24.6729C6.97723 24.5959 6.11333 24.4412 5.34277 24.1221C3.32133 23.2847 1.71525 21.6787 0.87793 19.6572C0.558755 18.8867 0.404144 18.0228 0.327148 16.8945C0.250313 15.7684 0.25 14.3603 0.25 12.5C0.25 10.6397 0.250313 9.23161 0.327148 8.10547C0.404144 6.97723 0.558755 6.11333 0.87793 5.34277C1.71525 3.32133 3.32133 1.71525 5.34277 0.87793C6.11333 0.558755 6.97723 0.404144 8.10547 0.327148C9.23161 0.250313 10.6397 0.25 12.5 0.25Z",
|
||||
"stroke": "#101828",
|
||||
"stroke-opacity": "0.08",
|
||||
"stroke-width": "0.5"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "defs",
|
||||
"attributes": {},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "pattern",
|
||||
"attributes": {
|
||||
"id": "pattern0_6305_73327",
|
||||
"patternContentUnits": "objectBoundingBox",
|
||||
"width": "1",
|
||||
"height": "1"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "use",
|
||||
"attributes": {
|
||||
"xlink:href": "#image0_6305_73327",
|
||||
"transform": "scale(0.00625)"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "clipPath",
|
||||
"attributes": {
|
||||
"id": "clip0_6305_73327"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M0.5 12.5C0.5 8.77247 0.5 6.9087 1.10896 5.43853C1.92092 3.47831 3.47831 1.92092 5.43853 1.10896C6.9087 0.5 8.77247 0.5 12.5 0.5C16.2275 0.5 18.0913 0.5 19.5615 1.10896C21.5217 1.92092 23.0791 3.47831 23.891 5.43853C24.5 6.9087 24.5 8.77247 24.5 12.5C24.5 16.2275 24.5 18.0913 23.891 19.5615C23.0791 21.5217 21.5217 23.0791 19.5615 23.891C18.0913 24.5 16.2275 24.5 12.5 24.5C8.77247 24.5 6.9087 24.5 5.43853 23.891C3.47831 23.0791 1.92092 21.5217 1.10896 19.5615C0.5 18.0913 0.5 16.2275 0.5 12.5Z",
|
||||
"fill": "white"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "image",
|
||||
"attributes": {
|
||||
"id": "image0_6305_73327",
|
||||
"width": "160",
|
||||
"height": "160",
|
||||
"preserveAspectRatio": "none",
|
||||
"xlink:href": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAKAAAACgCAYAAACLz2ctAAAACXBIWXMAACxLAAAsSwGlPZapAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAm6SURBVHgB7Z09jFVFGIZn/Wf9I5FSKIVSKYndErolVJJQQUKliRWFNhY2UGxFItUmUJFARZaOQGekA0poF8slUcFFRVnPu9cDw/HuPWdmvpnvu3PeJ9m4MQH23H3P/DzzzczCVoMjRInXHCGKMIBEFQaQqMIAElUYQKIKA0hUYQCJKgwgUYUBJKowgEQVBpCowgASVRhAogoDSFRhAIkqDCBRhQEkqjCARBUGkKjCABJVGECiCgNIVGEAiSpvuJGyubnlLq0+cQ/u/739fSyLiwvu8JF33PKxXY6EM9oW8Mrl393dO8+Swgfw59euPXU//finI+GMMoCPNp43gflr+3u0YBJcubzZhPG5I2GMMoA/nH/84vvjJxbd/gPpIxG0hDdvsBUMZXQBRFf5cP2f7e8Pff729tfysUUnwa0bf7iNDbaCIYwugNeb8VpLO3FAC4ggpoJW8GoztiTDGVUAEb62hULg9ux5+fjoiiXGg5jYYGZNhjGaAGLicbPpIsFHTfC62gThW2p0igTXr206MozRBHDt2uYL5XK0CZ/f+rXA5037/6GgBaSWGcYoAuhrF7R+O4330Ap+cUJmQkItM4xRBNDXLkd7Viw+O/gWtUxBqg/gNO3SB7VMOaoP4DTt0gdaQIkJCbVMP1UXI8zSLn2gq77dtJ6pa8XQMheaIcCuxfLvOiZVe/e97ixTbQD7tEsfrZbxW9BYEEINMPw4880HImPaXFTbBQ/RLn1IaRlNLq4+cZapMoBDtUsfUpUymuCzWBNoxXNRZQBDtMss/DHkPIPZuFUnWV0AY7TLNCataB0eD0OR60ZbweoCGKNdpoExZE0OD1L84bq9IomqAuh3mUsJEwh/DFkTWB60RjUB7GqXwwki2R9D1gSKJKyVilUTQAntAvwxZI1Y0zJVBFBKuwCrg3UprGmZKgLY3WQUSy3apQ9LWmbuA9jVLiinisEfQ9aOJS1jYpEQA+NHG3HjLkntklp4ME9Ay+CF3btPNwLqAYQakGh5qF3CwWePYgVNVLtgdJ9S3d6Ci2+9atUufVjQMqotoN99Yuz26cE3XQzrzRgQQV46Eq5f0O0eFtoNNy/cu/PM3b0zafG1JyNqAeyWqz+4/8ydPI29ueGN8qHm6+dmmQmnXYV2Kah4kdiUPk/4L772GFClC54240zdxIN9HBZNvzWkliulUPnXd1roT9nEg6pffFkvwNREcrlSiuIBnDXjTN3Ec+r0e+5p83fcGonPC0VquVKS4j9BX0VGytkqeKvRrWCpiZvCX0VyuVKSogGEdmlnX7NIOVul7VZqX9MNRWq5UpqiARwaipSzVTCrxYoIJjTcFD5BarkyB8UCGDrBSDlbBa0gJiSXOCHZRmq5MgdFAhiz0E8tI4M17dKlyE8Tu7+CWiYNi9qlS/YApiz0U8ukYVG7dMn+E6W2QNQycVjVLl2yLwRKjMGgZfZHlg2h20ELiEnN/gNxxQ7ziD/mtqRdumQPIE5nSt3k02qZmLe41TII4Bhr/sC9xr1aUi8+C1sNLiMIzsXV9DPyEKSzKx9GVcuAlXO/zc2MGF3muZXdLhX/ma2ekpV9DIhWy8KRt1KnnpZAqsv0n9nqyf1FpkUWjrxttYx1JFcq/Ge2enJ/kQBauYkIWsb6kWvSKxX+M1u0AcXEEDyU9k1ErZaxSo6VCv+ZJ2LaVitYLICSv/zUahmLrWDOlQr/ma2d3F9UjVu4iajVMtbIuVLhP/NkU7qdCUnRAEr+8iWqZaxQYqXCf2b4UCtKqvjiILXM/ym1UmFRy6isTlPLvKRkgahFLaMSQGqZl5Qej1rTMmr1OdQyOgWi1rSMWgDHrmVStUtKmZslLaNaoThmLZN6jDBmshLPrK1lVAM4Vi0jdYxwyhjOipZRr9Eeo5aROkY4dQxnQcuY2CQwJi2Teoxw94BxqWfW0jImAjgmLZN6jHCX1DGctpYxs01qDFpmOWHiMWt3YcoYTlvLmAlg7VomdeKB8vpZSD1zaS1jaqOoFS2TY202Vbv0hUJKRZXWMqYCaEXLSM3MW0rd3jSPWsbcVvkatQwG+rGE3N40j1rG3lkNzo6WkahSSXmhYu51mzctYzKAVrQMxoKpExJp7dKHpJYpcXWZ2X2KuDNE4g1stUxMK9TOzGNPW82lXfrAn3u6+djtitzEjwAiyCWurTUbwKuCt3tLnC0Teo9cbu3SB168VGIvDgrBZBc8RDuEoKFlcmuXEhw/8a7LjbkAouvJccB4SS1Tw6XZ+PlLFMuaC2Cut7+klimlXXKBF6hUjaSpAOa+Tr6ElimtXXIgtSI1BFMBXMssP0tomdLaRZrSZ0mbCeBkopD/AMmc1TJa2kWSo4W3J5gJYMk7PXJUy2hrFwmgXUqfJW0igKW1A1rA2JPzd9Iy1C5xqAcwl3bpI6VypDvRoHaJRz2AWm+/pJahdolHNYDa2iFVy6A7pnZJQ3UteH1dpugRV0Hs3Rf3KLjCIEY7oOVGK0rtkoZqAGOvXHj171hwX379ftE3uB23Uruko9oFS+zF1TjgBy0XamPmXbvgs9O+wku9HAtT/++/+9XFgO6j9BvctlynTr+rrl1Snh9DFgxdtFEPID6Epf9q7kLR6D7Q+qVol0nFsszEA89v9RLCoZgQ0TGb0j9uglv6w29PpUrRLlL7bjWePwcmAojwhW5K/6qZeJQGLZcV7aLx/DkwdTTH0DGVhrVvx20WtIvWqkUOTD3FyQFdm8aBkhLaBRt8JLSL1XtOYjEVwCFaZl61y4Xzj50ES4qrFjkw9ySzKjI0tYuFaheN58+NuQC2WmYa1C51hQ+YbMunaRkN7YDqaWqXvJgM4DQto6EdsH+E2iUvZk9GQCt42xs7fXvmF6fB3oQja6ld+jH9VCcTuj4pYjcxUbsMY2GrwRkGv8gSpzR1weQBtYIAXfCZwLNl0GJLjP0QvhonHy3mA6jJhfNPmhZwEkJUvwydBEC7XFyN33/cgtn3uZXdrmbqHFgI4W9EH3q2DLVLGAzgDPyN6EM3MVG7hMEA9hByhQG1SzgMYA/+RvS+s2WoXcJhAAfgy+idtAy1SxwM4ED6rjBgtUscDOBA/PMBu2fLsNolHgYwAF/LtGfLULukwQAGME3LULukwZWQQBA8LLPhv+19GhKcbVY8xjT2a2ELGEhXy0gwJu3ShQGMAIFZFrpg+5NmcpPjeth5gV0wUYUtIFGFASSqMIBEFQaQqMIAElUYQKIKA0hUYQCJKgwgUYUBJKowgEQVBpCowgASVRhAogoDSFRhAIkqDCBRhQEkqjCARBUGkKjCABJVGECiCgNIVPkXGPWKHZj1nMYAAAAASUVORK5CYII="
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "Tongyi"
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
import * as React from 'react'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import data from './Tongyi.json'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'Tongyi'
|
||||
|
||||
export default Icon
|
||||
@ -1,7 +1,6 @@
|
||||
export { default as Anthropic } from './Anthropic'
|
||||
export { default as AnthropicDark } from './AnthropicDark'
|
||||
export { default as AnthropicLight } from './AnthropicLight'
|
||||
export { default as AnthropicShortLight } from './AnthropicShortLight'
|
||||
export { default as AnthropicText } from './AnthropicText'
|
||||
export { default as Azureai } from './Azureai'
|
||||
export { default as AzureaiText } from './AzureaiText'
|
||||
@ -13,11 +12,8 @@ export { default as Chatglm } from './Chatglm'
|
||||
export { default as ChatglmText } from './ChatglmText'
|
||||
export { default as Cohere } from './Cohere'
|
||||
export { default as CohereText } from './CohereText'
|
||||
export { default as Deepseek } from './Deepseek'
|
||||
export { default as Gemini } from './Gemini'
|
||||
export { default as Gpt3 } from './Gpt3'
|
||||
export { default as Gpt4 } from './Gpt4'
|
||||
export { default as Grok } from './Grok'
|
||||
export { default as Huggingface } from './Huggingface'
|
||||
export { default as HuggingfaceText } from './HuggingfaceText'
|
||||
export { default as HuggingfaceTextHub } from './HuggingfaceTextHub'
|
||||
@ -30,19 +26,14 @@ export { default as Localai } from './Localai'
|
||||
export { default as LocalaiText } from './LocalaiText'
|
||||
export { default as Microsoft } from './Microsoft'
|
||||
export { default as OpenaiBlack } from './OpenaiBlack'
|
||||
export { default as OpenaiBlue } from './OpenaiBlue'
|
||||
export { default as OpenaiGreen } from './OpenaiGreen'
|
||||
export { default as OpenaiSmall } from './OpenaiSmall'
|
||||
export { default as OpenaiTeal } from './OpenaiTeal'
|
||||
export { default as OpenaiText } from './OpenaiText'
|
||||
export { default as OpenaiTransparent } from './OpenaiTransparent'
|
||||
export { default as OpenaiViolet } from './OpenaiViolet'
|
||||
export { default as OpenaiYellow } from './OpenaiYellow'
|
||||
export { default as Openllm } from './Openllm'
|
||||
export { default as OpenllmText } from './OpenllmText'
|
||||
export { default as Replicate } from './Replicate'
|
||||
export { default as ReplicateText } from './ReplicateText'
|
||||
export { default as Tongyi } from './Tongyi'
|
||||
export { default as XorbitsInference } from './XorbitsInference'
|
||||
export { default as XorbitsInferenceText } from './XorbitsInferenceText'
|
||||
export { default as Zhipuai } from './Zhipuai'
|
||||
|
||||
@ -11,7 +11,7 @@ const Icon = (
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
|
||||
@ -11,7 +11,7 @@ const Icon = (
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
|
||||
@ -11,7 +11,7 @@ const Icon = (
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
|
||||
@ -11,7 +11,7 @@ const Icon = (
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
|
||||
@ -1,16 +1,14 @@
|
||||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"width": "80px",
|
||||
"height": "18px",
|
||||
"viewBox": "0 0 80 18",
|
||||
"version": "1.1",
|
||||
"xmlns": "http://www.w3.org/2000/svg",
|
||||
"xmlns:xlink": "http://www.w3.org/1999/xlink"
|
||||
"version": "1.1"
|
||||
},
|
||||
"isRootNode": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
|
||||
@ -1,16 +1,14 @@
|
||||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"width": "120px",
|
||||
"height": "27px",
|
||||
"width": "80px",
|
||||
"height": "18px",
|
||||
"viewBox": "0 0 80 18",
|
||||
"version": "1.1",
|
||||
"xmlns": "http://www.w3.org/2000/svg",
|
||||
"xmlns:xlink": "http://www.w3.org/1999/xlink"
|
||||
"version": "1.1"
|
||||
},
|
||||
"isRootNode": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
|
||||
@ -75,9 +75,6 @@ const buildAppContext = (overrides: Partial<AppContextValue> = {}): AppContextVa
|
||||
created_at: 0,
|
||||
role: 'normal',
|
||||
providers: [],
|
||||
trial_credits: 200,
|
||||
trial_credits_used: 0,
|
||||
next_credit_reset_date: 0,
|
||||
}
|
||||
const langGeniusVersionInfo: LangGeniusVersionResponse = {
|
||||
current_env: '',
|
||||
@ -99,7 +96,6 @@ const buildAppContext = (overrides: Partial<AppContextValue> = {}): AppContextVa
|
||||
mutateCurrentWorkspace: vi.fn(),
|
||||
langGeniusVersionInfo,
|
||||
isLoadingCurrentWorkspace: false,
|
||||
isValidatingCurrentWorkspace: false,
|
||||
}
|
||||
const useSelector: AppContextValue['useSelector'] = selector => selector({ ...base, useSelector })
|
||||
return {
|
||||
|
||||
@ -0,0 +1,101 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
import EconomicalRetrievalMethodConfig from './index'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../settings/option-card', () => ({
|
||||
default: ({ children, title, description, disabled, id }: {
|
||||
children?: React.ReactNode
|
||||
title?: string
|
||||
description?: React.ReactNode
|
||||
disabled?: boolean
|
||||
id?: string
|
||||
}) => (
|
||||
<div data-testid="option-card" data-title={title} data-id={id} data-disabled={disabled}>
|
||||
<div>{description}</div>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../retrieval-param-config', () => ({
|
||||
default: ({ value, onChange, type }: {
|
||||
value: Record<string, unknown>
|
||||
onChange: (value: Record<string, unknown>) => void
|
||||
type?: string
|
||||
}) => (
|
||||
<div data-testid="retrieval-param-config" data-type={type}>
|
||||
<button onClick={() => onChange({ ...value, newProp: 'changed' })}>
|
||||
Change Value
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/icons/src/vender/knowledge', () => ({
|
||||
VectorSearch: () => <svg data-testid="vector-search-icon" />,
|
||||
}))
|
||||
|
||||
describe('EconomicalRetrievalMethodConfig', () => {
|
||||
const mockOnChange = vi.fn()
|
||||
const defaultProps = {
|
||||
value: {
|
||||
search_method: RETRIEVE_METHOD.keywordSearch,
|
||||
reranking_enable: false,
|
||||
reranking_model: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
top_k: 2,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0.5,
|
||||
},
|
||||
onChange: mockOnChange,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render correctly', () => {
|
||||
render(<EconomicalRetrievalMethodConfig {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('option-card')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('retrieval-param-config')).toBeInTheDocument()
|
||||
// Check if title and description are rendered (mocked i18n returns key)
|
||||
expect(screen.getByText('dataset.retrieval.keyword_search.description')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass correct props to OptionCard', () => {
|
||||
render(<EconomicalRetrievalMethodConfig {...defaultProps} disabled={true} />)
|
||||
|
||||
const card = screen.getByTestId('option-card')
|
||||
expect(card).toHaveAttribute('data-disabled', 'true')
|
||||
expect(card).toHaveAttribute('data-id', RETRIEVE_METHOD.keywordSearch)
|
||||
})
|
||||
|
||||
it('should pass correct props to RetrievalParamConfig', () => {
|
||||
render(<EconomicalRetrievalMethodConfig {...defaultProps} />)
|
||||
|
||||
const config = screen.getByTestId('retrieval-param-config')
|
||||
expect(config).toHaveAttribute('data-type', RETRIEVE_METHOD.keywordSearch)
|
||||
})
|
||||
|
||||
it('should handle onChange events', () => {
|
||||
render(<EconomicalRetrievalMethodConfig {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Change Value'))
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledTimes(1)
|
||||
expect(mockOnChange).toHaveBeenCalledWith({
|
||||
...defaultProps.value,
|
||||
newProp: 'changed',
|
||||
})
|
||||
})
|
||||
|
||||
it('should default disabled prop to false', () => {
|
||||
render(<EconomicalRetrievalMethodConfig {...defaultProps} />)
|
||||
const card = screen.getByTestId('option-card')
|
||||
expect(card).toHaveAttribute('data-disabled', 'false')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,148 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
import { retrievalIcon } from '../../create/icons'
|
||||
import RetrievalMethodInfo, { getIcon } from './index'
|
||||
|
||||
// Mock next/image
|
||||
vi.mock('next/image', () => ({
|
||||
default: ({ src, alt, className }: { src: string, alt: string, className?: string }) => (
|
||||
<img src={src} alt={alt || ''} className={className} data-testid="method-icon" />
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock RadioCard
|
||||
vi.mock('@/app/components/base/radio-card', () => ({
|
||||
default: ({ title, description, chosenConfig, icon }: { title: string, description: string, chosenConfig: ReactNode, icon: ReactNode }) => (
|
||||
<div data-testid="radio-card">
|
||||
<div data-testid="card-title">{title}</div>
|
||||
<div data-testid="card-description">{description}</div>
|
||||
<div data-testid="card-icon">{icon}</div>
|
||||
<div data-testid="chosen-config">{chosenConfig}</div>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock icons
|
||||
vi.mock('../../create/icons', () => ({
|
||||
retrievalIcon: {
|
||||
vector: 'vector-icon.png',
|
||||
fullText: 'fulltext-icon.png',
|
||||
hybrid: 'hybrid-icon.png',
|
||||
},
|
||||
}))
|
||||
|
||||
describe('RetrievalMethodInfo', () => {
|
||||
const defaultConfig = {
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: false,
|
||||
reranking_model: {
|
||||
reranking_provider_name: 'test-provider',
|
||||
reranking_model_name: 'test-model',
|
||||
},
|
||||
top_k: 5,
|
||||
score_threshold_enabled: true,
|
||||
score_threshold: 0.8,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render correctly with full config', () => {
|
||||
render(<RetrievalMethodInfo value={defaultConfig} />)
|
||||
|
||||
expect(screen.getByTestId('radio-card')).toBeInTheDocument()
|
||||
|
||||
// Check Title & Description (mocked i18n returns key prefixed with ns)
|
||||
expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.semantic_search.title')
|
||||
expect(screen.getByTestId('card-description')).toHaveTextContent('dataset.retrieval.semantic_search.description')
|
||||
|
||||
// Check Icon
|
||||
const icon = screen.getByTestId('method-icon')
|
||||
expect(icon).toHaveAttribute('src', 'vector-icon.png')
|
||||
|
||||
// Check Config Details
|
||||
expect(screen.getByText('test-model')).toBeInTheDocument() // Rerank model
|
||||
expect(screen.getByText('5')).toBeInTheDocument() // Top K
|
||||
expect(screen.getByText('0.8')).toBeInTheDocument() // Score threshold
|
||||
})
|
||||
|
||||
it('should not render reranking model if missing', () => {
|
||||
const configWithoutRerank = {
|
||||
...defaultConfig,
|
||||
reranking_model: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
}
|
||||
|
||||
render(<RetrievalMethodInfo value={configWithoutRerank} />)
|
||||
|
||||
expect(screen.queryByText('test-model')).not.toBeInTheDocument()
|
||||
// Other fields should still be there
|
||||
expect(screen.getByText('5')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle different retrieval methods', () => {
|
||||
// Test Hybrid
|
||||
const hybridConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.hybrid }
|
||||
const { unmount } = render(<RetrievalMethodInfo value={hybridConfig} />)
|
||||
|
||||
expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.hybrid_search.title')
|
||||
expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'hybrid-icon.png')
|
||||
|
||||
unmount()
|
||||
|
||||
// Test FullText
|
||||
const fullTextConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.fullText }
|
||||
render(<RetrievalMethodInfo value={fullTextConfig} />)
|
||||
expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.full_text_search.title')
|
||||
expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'fulltext-icon.png')
|
||||
})
|
||||
|
||||
describe('getIcon utility', () => {
|
||||
it('should return correct icon for each type', () => {
|
||||
expect(getIcon(RETRIEVE_METHOD.semantic)).toBe(retrievalIcon.vector)
|
||||
expect(getIcon(RETRIEVE_METHOD.fullText)).toBe(retrievalIcon.fullText)
|
||||
expect(getIcon(RETRIEVE_METHOD.hybrid)).toBe(retrievalIcon.hybrid)
|
||||
expect(getIcon(RETRIEVE_METHOD.invertedIndex)).toBe(retrievalIcon.vector)
|
||||
expect(getIcon(RETRIEVE_METHOD.keywordSearch)).toBe(retrievalIcon.vector)
|
||||
})
|
||||
|
||||
it('should return default vector icon for unknown type', () => {
|
||||
// Test fallback branch when type is not in the mapping
|
||||
const unknownType = 'unknown_method' as RETRIEVE_METHOD
|
||||
expect(getIcon(unknownType)).toBe(retrievalIcon.vector)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not render score threshold if disabled', () => {
|
||||
const configWithoutScoreThreshold = {
|
||||
...defaultConfig,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0,
|
||||
}
|
||||
|
||||
render(<RetrievalMethodInfo value={configWithoutScoreThreshold} />)
|
||||
|
||||
// score_threshold is still rendered but may be undefined
|
||||
expect(screen.queryByText('0.8')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render correctly with invertedIndex search method', () => {
|
||||
const invertedIndexConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.invertedIndex }
|
||||
render(<RetrievalMethodInfo value={invertedIndexConfig} />)
|
||||
|
||||
// invertedIndex uses vector icon
|
||||
expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'vector-icon.png')
|
||||
})
|
||||
|
||||
it('should render correctly with keywordSearch search method', () => {
|
||||
const keywordSearchConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.keywordSearch }
|
||||
render(<RetrievalMethodInfo value={keywordSearchConfig} />)
|
||||
|
||||
// keywordSearch uses vector icon
|
||||
expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'vector-icon.png')
|
||||
})
|
||||
})
|
||||
@ -30,9 +30,10 @@ 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, useDocumentEnable } from '@/service/knowledge/use-document'
|
||||
import { useDocumentArchive, useDocumentBatchRetryIndex, useDocumentDelete, useDocumentDisable, useDocumentDownloadZip, 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'
|
||||
@ -222,6 +223,7 @@ 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 () => {
|
||||
@ -300,6 +302,39 @@ 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">
|
||||
@ -463,6 +498,7 @@ 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}
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import type { OperationName } from '../types'
|
||||
import type { CommonResponse } from '@/models/common'
|
||||
import type { DocumentDownloadResponse } from '@/service/datasets'
|
||||
import {
|
||||
RiArchive2Line,
|
||||
RiDeleteBinLine,
|
||||
RiDownload2Line,
|
||||
RiEditLine,
|
||||
RiEqualizer2Line,
|
||||
RiLoopLeftLine,
|
||||
@ -28,6 +30,7 @@ import {
|
||||
useDocumentArchive,
|
||||
useDocumentDelete,
|
||||
useDocumentDisable,
|
||||
useDocumentDownload,
|
||||
useDocumentEnable,
|
||||
useDocumentPause,
|
||||
useDocumentResume,
|
||||
@ -37,6 +40,7 @@ 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'
|
||||
|
||||
@ -69,7 +73,7 @@ const Operations = ({
|
||||
scene = 'list',
|
||||
className = '',
|
||||
}: OperationsProps) => {
|
||||
const { id, enabled = false, archived = false, data_source_type, display_status } = detail || {}
|
||||
const { id, name, enabled = false, archived = false, data_source_type, display_status } = detail || {}
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const { notify } = useContext(ToastContext)
|
||||
@ -80,6 +84,7 @@ 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()
|
||||
@ -158,6 +163,24 @@ 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 && (
|
||||
@ -214,6 +237,20 @@ 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" />
|
||||
@ -223,6 +260,23 @@ 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" />
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { FC } from 'react'
|
||||
import { RiArchive2Line, RiCheckboxCircleLine, RiCloseCircleLine, RiDeleteBinLine, RiDraftLine, RiRefreshLine } from '@remixicon/react'
|
||||
import { RiArchive2Line, RiCheckboxCircleLine, RiCloseCircleLine, RiDeleteBinLine, RiDownload2Line, RiDraftLine, RiRefreshLine } from '@remixicon/react'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -14,6 +14,7 @@ type IBatchActionProps = {
|
||||
selectedIds: string[]
|
||||
onBatchEnable: () => void
|
||||
onBatchDisable: () => void
|
||||
onBatchDownload?: () => void
|
||||
onBatchDelete: () => Promise<void>
|
||||
onArchive?: () => void
|
||||
onEditMetadata?: () => void
|
||||
@ -26,6 +27,7 @@ const BatchAction: FC<IBatchActionProps> = ({
|
||||
selectedIds,
|
||||
onBatchEnable,
|
||||
onBatchDisable,
|
||||
onBatchDownload,
|
||||
onArchive,
|
||||
onBatchDelete,
|
||||
onEditMetadata,
|
||||
@ -103,6 +105,16 @@ 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
|
||||
|
||||
@ -0,0 +1,46 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import EmbeddingSkeleton from './index'
|
||||
|
||||
// Mock Skeleton components
|
||||
vi.mock('@/app/components/base/skeleton', () => ({
|
||||
SkeletonContainer: ({ children }: { children?: React.ReactNode }) => <div data-testid="skeleton-container">{children}</div>,
|
||||
SkeletonPoint: () => <div data-testid="skeleton-point" />,
|
||||
SkeletonRectangle: () => <div data-testid="skeleton-rectangle" />,
|
||||
SkeletonRow: ({ children }: { children?: React.ReactNode }) => <div data-testid="skeleton-row">{children}</div>,
|
||||
}))
|
||||
|
||||
// Mock Divider
|
||||
vi.mock('@/app/components/base/divider', () => ({
|
||||
default: () => <div data-testid="divider" />,
|
||||
}))
|
||||
|
||||
describe('EmbeddingSkeleton', () => {
|
||||
it('should render correct number of skeletons', () => {
|
||||
render(<EmbeddingSkeleton />)
|
||||
|
||||
// It renders 5 CardSkeletons. Each CardSkelton has multiple SkeletonContainers.
|
||||
// Let's count the number of main wrapper divs (loop is 5)
|
||||
|
||||
// Each iteration renders a CardSkeleton and potentially a Divider.
|
||||
// The component structure is:
|
||||
// div.relative...
|
||||
// div.absolute... (mask)
|
||||
// map(5) -> div.w-full.px-11 -> CardSkelton + Divider (except last?)
|
||||
|
||||
// Actually the code says `index !== 9`, but the loop is length 5.
|
||||
// So `index` goes 0..4. All are !== 9. So 5 dividers should be rendered.
|
||||
|
||||
expect(screen.getAllByTestId('divider')).toHaveLength(5)
|
||||
|
||||
// Just ensure it renders without crashing and contains skeleton elements
|
||||
expect(screen.getAllByTestId('skeleton-container').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByTestId('skeleton-rectangle').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should render the mask overlay', () => {
|
||||
const { container } = render(<EmbeddingSkeleton />)
|
||||
// Check for the absolute positioned mask
|
||||
const mask = container.querySelector('.bg-dataset-chunk-list-mask-bg')
|
||||
expect(mask).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,239 @@
|
||||
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' },
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,424 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,207 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,382 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
92
web/app/components/datasets/extra-info/api-access/card.tsx
Normal file
92
web/app/components/datasets/extra-info/api-access/card.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
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)
|
||||
65
web/app/components/datasets/extra-info/api-access/index.tsx
Normal file
65
web/app/components/datasets/extra-info/api-access/index.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
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)
|
||||
1169
web/app/components/datasets/extra-info/index.spec.tsx
Normal file
1169
web/app/components/datasets/extra-info/index.spec.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,8 +1,7 @@
|
||||
import type { RelatedAppResponse } from '@/models/datasets'
|
||||
import * as React from 'react'
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import { useDatasetApiBaseUrl } from '@/service/knowledge/use-dataset'
|
||||
import ServiceApi from './service-api'
|
||||
import ApiAccess from './api-access'
|
||||
import Statistics from './statistics'
|
||||
|
||||
type IExtraInfoProps = {
|
||||
@ -17,7 +16,6 @@ const ExtraInfo = ({
|
||||
expand,
|
||||
}: IExtraInfoProps) => {
|
||||
const apiEnabled = useDatasetDetailContextWithSelector(state => state.dataset?.enable_api)
|
||||
const { data: apiBaseInfo } = useDatasetApiBaseUrl()
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -28,9 +26,8 @@ const ExtraInfo = ({
|
||||
relatedApps={relatedApps}
|
||||
/>
|
||||
)}
|
||||
<ServiceApi
|
||||
<ApiAccess
|
||||
expand={expand}
|
||||
apiBaseUrl={apiBaseInfo?.api_base_url ?? ''}
|
||||
apiEnabled={apiEnabled ?? false}
|
||||
/>
|
||||
</>
|
||||
|
||||
@ -6,45 +6,22 @@ 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)
|
||||
}, [])
|
||||
@ -68,24 +45,16 @@ const Card = ({
|
||||
<div className="flex items-center gap-x-1">
|
||||
<Indicator
|
||||
className="shrink-0"
|
||||
color={apiEnabled ? 'green' : 'yellow'}
|
||||
color={
|
||||
apiBaseUrl ? 'green' : 'yellow'
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'system-xs-semibold-uppercase',
|
||||
apiEnabled ? 'text-text-success' : 'text-text-warning',
|
||||
)}
|
||||
className="system-xs-semibold-uppercase text-text-success"
|
||||
>
|
||||
{apiEnabled
|
||||
? t('serviceApi.enabled', { ns: 'dataset' })
|
||||
: t('serviceApi.disabled', { ns: 'dataset' })}
|
||||
{t('serviceApi.enabled', { 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">
|
||||
|
||||
@ -1,22 +1,17 @@
|
||||
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)
|
||||
@ -26,7 +21,7 @@ const ServiceApi = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-3 pt-2">
|
||||
<div>
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
@ -41,22 +36,21 @@ const ServiceApi = ({
|
||||
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',
|
||||
'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',
|
||||
)}
|
||||
>
|
||||
<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', !expand && 'absolute -right-px -top-px')}
|
||||
color={apiEnabled ? 'green' : 'yellow'}
|
||||
className={cn('shrink-0')}
|
||||
color={
|
||||
apiBaseUrl ? '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>
|
||||
|
||||
2654
web/app/components/datasets/hit-testing/index.spec.tsx
Normal file
2654
web/app/components/datasets/hit-testing/index.spec.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,125 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,177 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,254 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,237 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,107 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,162 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,198 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,427 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
256
web/app/components/datasets/list/dataset-card/index.spec.tsx
Normal file
256
web/app/components/datasets/list/dataset-card/index.spec.tsx
Normal file
@ -0,0 +1,256 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,87 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,119 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import Operations from './operations'
|
||||
|
||||
describe('Operations', () => {
|
||||
const defaultProps = {
|
||||
showDelete: true,
|
||||
showExportPipeline: true,
|
||||
openRenameModal: vi.fn(),
|
||||
handleExportPipeline: vi.fn(),
|
||||
detectIsUsedByApp: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Operations {...defaultProps} />)
|
||||
// Edit operation should always be visible
|
||||
expect(screen.getByText(/operation\.edit/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render edit operation', () => {
|
||||
render(<Operations {...defaultProps} />)
|
||||
expect(screen.getByText(/operation\.edit/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render export pipeline operation when showExportPipeline is true', () => {
|
||||
render(<Operations {...defaultProps} showExportPipeline={true} />)
|
||||
expect(screen.getByText(/exportPipeline/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render export pipeline operation when showExportPipeline is false', () => {
|
||||
render(<Operations {...defaultProps} showExportPipeline={false} />)
|
||||
expect(screen.queryByText(/exportPipeline/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render delete operation when showDelete is true', () => {
|
||||
render(<Operations {...defaultProps} showDelete={true} />)
|
||||
expect(screen.getByText(/operation\.delete/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render delete operation when showDelete is false', () => {
|
||||
render(<Operations {...defaultProps} showDelete={false} />)
|
||||
expect(screen.queryByText(/operation\.delete/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should render divider when showDelete is true', () => {
|
||||
const { container } = render(<Operations {...defaultProps} showDelete={true} />)
|
||||
const divider = container.querySelector('.bg-divider-subtle')
|
||||
expect(divider).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render divider when showDelete is false', () => {
|
||||
const { container } = render(<Operations {...defaultProps} showDelete={false} />)
|
||||
// Should not have the divider-subtle one (the separator before delete)
|
||||
expect(container.querySelector('.bg-divider-subtle')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call openRenameModal when edit is clicked', () => {
|
||||
const openRenameModal = vi.fn()
|
||||
render(<Operations {...defaultProps} openRenameModal={openRenameModal} />)
|
||||
|
||||
const editItem = screen.getByText(/operation\.edit/).closest('div')
|
||||
fireEvent.click(editItem!)
|
||||
|
||||
expect(openRenameModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call handleExportPipeline when export is clicked', () => {
|
||||
const handleExportPipeline = vi.fn()
|
||||
render(<Operations {...defaultProps} handleExportPipeline={handleExportPipeline} />)
|
||||
|
||||
const exportItem = screen.getByText(/exportPipeline/).closest('div')
|
||||
fireEvent.click(exportItem!)
|
||||
|
||||
expect(handleExportPipeline).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call detectIsUsedByApp when delete is clicked', () => {
|
||||
const detectIsUsedByApp = vi.fn()
|
||||
render(<Operations {...defaultProps} detectIsUsedByApp={detectIsUsedByApp} />)
|
||||
|
||||
const deleteItem = screen.getByText(/operation\.delete/).closest('div')
|
||||
fireEvent.click(deleteItem!)
|
||||
|
||||
expect(detectIsUsedByApp).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styles', () => {
|
||||
it('should have correct container styling', () => {
|
||||
const { container } = render(<Operations {...defaultProps} />)
|
||||
const operationsContainer = container.firstChild
|
||||
expect(operationsContainer).toHaveClass(
|
||||
'relative',
|
||||
'flex',
|
||||
'w-full',
|
||||
'flex-col',
|
||||
'rounded-xl',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render only edit when both showDelete and showExportPipeline are false', () => {
|
||||
render(<Operations {...defaultProps} showDelete={false} showExportPipeline={false} />)
|
||||
expect(screen.getByText(/operation\.edit/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/exportPipeline/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/operation\.delete/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,52 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import DatasetFooter from './index'
|
||||
|
||||
describe('DatasetFooter', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<DatasetFooter />)
|
||||
expect(screen.getByRole('contentinfo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the main heading', () => {
|
||||
render(<DatasetFooter />)
|
||||
expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render description paragraph', () => {
|
||||
render(<DatasetFooter />)
|
||||
// The paragraph contains multiple text spans
|
||||
expect(screen.getByText(/intro1/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should be memoized', () => {
|
||||
// DatasetFooter is wrapped with React.memo
|
||||
expect(DatasetFooter).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styles', () => {
|
||||
it('should have correct footer styling', () => {
|
||||
render(<DatasetFooter />)
|
||||
const footer = screen.getByRole('contentinfo')
|
||||
expect(footer).toHaveClass('shrink-0', 'px-12', 'py-6')
|
||||
})
|
||||
|
||||
it('should have gradient text on heading', () => {
|
||||
render(<DatasetFooter />)
|
||||
const heading = screen.getByRole('heading', { level: 3 })
|
||||
expect(heading).toHaveClass('text-gradient')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Content Structure', () => {
|
||||
it('should render accent spans for highlighted text', () => {
|
||||
render(<DatasetFooter />)
|
||||
const accentSpans = document.querySelectorAll('.text-text-accent')
|
||||
expect(accentSpans.length).toBe(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
485
web/app/components/datasets/list/datasets.spec.tsx
Normal file
485
web/app/components/datasets/list/datasets.spec.tsx
Normal file
@ -0,0 +1,485 @@
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { render, screen, 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 { RETRIEVE_METHOD } from '@/types/app'
|
||||
import Datasets from './datasets'
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}))
|
||||
|
||||
// Mock ahooks
|
||||
vi.mock('ahooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('ahooks')>()
|
||||
return {
|
||||
...actual,
|
||||
useHover: () => false,
|
||||
}
|
||||
})
|
||||
|
||||
// Mock useFormatTimeFromNow hook
|
||||
vi.mock('@/hooks/use-format-time-from-now', () => ({
|
||||
useFormatTimeFromNow: () => ({
|
||||
formatTimeFromNow: (timestamp: number) => new Date(timestamp).toLocaleDateString(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useKnowledge hook
|
||||
vi.mock('@/hooks/use-knowledge', () => ({
|
||||
useKnowledge: () => ({
|
||||
formatIndexingTechniqueAndMethod: () => 'High Quality',
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock service hooks - will be overridden in individual tests
|
||||
const mockFetchNextPage = vi.fn()
|
||||
const mockInvalidDatasetList = vi.fn()
|
||||
|
||||
vi.mock('@/service/knowledge/use-dataset', () => ({
|
||||
useDatasetList: vi.fn(() => ({
|
||||
data: {
|
||||
pages: [
|
||||
{
|
||||
data: [
|
||||
createMockDataset({ id: 'dataset-1', name: 'Dataset 1' }),
|
||||
createMockDataset({ id: 'dataset-2', name: 'Dataset 2' }),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
})),
|
||||
useInvalidDatasetList: () => mockInvalidDatasetList,
|
||||
}))
|
||||
|
||||
// Mock app context - will be overridden in tests
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useSelector: vi.fn(() => true),
|
||||
}))
|
||||
|
||||
// Mock useDatasetCardState hook
|
||||
vi.mock('./dataset-card/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 RenameDatasetModal
|
||||
vi.mock('../rename-modal', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
function createMockDataset(overrides: Partial<DataSet> = {}): DataSet {
|
||||
return {
|
||||
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
|
||||
}
|
||||
|
||||
// Store IntersectionObserver callbacks for testing
|
||||
let intersectionObserverCallback: IntersectionObserverCallback | null = null
|
||||
const mockObserve = vi.fn()
|
||||
const mockDisconnect = vi.fn()
|
||||
const mockUnobserve = vi.fn()
|
||||
|
||||
// Custom IntersectionObserver mock
|
||||
class MockIntersectionObserver {
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
intersectionObserverCallback = callback
|
||||
}
|
||||
|
||||
observe = mockObserve
|
||||
disconnect = mockDisconnect
|
||||
unobserve = mockUnobserve
|
||||
root = null
|
||||
rootMargin = ''
|
||||
thresholds = []
|
||||
takeRecords = () => []
|
||||
}
|
||||
|
||||
describe('Datasets', () => {
|
||||
const defaultProps = {
|
||||
tags: [],
|
||||
keywords: '',
|
||||
includeAll: false,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
intersectionObserverCallback = null
|
||||
document.title = ''
|
||||
|
||||
// Setup IntersectionObserver mock
|
||||
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Datasets {...defaultProps} />)
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render NewDatasetCard when user is editor', async () => {
|
||||
const { useSelector } = await import('@/context/app-context')
|
||||
vi.mocked(useSelector).mockReturnValue(true)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
expect(screen.getByText(/createDataset/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should NOT render NewDatasetCard when user is NOT editor', async () => {
|
||||
const { useSelector } = await import('@/context/app-context')
|
||||
vi.mocked(useSelector).mockReturnValue(false)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
expect(screen.queryByText(/createDataset/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render dataset cards from data', () => {
|
||||
render(<Datasets {...defaultProps} />)
|
||||
expect(screen.getByText('Dataset 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Dataset 2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render anchor div for infinite scroll', () => {
|
||||
render(<Datasets {...defaultProps} />)
|
||||
const anchor = document.querySelector('.h-0')
|
||||
expect(anchor).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should pass tags to useDatasetList', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
render(<Datasets {...defaultProps} tags={['tag-1', 'tag-2']} />)
|
||||
expect(useDatasetList).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tag_ids: ['tag-1', 'tag-2'],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should pass keywords to useDatasetList', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
render(<Datasets {...defaultProps} keywords="search term" />)
|
||||
expect(useDatasetList).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
keyword: 'search term',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should pass includeAll to useDatasetList', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
render(<Datasets {...defaultProps} includeAll={true} />)
|
||||
expect(useDatasetList).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
include_all: true,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Document Title', () => {
|
||||
it('should set document title on mount', async () => {
|
||||
render(<Datasets {...defaultProps} />)
|
||||
await waitFor(() => {
|
||||
expect(document.title).toContain('dataset.knowledge')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Loading States', () => {
|
||||
it('should show Loading component when isFetchingNextPage is true', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: { pages: [{ data: [] }] },
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: true,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: true,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
// Loading component renders a div with loading classes
|
||||
const nav = screen.getByRole('navigation')
|
||||
expect(nav).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should NOT show Loading component when isFetchingNextPage is false', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: { pages: [{ data: [] }] },
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: true,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DatasetList null handling', () => {
|
||||
it('should handle null datasetList gracefully', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: null,
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined datasetList gracefully', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: undefined,
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty pages array', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: { pages: [] },
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('IntersectionObserver', () => {
|
||||
it('should setup IntersectionObserver on mount', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: { pages: [{ data: [] }] },
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: true,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
|
||||
// Should observe the anchor element
|
||||
expect(mockObserve).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call fetchNextPage when isIntersecting, hasNextPage, and not isFetching', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: { pages: [{ data: [] }] },
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: true,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
|
||||
// Simulate intersection
|
||||
if (intersectionObserverCallback) {
|
||||
intersectionObserverCallback(
|
||||
[{ isIntersecting: true } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
}
|
||||
|
||||
expect(mockFetchNextPage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should NOT call fetchNextPage when isIntersecting is false', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: { pages: [{ data: [] }] },
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: true,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
|
||||
if (intersectionObserverCallback) {
|
||||
intersectionObserverCallback(
|
||||
[{ isIntersecting: false } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
}
|
||||
|
||||
expect(mockFetchNextPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should NOT call fetchNextPage when hasNextPage is false', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: { pages: [{ data: [] }] },
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: false, // No more pages
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
|
||||
if (intersectionObserverCallback) {
|
||||
intersectionObserverCallback(
|
||||
[{ isIntersecting: true } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
}
|
||||
|
||||
expect(mockFetchNextPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should NOT call fetchNextPage when isFetching is true', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: { pages: [{ data: [] }] },
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: true,
|
||||
isFetching: true, // Already fetching
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
|
||||
if (intersectionObserverCallback) {
|
||||
intersectionObserverCallback(
|
||||
[{ isIntersecting: true } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
}
|
||||
|
||||
expect(mockFetchNextPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should disconnect observer on unmount', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: { pages: [{ data: [] }] },
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: true,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
const { unmount } = render(<Datasets {...defaultProps} />)
|
||||
|
||||
// Unmount the component
|
||||
unmount()
|
||||
|
||||
// disconnect should be called during cleanup
|
||||
expect(mockDisconnect).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styles', () => {
|
||||
it('should have correct grid styling', () => {
|
||||
render(<Datasets {...defaultProps} />)
|
||||
const nav = screen.getByRole('navigation')
|
||||
expect(nav).toHaveClass('grid', 'grow', 'gap-3', 'px-12')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty tags array', () => {
|
||||
render(<Datasets {...defaultProps} tags={[]} />)
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty keywords', () => {
|
||||
render(<Datasets {...defaultProps} keywords="" />)
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle multiple pages of data', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: {
|
||||
pages: [
|
||||
{ data: [createMockDataset({ id: 'ds-1', name: 'Page 1 Dataset' })] },
|
||||
{ data: [createMockDataset({ id: 'ds-2', name: 'Page 2 Dataset' })] },
|
||||
],
|
||||
},
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
expect(screen.getByText('Page 1 Dataset')).toBeInTheDocument()
|
||||
expect(screen.getByText('Page 2 Dataset')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
368
web/app/components/datasets/list/index.spec.tsx
Normal file
368
web/app/components/datasets/list/index.spec.tsx
Normal file
@ -0,0 +1,368 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import List from './index'
|
||||
|
||||
// Mock next/navigation
|
||||
const mockPush = vi.fn()
|
||||
const mockReplace = vi.fn()
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
replace: mockReplace,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock ahooks
|
||||
vi.mock('ahooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('ahooks')>()
|
||||
return {
|
||||
...actual,
|
||||
useBoolean: () => [false, { toggle: vi.fn(), setTrue: vi.fn(), setFalse: vi.fn() }],
|
||||
useDebounceFn: (fn: () => void) => ({ run: fn }),
|
||||
useHover: () => false,
|
||||
}
|
||||
})
|
||||
|
||||
// Mock app context
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
currentWorkspace: { role: 'admin' },
|
||||
isCurrentWorkspaceOwner: true,
|
||||
}),
|
||||
useSelector: () => true,
|
||||
}))
|
||||
|
||||
// Mock global public context
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: () => ({
|
||||
systemFeatures: {
|
||||
branding: { enabled: false },
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock external api panel context
|
||||
const mockSetShowExternalApiPanel = vi.fn()
|
||||
vi.mock('@/context/external-api-panel-context', () => ({
|
||||
useExternalApiPanel: () => ({
|
||||
showExternalApiPanel: false,
|
||||
setShowExternalApiPanel: mockSetShowExternalApiPanel,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock tag management store
|
||||
vi.mock('@/app/components/base/tag-management/store', () => ({
|
||||
useStore: () => false,
|
||||
}))
|
||||
|
||||
// Mock useDocumentTitle hook
|
||||
vi.mock('@/hooks/use-document-title', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock useFormatTimeFromNow hook
|
||||
vi.mock('@/hooks/use-format-time-from-now', () => ({
|
||||
useFormatTimeFromNow: () => ({
|
||||
formatTimeFromNow: (timestamp: number) => new Date(timestamp).toLocaleDateString(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useKnowledge hook
|
||||
vi.mock('@/hooks/use-knowledge', () => ({
|
||||
useKnowledge: () => ({
|
||||
formatIndexingTechniqueAndMethod: () => 'High Quality',
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock service hooks
|
||||
vi.mock('@/service/knowledge/use-dataset', () => ({
|
||||
useDatasetList: vi.fn(() => ({
|
||||
data: { pages: [{ data: [] }] },
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
})),
|
||||
useInvalidDatasetList: () => vi.fn(),
|
||||
useDatasetApiBaseUrl: () => ({
|
||||
data: { api_base_url: 'https://api.example.com' },
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock Datasets component
|
||||
vi.mock('./datasets', () => ({
|
||||
default: ({ tags, keywords, includeAll }: { tags: string[], keywords: string, includeAll: boolean }) => (
|
||||
<div data-testid="datasets-component">
|
||||
<span data-testid="tags">{tags.join(',')}</span>
|
||||
<span data-testid="keywords">{keywords}</span>
|
||||
<span data-testid="include-all">{includeAll ? 'true' : 'false'}</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock DatasetFooter component
|
||||
vi.mock('./dataset-footer', () => ({
|
||||
default: () => <footer data-testid="dataset-footer">Footer</footer>,
|
||||
}))
|
||||
|
||||
// Mock ExternalAPIPanel component
|
||||
vi.mock('../external-api/external-api-panel', () => ({
|
||||
default: ({ onClose }: { onClose: () => void }) => (
|
||||
<div data-testid="external-api-panel">
|
||||
<button onClick={onClose}>Close Panel</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock TagManagementModal
|
||||
vi.mock('@/app/components/base/tag-management', () => ({
|
||||
default: () => <div data-testid="tag-management-modal" />,
|
||||
}))
|
||||
|
||||
// Mock TagFilter
|
||||
vi.mock('@/app/components/base/tag-management/filter', () => ({
|
||||
default: ({ onChange }: { value: string[], onChange: (val: string[]) => void }) => (
|
||||
<div data-testid="tag-filter">
|
||||
<button onClick={() => onChange(['tag-1', 'tag-2'])}>Select Tags</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock CheckboxWithLabel
|
||||
vi.mock('@/app/components/datasets/create/website/base/checkbox-with-label', () => ({
|
||||
default: ({ isChecked, onChange, label }: { isChecked: boolean, onChange: () => void, label: string }) => (
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={onChange}
|
||||
data-testid="include-all-checkbox"
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('List', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByTestId('datasets-component')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the search input', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tag filter', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByTestId('tag-filter')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render external API panel button', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByText(/externalAPIPanelTitle/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render dataset footer when branding is disabled', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByTestId('dataset-footer')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should pass includeAll prop to Datasets', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByTestId('include-all')).toHaveTextContent('false')
|
||||
})
|
||||
|
||||
it('should pass empty keywords initially', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByTestId('keywords')).toHaveTextContent('')
|
||||
})
|
||||
|
||||
it('should pass empty tags initially', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByTestId('tags')).toHaveTextContent('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should open external API panel when button is clicked', () => {
|
||||
render(<List />)
|
||||
|
||||
const button = screen.getByText(/externalAPIPanelTitle/)
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(mockSetShowExternalApiPanel).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should update search input value', () => {
|
||||
render(<List />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'test search' } })
|
||||
|
||||
expect(input).toHaveValue('test search')
|
||||
})
|
||||
|
||||
it('should trigger tag filter change', () => {
|
||||
render(<List />)
|
||||
// Tag filter is rendered and interactive
|
||||
const selectTagsBtn = screen.getByText('Select Tags')
|
||||
expect(selectTagsBtn).toBeInTheDocument()
|
||||
fireEvent.click(selectTagsBtn)
|
||||
// The onChange callback was triggered (debounced)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Conditional Rendering', () => {
|
||||
it('should show include all checkbox for workspace owner', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByTestId('include-all-checkbox')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styles', () => {
|
||||
it('should have correct container styling', () => {
|
||||
const { container } = render(<List />)
|
||||
const mainContainer = container.firstChild as HTMLElement
|
||||
expect(mainContainer).toHaveClass('scroll-container', 'relative', 'flex', 'grow', 'flex-col')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty state gracefully', () => {
|
||||
render(<List />)
|
||||
// Should render without errors even with empty data
|
||||
expect(screen.getByTestId('datasets-component')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Branch Coverage', () => {
|
||||
it('should redirect normal role users to /apps', async () => {
|
||||
// Re-mock useAppContext with normal role
|
||||
vi.doMock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
currentWorkspace: { role: 'normal' },
|
||||
isCurrentWorkspaceOwner: false,
|
||||
}),
|
||||
useSelector: () => true,
|
||||
}))
|
||||
|
||||
// Clear module cache and re-import
|
||||
vi.resetModules()
|
||||
const { default: ListComponent } = await import('./index')
|
||||
|
||||
render(<ListComponent />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockReplace).toHaveBeenCalledWith('/apps')
|
||||
})
|
||||
})
|
||||
|
||||
it('should clear search input when onClear is called', () => {
|
||||
render(<List />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
// First set a value
|
||||
fireEvent.change(input, { target: { value: 'test search' } })
|
||||
expect(input).toHaveValue('test search')
|
||||
|
||||
// Find and click the clear button
|
||||
const clearButton = document.querySelector('[class*="clear"], button[aria-label*="clear"]')
|
||||
if (clearButton) {
|
||||
fireEvent.click(clearButton)
|
||||
expect(input).toHaveValue('')
|
||||
}
|
||||
})
|
||||
|
||||
it('should show ExternalAPIPanel when showExternalApiPanel is true', async () => {
|
||||
// Re-mock to show external API panel
|
||||
vi.doMock('@/context/external-api-panel-context', () => ({
|
||||
useExternalApiPanel: () => ({
|
||||
showExternalApiPanel: true,
|
||||
setShowExternalApiPanel: mockSetShowExternalApiPanel,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.resetModules()
|
||||
const { default: ListComponent } = await import('./index')
|
||||
|
||||
render(<ListComponent />)
|
||||
|
||||
expect(screen.getByTestId('external-api-panel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close ExternalAPIPanel when onClose is called', async () => {
|
||||
vi.doMock('@/context/external-api-panel-context', () => ({
|
||||
useExternalApiPanel: () => ({
|
||||
showExternalApiPanel: true,
|
||||
setShowExternalApiPanel: mockSetShowExternalApiPanel,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.resetModules()
|
||||
const { default: ListComponent } = await import('./index')
|
||||
|
||||
render(<ListComponent />)
|
||||
|
||||
const closeButton = screen.getByText('Close Panel')
|
||||
fireEvent.click(closeButton)
|
||||
|
||||
expect(mockSetShowExternalApiPanel).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should show TagManagementModal when showTagManagementModal is true', async () => {
|
||||
vi.doMock('@/app/components/base/tag-management/store', () => ({
|
||||
useStore: () => true, // showTagManagementModal is true
|
||||
}))
|
||||
|
||||
vi.resetModules()
|
||||
const { default: ListComponent } = await import('./index')
|
||||
|
||||
render(<ListComponent />)
|
||||
|
||||
expect(screen.getByTestId('tag-management-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show DatasetFooter when branding is enabled', async () => {
|
||||
vi.doMock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: () => ({
|
||||
systemFeatures: {
|
||||
branding: { enabled: true },
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.resetModules()
|
||||
const { default: ListComponent } = await import('./index')
|
||||
|
||||
render(<ListComponent />)
|
||||
|
||||
expect(screen.queryByTestId('dataset-footer')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show include all checkbox when not workspace owner', async () => {
|
||||
vi.doMock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
currentWorkspace: { role: 'editor' },
|
||||
isCurrentWorkspaceOwner: false,
|
||||
}),
|
||||
useSelector: () => true,
|
||||
}))
|
||||
|
||||
vi.resetModules()
|
||||
const { default: ListComponent } = await import('./index')
|
||||
|
||||
render(<ListComponent />)
|
||||
|
||||
expect(screen.queryByTestId('include-all-checkbox')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -14,13 +14,14 @@ import TagFilter from '@/app/components/base/tag-management/filter'
|
||||
// Hooks
|
||||
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
|
||||
import CheckboxWithLabel from '@/app/components/datasets/create/website/base/checkbox-with-label'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useAppContext, useSelector as useAppContextSelector } from '@/context/app-context'
|
||||
import { useExternalApiPanel } from '@/context/external-api-panel-context'
|
||||
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useDatasetApiBaseUrl } from '@/service/knowledge/use-dataset'
|
||||
// Components
|
||||
import ExternalAPIPanel from '../external-api/external-api-panel'
|
||||
import ServiceApi from '../extra-info/service-api'
|
||||
import DatasetFooter from './dataset-footer'
|
||||
import Datasets from './datasets'
|
||||
|
||||
@ -58,6 +59,9 @@ const List = () => {
|
||||
return router.replace('/apps')
|
||||
}, [currentWorkspace, router])
|
||||
|
||||
const isCurrentWorkspaceManager = useAppContextSelector(state => state.isCurrentWorkspaceManager)
|
||||
const { data: apiBaseInfo } = useDatasetApiBaseUrl()
|
||||
|
||||
return (
|
||||
<div className="scroll-container relative flex grow flex-col overflow-y-auto bg-background-body">
|
||||
<div className="sticky top-0 z-10 flex items-center justify-end gap-x-1 bg-background-body px-12 pb-2 pt-4">
|
||||
@ -81,6 +85,11 @@ const List = () => {
|
||||
onChange={e => handleKeywordsChange(e.target.value)}
|
||||
onClear={() => handleKeywordsChange('')}
|
||||
/>
|
||||
{
|
||||
isCurrentWorkspaceManager && (
|
||||
<ServiceApi apiBaseUrl={apiBaseInfo?.api_base_url ?? ''} />
|
||||
)
|
||||
}
|
||||
<div className="h-4 w-[1px] bg-divider-regular" />
|
||||
<Button
|
||||
className="shadows-shadow-xs gap-0.5"
|
||||
@ -96,7 +105,6 @@ const List = () => {
|
||||
{showTagManagementModal && (
|
||||
<TagManagementModal type="knowledge" show={showTagManagementModal} />
|
||||
)}
|
||||
|
||||
{showExternalApiPanel && <ExternalAPIPanel onClose={() => setShowExternalApiPanel(false)} />}
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -0,0 +1,76 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import CreateAppCard from './index'
|
||||
|
||||
describe('CreateAppCard', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<CreateAppCard />)
|
||||
expect(screen.getAllByRole('link')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should render create dataset option', () => {
|
||||
render(<CreateAppCard />)
|
||||
expect(screen.getByText(/createDataset/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render create from pipeline option', () => {
|
||||
render(<CreateAppCard />)
|
||||
expect(screen.getByText(/createFromPipeline/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render connect dataset option', () => {
|
||||
render(<CreateAppCard />)
|
||||
expect(screen.getByText(/connectDataset/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should have correct displayName', () => {
|
||||
expect(CreateAppCard.displayName).toBe('CreateAppCard')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Links', () => {
|
||||
it('should have correct href for create dataset', () => {
|
||||
render(<CreateAppCard />)
|
||||
const links = screen.getAllByRole('link')
|
||||
expect(links[0]).toHaveAttribute('href', '/datasets/create')
|
||||
})
|
||||
|
||||
it('should have correct href for create from pipeline', () => {
|
||||
render(<CreateAppCard />)
|
||||
const links = screen.getAllByRole('link')
|
||||
expect(links[1]).toHaveAttribute('href', '/datasets/create-from-pipeline')
|
||||
})
|
||||
|
||||
it('should have correct href for connect dataset', () => {
|
||||
render(<CreateAppCard />)
|
||||
const links = screen.getAllByRole('link')
|
||||
expect(links[2]).toHaveAttribute('href', '/datasets/connect')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styles', () => {
|
||||
it('should have correct card styling', () => {
|
||||
const { container } = render(<CreateAppCard />)
|
||||
const card = container.firstChild as HTMLElement
|
||||
expect(card).toHaveClass('h-[190px]', 'flex', 'flex-col', 'rounded-xl')
|
||||
})
|
||||
|
||||
it('should have border separator for connect option', () => {
|
||||
const { container } = render(<CreateAppCard />)
|
||||
const borderDiv = container.querySelector('.border-t-\\[0\\.5px\\]')
|
||||
expect(borderDiv).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Icons', () => {
|
||||
it('should render three icons for three options', () => {
|
||||
const { container } = render(<CreateAppCard />)
|
||||
// Each option has an icon
|
||||
const icons = container.querySelectorAll('svg')
|
||||
expect(icons.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,78 @@
|
||||
import { RiAddLine } from '@remixicon/react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import Option from './option'
|
||||
|
||||
describe('Option', () => {
|
||||
const defaultProps = {
|
||||
Icon: RiAddLine,
|
||||
text: 'Test Option',
|
||||
href: '/test-path',
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Option {...defaultProps} />)
|
||||
expect(screen.getByRole('link')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the text content', () => {
|
||||
render(<Option {...defaultProps} />)
|
||||
expect(screen.getByText('Test Option')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the icon', () => {
|
||||
render(<Option {...defaultProps} />)
|
||||
// Icon should be rendered with correct size class
|
||||
const icon = document.querySelector('.h-4.w-4')
|
||||
expect(icon).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should have correct href attribute', () => {
|
||||
render(<Option {...defaultProps} />)
|
||||
const link = screen.getByRole('link')
|
||||
expect(link).toHaveAttribute('href', '/test-path')
|
||||
})
|
||||
|
||||
it('should render different text based on props', () => {
|
||||
render(<Option {...defaultProps} text="Different Text" />)
|
||||
expect(screen.getByText('Different Text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render different href based on props', () => {
|
||||
render(<Option {...defaultProps} href="/different-path" />)
|
||||
const link = screen.getByRole('link')
|
||||
expect(link).toHaveAttribute('href', '/different-path')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styles', () => {
|
||||
it('should have correct base styling', () => {
|
||||
render(<Option {...defaultProps} />)
|
||||
const link = screen.getByRole('link')
|
||||
expect(link).toHaveClass('flex', 'w-full', 'items-center', 'gap-x-2', 'rounded-lg')
|
||||
})
|
||||
|
||||
it('should have text span with correct styling', () => {
|
||||
render(<Option {...defaultProps} />)
|
||||
const textSpan = screen.getByText('Test Option')
|
||||
expect(textSpan).toHaveClass('system-sm-medium', 'grow', 'text-left')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty text', () => {
|
||||
render(<Option {...defaultProps} text="" />)
|
||||
const link = screen.getByRole('link')
|
||||
expect(link).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle long text', () => {
|
||||
const longText = 'A'.repeat(100)
|
||||
render(<Option {...defaultProps} text={longText} />)
|
||||
expect(screen.getByText(longText)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,92 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import AddedMetadataButton from './add-metadata-button'
|
||||
|
||||
describe('AddedMetadataButton', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<AddedMetadataButton />)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with translated text', () => {
|
||||
render(<AddedMetadataButton />)
|
||||
// The button should contain text from i18n
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render add icon', () => {
|
||||
const { container } = render(<AddedMetadataButton />)
|
||||
// Check if there's an SVG element (the RiAddLine icon)
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
render(<AddedMetadataButton className="custom-class" />)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should apply default classes', () => {
|
||||
render(<AddedMetadataButton />)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveClass('flex', 'w-full', 'items-center')
|
||||
})
|
||||
|
||||
it('should merge custom className with default classes', () => {
|
||||
render(<AddedMetadataButton className="my-custom-class" />)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveClass('flex', 'w-full', 'items-center', 'my-custom-class')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClick when button is clicked', () => {
|
||||
const handleClick = vi.fn()
|
||||
render(<AddedMetadataButton onClick={handleClick} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not throw when onClick is not provided and button is clicked', () => {
|
||||
render(<AddedMetadataButton />)
|
||||
|
||||
expect(() => {
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should call onClick multiple times on multiple clicks', () => {
|
||||
const handleClick = vi.fn()
|
||||
render(<AddedMetadataButton onClick={handleClick} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render with undefined className', () => {
|
||||
render(<AddedMetadataButton className={undefined} />)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with empty className', () => {
|
||||
render(<AddedMetadataButton className="" />)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with undefined onClick', () => {
|
||||
render(<AddedMetadataButton onClick={undefined} />)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
287
web/app/components/datasets/metadata/base/date-picker.spec.tsx
Normal file
287
web/app/components/datasets/metadata/base/date-picker.spec.tsx
Normal file
@ -0,0 +1,287 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import WrappedDatePicker from './date-picker'
|
||||
|
||||
type TriggerArgs = {
|
||||
handleClickTrigger: () => void
|
||||
}
|
||||
|
||||
type DatePickerProps = {
|
||||
onChange: (value: Date | null) => void
|
||||
onClear: () => void
|
||||
renderTrigger: (args: TriggerArgs) => React.ReactNode
|
||||
value?: Date
|
||||
}
|
||||
|
||||
// Mock the base date picker component
|
||||
vi.mock('@/app/components/base/date-and-time-picker/date-picker', () => ({
|
||||
default: ({ onChange, onClear, renderTrigger, value }: DatePickerProps) => {
|
||||
const trigger = renderTrigger({
|
||||
handleClickTrigger: () => {},
|
||||
})
|
||||
return (
|
||||
<div data-testid="date-picker-wrapper">
|
||||
{trigger}
|
||||
<button data-testid="select-date" onClick={() => onChange(value || null)}>
|
||||
Select Date
|
||||
</button>
|
||||
<button data-testid="clear-date" onClick={() => onClear()}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock useTimestamp hook
|
||||
vi.mock('@/hooks/use-timestamp', () => ({
|
||||
default: () => ({
|
||||
formatTime: (timestamp: number) => {
|
||||
if (!timestamp)
|
||||
return ''
|
||||
return new Date(timestamp * 1000).toLocaleDateString()
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('WrappedDatePicker', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<WrappedDatePicker onChange={handleChange} />)
|
||||
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render placeholder text when no value', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<WrappedDatePicker onChange={handleChange} />)
|
||||
// When no value, should show placeholder from i18n
|
||||
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render formatted date when value is provided', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
render(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
|
||||
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render calendar icon', () => {
|
||||
const handleChange = vi.fn()
|
||||
const { container } = render(<WrappedDatePicker onChange={handleChange} />)
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render select date button', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<WrappedDatePicker onChange={handleChange} />)
|
||||
expect(screen.getByTestId('select-date')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render clear date button', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<WrappedDatePicker onChange={handleChange} />)
|
||||
expect(screen.getByTestId('clear-date')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render close icon for clearing', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
const { container } = render(
|
||||
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
|
||||
)
|
||||
// RiCloseCircleFill should be rendered
|
||||
const closeIcon = container.querySelectorAll('svg')
|
||||
expect(closeIcon.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
const handleChange = vi.fn()
|
||||
const { container } = render(
|
||||
<WrappedDatePicker className="custom-class" onChange={handleChange} />,
|
||||
)
|
||||
const triggerElement = container.querySelector('.custom-class')
|
||||
expect(triggerElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should accept undefined value', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<WrappedDatePicker value={undefined} onChange={handleChange} />)
|
||||
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should accept number value', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = 1609459200 // 2021-01-01
|
||||
render(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
|
||||
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onChange with timestamp when date is selected', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
render(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('select-date'))
|
||||
|
||||
expect(handleChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onChange with null when date is cleared via onClear', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
render(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('clear-date'))
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('should call onChange with null when close icon is clicked directly', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
const { container } = render(
|
||||
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
// Find the RiCloseCircleFill icon (it has specific classes)
|
||||
const closeIcon = container.querySelector('.cursor-pointer.hover\\:text-components-input-text-filled')
|
||||
if (closeIcon) {
|
||||
fireEvent.click(closeIcon)
|
||||
expect(handleChange).toHaveBeenCalledWith(null)
|
||||
}
|
||||
})
|
||||
|
||||
it('should show close button on hover when value exists', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
const { container } = render(
|
||||
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
// The close icon should be present but hidden initially
|
||||
const triggerGroup = container.querySelector('.group')
|
||||
expect(triggerGroup).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle clicking on trigger element', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
const { container } = render(
|
||||
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
const trigger = container.querySelector('.group.flex')
|
||||
if (trigger)
|
||||
fireEvent.click(trigger)
|
||||
|
||||
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should have tertiary text color when no value', () => {
|
||||
const handleChange = vi.fn()
|
||||
const { container } = render(<WrappedDatePicker onChange={handleChange} />)
|
||||
const textElement = container.querySelector('.text-text-tertiary')
|
||||
expect(textElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have secondary text color when value exists', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
const { container } = render(
|
||||
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
|
||||
)
|
||||
const textElement = container.querySelector('.text-text-secondary')
|
||||
expect(textElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have input background styling', () => {
|
||||
const handleChange = vi.fn()
|
||||
const { container } = render(<WrappedDatePicker onChange={handleChange} />)
|
||||
const bgElement = container.querySelector('.bg-components-input-bg-normal')
|
||||
expect(bgElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have quaternary text color for close icon when value exists', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
const { container } = render(
|
||||
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
|
||||
)
|
||||
const closeIcon = container.querySelector('.text-text-quaternary')
|
||||
expect(closeIcon).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle timestamp of 0', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<WrappedDatePicker value={0} onChange={handleChange} />)
|
||||
// 0 is falsy but is a valid timestamp (epoch)
|
||||
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle very large timestamp', () => {
|
||||
const handleChange = vi.fn()
|
||||
const farFuture = 4102444800 // 2100-01-01
|
||||
render(<WrappedDatePicker value={farFuture} onChange={handleChange} />)
|
||||
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle switching between no value and value', () => {
|
||||
const handleChange = vi.fn()
|
||||
const { rerender } = render(
|
||||
<WrappedDatePicker onChange={handleChange} />,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
|
||||
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
rerender(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
|
||||
|
||||
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle clearing date multiple times', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
render(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('clear-date'))
|
||||
fireEvent.click(screen.getByTestId('clear-date'))
|
||||
fireEvent.click(screen.getByTestId('clear-date'))
|
||||
|
||||
expect(handleChange).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('should handle rapid date selections', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
render(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('select-date'))
|
||||
fireEvent.click(screen.getByTestId('select-date'))
|
||||
fireEvent.click(screen.getByTestId('select-date'))
|
||||
|
||||
expect(handleChange).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('should handle onChange with date object that has unix method', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<WrappedDatePicker onChange={handleChange} />)
|
||||
|
||||
// The mock triggers onChange with the value prop
|
||||
fireEvent.click(screen.getByTestId('select-date'))
|
||||
|
||||
// onChange should have been called
|
||||
expect(handleChange).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,257 @@
|
||||
import type { MetadataItemWithEdit } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DataType } from '../types'
|
||||
import AddRow from './add-row'
|
||||
|
||||
type InputCombinedProps = {
|
||||
type: DataType
|
||||
value: string | number | null
|
||||
onChange: (value: string | number) => void
|
||||
}
|
||||
|
||||
type LabelProps = {
|
||||
text: string
|
||||
}
|
||||
|
||||
// Mock InputCombined component
|
||||
vi.mock('./input-combined', () => ({
|
||||
default: ({ type, value, onChange }: InputCombinedProps) => (
|
||||
<input
|
||||
data-testid="input-combined"
|
||||
data-type={type}
|
||||
value={value || ''}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock Label component
|
||||
vi.mock('./label', () => ({
|
||||
default: ({ text }: LabelProps) => <div data-testid="label">{text}</div>,
|
||||
}))
|
||||
|
||||
describe('AddRow', () => {
|
||||
const mockPayload: MetadataItemWithEdit = {
|
||||
id: 'test-id',
|
||||
name: 'test_field',
|
||||
type: DataType.string,
|
||||
value: 'test value',
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
const { container } = render(
|
||||
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render label with payload name', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
render(
|
||||
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
expect(screen.getByTestId('label')).toHaveTextContent('test_field')
|
||||
})
|
||||
|
||||
it('should render input combined component', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
render(
|
||||
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
expect(screen.getByTestId('input-combined')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render remove button icon', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
const { container } = render(
|
||||
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass correct type to input combined', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
render(
|
||||
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
expect(screen.getByTestId('input-combined')).toHaveAttribute('data-type', DataType.string)
|
||||
})
|
||||
|
||||
it('should pass correct value to input combined', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
render(
|
||||
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
expect(screen.getByTestId('input-combined')).toHaveValue('test value')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
const { container } = render(
|
||||
<AddRow
|
||||
payload={mockPayload}
|
||||
onChange={handleChange}
|
||||
onRemove={handleRemove}
|
||||
className="custom-class"
|
||||
/>,
|
||||
)
|
||||
expect(container.firstChild).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should have default flex styling', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
const { container } = render(
|
||||
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
expect(container.firstChild).toHaveClass('flex', 'h-6', 'items-center', 'space-x-0.5')
|
||||
})
|
||||
|
||||
it('should handle different data types', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
const numberPayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
type: DataType.number,
|
||||
value: 42,
|
||||
}
|
||||
render(
|
||||
<AddRow payload={numberPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
expect(screen.getByTestId('input-combined')).toHaveAttribute('data-type', DataType.number)
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onChange with updated payload when input changes', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
render(
|
||||
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByTestId('input-combined'), { target: { value: 'new value' } })
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith({
|
||||
...mockPayload,
|
||||
value: 'new value',
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onRemove when remove button is clicked', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
const { container } = render(
|
||||
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
|
||||
const removeButton = container.querySelector('.cursor-pointer')
|
||||
if (removeButton)
|
||||
fireEvent.click(removeButton)
|
||||
|
||||
expect(handleRemove).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should preserve other payload properties on change', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
render(
|
||||
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByTestId('input-combined'), { target: { value: 'updated' } })
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'test-id',
|
||||
name: 'test_field',
|
||||
type: DataType.string,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Remove Button Styling', () => {
|
||||
it('should have hover styling on remove button', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
const { container } = render(
|
||||
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
const removeButton = container.querySelector('.cursor-pointer')
|
||||
expect(removeButton).toHaveClass('hover:bg-state-destructive-hover', 'hover:text-text-destructive')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle null value', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
const nullPayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
value: null,
|
||||
}
|
||||
render(
|
||||
<AddRow payload={nullPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
expect(screen.getByTestId('input-combined')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty string value', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
const emptyPayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
value: '',
|
||||
}
|
||||
render(
|
||||
<AddRow payload={emptyPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
expect(screen.getByTestId('input-combined')).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should handle time type payload', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
const timePayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
type: DataType.time,
|
||||
value: 1609459200,
|
||||
}
|
||||
render(
|
||||
<AddRow payload={timePayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
expect(screen.getByTestId('input-combined')).toHaveAttribute('data-type', DataType.time)
|
||||
})
|
||||
|
||||
it('should handle multiple onRemove calls', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
const { container } = render(
|
||||
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
|
||||
const removeButton = container.querySelector('.cursor-pointer')
|
||||
if (removeButton) {
|
||||
fireEvent.click(removeButton)
|
||||
fireEvent.click(removeButton)
|
||||
fireEvent.click(removeButton)
|
||||
}
|
||||
|
||||
expect(handleRemove).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,395 @@
|
||||
import type { MetadataItemWithEdit } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DataType, UpdateType } from '../types'
|
||||
import EditMetadatabatchItem from './edit-row'
|
||||
|
||||
type InputCombinedProps = {
|
||||
type: DataType
|
||||
value: string | number | null
|
||||
onChange: (value: string | number) => void
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
type MultipleValueInputProps = {
|
||||
onClear: () => void
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
type LabelProps = {
|
||||
text: string
|
||||
isDeleted?: boolean
|
||||
}
|
||||
|
||||
type EditedBeaconProps = {
|
||||
onReset: () => void
|
||||
}
|
||||
|
||||
// Mock InputCombined component
|
||||
vi.mock('./input-combined', () => ({
|
||||
default: ({ type, value, onChange, readOnly }: InputCombinedProps) => (
|
||||
<input
|
||||
data-testid="input-combined"
|
||||
data-type={type}
|
||||
value={value || ''}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock InputHasSetMultipleValue component
|
||||
vi.mock('./input-has-set-multiple-value', () => ({
|
||||
default: ({ onClear, readOnly }: MultipleValueInputProps) => (
|
||||
<div data-testid="multiple-value-input" data-readonly={readOnly}>
|
||||
<button data-testid="clear-multiple" onClick={onClear}>Clear Multiple</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock Label component
|
||||
vi.mock('./label', () => ({
|
||||
default: ({ text, isDeleted }: LabelProps) => (
|
||||
<div data-testid="label" data-deleted={isDeleted}>{text}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock EditedBeacon component
|
||||
vi.mock('./edited-beacon', () => ({
|
||||
default: ({ onReset }: EditedBeaconProps) => (
|
||||
<button data-testid="edited-beacon" onClick={onReset}>Reset</button>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('EditMetadatabatchItem', () => {
|
||||
const mockPayload: MetadataItemWithEdit = {
|
||||
id: 'test-id',
|
||||
name: 'test_field',
|
||||
type: DataType.string,
|
||||
value: 'test value',
|
||||
isMultipleValue: false,
|
||||
isUpdated: false,
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(
|
||||
<EditMetadatabatchItem
|
||||
payload={mockPayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render label with payload name', () => {
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={mockPayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('label')).toHaveTextContent('test_field')
|
||||
})
|
||||
|
||||
it('should render input combined for single value', () => {
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={mockPayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('input-combined')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render multiple value input when isMultipleValue is true', () => {
|
||||
const multiplePayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
isMultipleValue: true,
|
||||
}
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={multiplePayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('multiple-value-input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render delete button icon', () => {
|
||||
const { container } = render(
|
||||
<EditMetadatabatchItem
|
||||
payload={mockPayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Updated State', () => {
|
||||
it('should show edited beacon when isUpdated is true', () => {
|
||||
const updatedPayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
isUpdated: true,
|
||||
}
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={updatedPayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('edited-beacon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show edited beacon when isUpdated is false', () => {
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={mockPayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByTestId('edited-beacon')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Deleted State', () => {
|
||||
it('should pass isDeleted to label when updateType is delete', () => {
|
||||
const deletedPayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
updateType: UpdateType.delete,
|
||||
}
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={deletedPayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('label')).toHaveAttribute('data-deleted', 'true')
|
||||
})
|
||||
|
||||
it('should set readOnly on input when deleted', () => {
|
||||
const deletedPayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
updateType: UpdateType.delete,
|
||||
}
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={deletedPayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('input-combined')).toHaveAttribute('readonly')
|
||||
})
|
||||
|
||||
it('should have destructive styling on delete button when deleted', () => {
|
||||
const deletedPayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
updateType: UpdateType.delete,
|
||||
}
|
||||
const { container } = render(
|
||||
<EditMetadatabatchItem
|
||||
payload={deletedPayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
const deleteButton = container.querySelector('.bg-state-destructive-hover')
|
||||
expect(deleteButton).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onChange with updated payload when input changes', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={mockPayload}
|
||||
onChange={handleChange}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByTestId('input-combined'), { target: { value: 'new value' } })
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
...mockPayload,
|
||||
value: 'new value',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should call onRemove with id when delete button is clicked', () => {
|
||||
const handleRemove = vi.fn()
|
||||
const { container } = render(
|
||||
<EditMetadatabatchItem
|
||||
payload={mockPayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={handleRemove}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const deleteButton = container.querySelector('.cursor-pointer')
|
||||
if (deleteButton)
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
expect(handleRemove).toHaveBeenCalledWith('test-id')
|
||||
})
|
||||
|
||||
it('should call onReset with id when reset beacon is clicked', () => {
|
||||
const handleReset = vi.fn()
|
||||
const updatedPayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
isUpdated: true,
|
||||
}
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={updatedPayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={handleReset}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('edited-beacon'))
|
||||
|
||||
expect(handleReset).toHaveBeenCalledWith('test-id')
|
||||
})
|
||||
|
||||
it('should call onChange to clear multiple value', () => {
|
||||
const handleChange = vi.fn()
|
||||
const multiplePayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
isMultipleValue: true,
|
||||
}
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={multiplePayload}
|
||||
onChange={handleChange}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('clear-multiple'))
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
value: null,
|
||||
isMultipleValue: false,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Multiple Value State', () => {
|
||||
it('should render multiple value input when isMultipleValue is true', () => {
|
||||
const multiplePayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
isMultipleValue: true,
|
||||
}
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={multiplePayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('multiple-value-input')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('input-combined')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass readOnly to multiple value input when deleted', () => {
|
||||
const multipleDeletedPayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
isMultipleValue: true,
|
||||
updateType: UpdateType.delete,
|
||||
}
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={multipleDeletedPayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('multiple-value-input')).toHaveAttribute('data-readonly', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle payload with number type', () => {
|
||||
const numberPayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
type: DataType.number,
|
||||
value: 42,
|
||||
}
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={numberPayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('input-combined')).toHaveAttribute('data-type', DataType.number)
|
||||
})
|
||||
|
||||
it('should handle payload with time type', () => {
|
||||
const timePayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
type: DataType.time,
|
||||
value: 1609459200,
|
||||
}
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={timePayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('input-combined')).toHaveAttribute('data-type', DataType.time)
|
||||
})
|
||||
|
||||
it('should handle null value', () => {
|
||||
const nullPayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
value: null,
|
||||
}
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={nullPayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('input-combined')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,179 @@
|
||||
import { fireEvent, render, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import EditedBeacon from './edited-beacon'
|
||||
|
||||
describe('EditedBeacon', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const handleReset = vi.fn()
|
||||
const { container } = render(<EditedBeacon onReset={handleReset} />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with correct size', () => {
|
||||
const handleReset = vi.fn()
|
||||
const { container } = render(<EditedBeacon onReset={handleReset} />)
|
||||
expect(container.firstChild).toHaveClass('size-4', 'cursor-pointer')
|
||||
})
|
||||
|
||||
it('should render beacon dot by default (not hovering)', () => {
|
||||
const handleReset = vi.fn()
|
||||
const { container } = render(<EditedBeacon onReset={handleReset} />)
|
||||
// When not hovering, should show the small beacon dot
|
||||
const beaconDot = container.querySelector('.size-1')
|
||||
expect(beaconDot).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Hover State', () => {
|
||||
it('should show reset icon on hover', async () => {
|
||||
const handleReset = vi.fn()
|
||||
const { container } = render(<EditedBeacon onReset={handleReset} />)
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
fireEvent.mouseEnter(wrapper)
|
||||
|
||||
await waitFor(() => {
|
||||
// On hover, should show the reset icon (RiResetLeftLine)
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show beacon dot when not hovering', () => {
|
||||
const handleReset = vi.fn()
|
||||
const { container } = render(<EditedBeacon onReset={handleReset} />)
|
||||
|
||||
// By default (not hovering), should show beacon dot
|
||||
const beaconDot = container.querySelector('.size-1.rounded-full.bg-text-accent-secondary')
|
||||
expect(beaconDot).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide beacon dot on hover', async () => {
|
||||
const handleReset = vi.fn()
|
||||
const { container } = render(<EditedBeacon onReset={handleReset} />)
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
fireEvent.mouseEnter(wrapper)
|
||||
|
||||
await waitFor(() => {
|
||||
// On hover, the small beacon dot should be hidden
|
||||
const beaconDot = container.querySelector('.size-1.rounded-full.bg-text-accent-secondary')
|
||||
expect(beaconDot).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show beacon dot again on mouse leave', async () => {
|
||||
const handleReset = vi.fn()
|
||||
const { container } = render(<EditedBeacon onReset={handleReset} />)
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
|
||||
// Hover
|
||||
fireEvent.mouseEnter(wrapper)
|
||||
|
||||
await waitFor(() => {
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Leave
|
||||
fireEvent.mouseLeave(wrapper)
|
||||
|
||||
await waitFor(() => {
|
||||
const beaconDot = container.querySelector('.size-1.rounded-full.bg-text-accent-secondary')
|
||||
expect(beaconDot).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onReset when reset button is clicked', async () => {
|
||||
const handleReset = vi.fn()
|
||||
const { container } = render(<EditedBeacon onReset={handleReset} />)
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
|
||||
// Hover to show reset button
|
||||
fireEvent.mouseEnter(wrapper)
|
||||
|
||||
await waitFor(() => {
|
||||
const resetButton = container.querySelector('.bg-text-accent-secondary')
|
||||
expect(resetButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Find and click the reset button (the clickable element with onClick)
|
||||
const clickableElement = container.querySelector('.flex.size-4.items-center.justify-center.rounded-full.bg-text-accent-secondary')
|
||||
if (clickableElement) {
|
||||
fireEvent.click(clickableElement)
|
||||
}
|
||||
|
||||
expect(handleReset).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not call onReset when clicking beacon dot (not hovering)', () => {
|
||||
const handleReset = vi.fn()
|
||||
const { container } = render(<EditedBeacon onReset={handleReset} />)
|
||||
|
||||
// Click on the wrapper when not hovering
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
fireEvent.click(wrapper)
|
||||
|
||||
// onReset should not be called because we're not hovering
|
||||
expect(handleReset).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tooltip', () => {
|
||||
it('should render tooltip on hover', async () => {
|
||||
const handleReset = vi.fn()
|
||||
const { container } = render(<EditedBeacon onReset={handleReset} />)
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
fireEvent.mouseEnter(wrapper)
|
||||
|
||||
// Tooltip should be rendered (it wraps the reset button)
|
||||
await waitFor(() => {
|
||||
const resetIcon = container.querySelector('svg')
|
||||
expect(resetIcon).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle multiple hover/leave cycles', async () => {
|
||||
const handleReset = vi.fn()
|
||||
const { container } = render(<EditedBeacon onReset={handleReset} />)
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
fireEvent.mouseEnter(wrapper)
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.mouseLeave(wrapper)
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('.size-1.rounded-full')).toBeInTheDocument()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('should handle rapid hover/leave', async () => {
|
||||
const handleReset = vi.fn()
|
||||
const { container } = render(<EditedBeacon onReset={handleReset} />)
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
|
||||
// Rapid hover/leave
|
||||
fireEvent.mouseEnter(wrapper)
|
||||
fireEvent.mouseLeave(wrapper)
|
||||
fireEvent.mouseEnter(wrapper)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,269 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DataType } from '../types'
|
||||
import InputCombined from './input-combined'
|
||||
|
||||
type DatePickerProps = {
|
||||
value: number | null
|
||||
onChange: (value: number) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Mock the base date-picker component
|
||||
vi.mock('../base/date-picker', () => ({
|
||||
default: ({ value, onChange, className }: DatePickerProps) => (
|
||||
<div data-testid="date-picker" className={className} onClick={() => onChange(Date.now())}>
|
||||
{value || 'Pick date'}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('InputCombined', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const handleChange = vi.fn()
|
||||
const { container } = render(
|
||||
<InputCombined type={DataType.string} value="" onChange={handleChange} />,
|
||||
)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render text input for string type', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.string} value="test" onChange={handleChange} />,
|
||||
)
|
||||
const input = screen.getByDisplayValue('test')
|
||||
expect(input).toBeInTheDocument()
|
||||
expect(input.tagName.toLowerCase()).toBe('input')
|
||||
})
|
||||
|
||||
it('should render number input for number type', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.number} value={42} onChange={handleChange} />,
|
||||
)
|
||||
const input = screen.getByDisplayValue('42')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render date picker for time type', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.time} value={Date.now()} onChange={handleChange} />,
|
||||
)
|
||||
expect(screen.getByTestId('date-picker')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('String Input', () => {
|
||||
it('should call onChange with input value for string type', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.string} value="" onChange={handleChange} />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'new value' } })
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith('new value')
|
||||
})
|
||||
|
||||
it('should display current value for string type', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.string} value="existing value" onChange={handleChange} />,
|
||||
)
|
||||
|
||||
expect(screen.getByDisplayValue('existing value')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply readOnly prop to string input', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.string} value="test" onChange={handleChange} readOnly />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveAttribute('readonly')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Number Input', () => {
|
||||
it('should call onChange with number value for number type', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.number} value={0} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('spinbutton')
|
||||
fireEvent.change(input, { target: { value: '123' } })
|
||||
|
||||
expect(handleChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should display current value for number type', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.number} value={999} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
expect(screen.getByDisplayValue('999')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply readOnly prop to number input', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.number} value={42} onChange={handleChange} readOnly />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveAttribute('readonly')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Time/Date Input', () => {
|
||||
it('should render date picker for time type', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.time} value={1234567890} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('date-picker')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onChange when date is selected', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.time} value={null} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('date-picker'))
|
||||
expect(handleChange).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
const handleChange = vi.fn()
|
||||
const { container } = render(
|
||||
<InputCombined
|
||||
type={DataType.string}
|
||||
value=""
|
||||
onChange={handleChange}
|
||||
className="custom-class"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Check that custom class is applied to wrapper
|
||||
const wrapper = container.querySelector('.custom-class')
|
||||
expect(wrapper).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle null value for string type', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.string} value={null} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined value for string type', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.string} value={undefined as unknown as string} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle null value for number type', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.number} value={null} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should have correct base styling for string input', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.string} value="" onChange={handleChange} />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveClass('h-6', 'grow', 'p-0.5', 'text-xs', 'rounded-md')
|
||||
})
|
||||
|
||||
it('should have correct styling for number input', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.number} value={0} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveClass('rounded-l-md')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty string value', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.string} value="" onChange={handleChange} />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should handle zero value for number', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.number} value={0} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
expect(screen.getByDisplayValue('0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle negative number', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.number} value={-100} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
expect(screen.getByDisplayValue('-100')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle special characters in string', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.string} value={'<script>alert("xss")</script>'} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
expect(screen.getByDisplayValue('<script>alert("xss")</script>')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle switching between types', () => {
|
||||
const handleChange = vi.fn()
|
||||
const { rerender } = render(
|
||||
<InputCombined type={DataType.string} value="test" onChange={handleChange} />,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<InputCombined type={DataType.number} value={42} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('spinbutton')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,147 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import InputHasSetMultipleValue from './input-has-set-multiple-value'
|
||||
|
||||
describe('InputHasSetMultipleValue', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const handleClear = vi.fn()
|
||||
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with correct wrapper styling', () => {
|
||||
const handleClear = vi.fn()
|
||||
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
|
||||
expect(container.firstChild).toHaveClass('h-6', 'grow', 'rounded-md', 'bg-components-input-bg-normal', 'p-0.5')
|
||||
})
|
||||
|
||||
it('should render multiple value text', () => {
|
||||
const handleClear = vi.fn()
|
||||
render(<InputHasSetMultipleValue onClear={handleClear} />)
|
||||
// The text should come from i18n
|
||||
expect(screen.getByText(/multipleValue|Multiple/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render close icon when not readOnly', () => {
|
||||
const handleClear = vi.fn()
|
||||
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
|
||||
// Should have close icon (RiCloseLine)
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should not show close icon when readOnly is true', () => {
|
||||
const handleClear = vi.fn()
|
||||
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} readOnly />)
|
||||
// Should not have close icon
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show close icon when readOnly is false', () => {
|
||||
const handleClear = vi.fn()
|
||||
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} readOnly={false} />)
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show close icon when readOnly is undefined', () => {
|
||||
const handleClear = vi.fn()
|
||||
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} readOnly={undefined} />)
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply pr-1.5 padding when readOnly', () => {
|
||||
const handleClear = vi.fn()
|
||||
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} readOnly />)
|
||||
const badge = container.querySelector('.inline-flex')
|
||||
expect(badge).toHaveClass('pr-1.5')
|
||||
})
|
||||
|
||||
it('should apply pr-0.5 padding when not readOnly', () => {
|
||||
const handleClear = vi.fn()
|
||||
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
|
||||
const badge = container.querySelector('.inline-flex')
|
||||
expect(badge).toHaveClass('pr-0.5')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClear when close icon is clicked', () => {
|
||||
const handleClear = vi.fn()
|
||||
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
|
||||
|
||||
const closeIcon = container.querySelector('svg')
|
||||
expect(closeIcon).toBeInTheDocument()
|
||||
|
||||
if (closeIcon) {
|
||||
fireEvent.click(closeIcon)
|
||||
}
|
||||
|
||||
expect(handleClear).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not call onClear when readOnly and clicking on component', () => {
|
||||
const handleClear = vi.fn()
|
||||
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} readOnly />)
|
||||
|
||||
// Click on the wrapper
|
||||
fireEvent.click(container.firstChild as HTMLElement)
|
||||
|
||||
expect(handleClear).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onClear multiple times on multiple clicks', () => {
|
||||
const handleClear = vi.fn()
|
||||
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
|
||||
|
||||
const closeIcon = container.querySelector('svg')
|
||||
|
||||
if (closeIcon) {
|
||||
fireEvent.click(closeIcon)
|
||||
fireEvent.click(closeIcon)
|
||||
fireEvent.click(closeIcon)
|
||||
}
|
||||
|
||||
expect(handleClear).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should have badge styling', () => {
|
||||
const handleClear = vi.fn()
|
||||
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
|
||||
const badge = container.querySelector('.inline-flex')
|
||||
expect(badge).toHaveClass('h-5', 'items-center', 'rounded-[5px]', 'border-[0.5px]')
|
||||
})
|
||||
|
||||
it('should have hover styles on close button wrapper', () => {
|
||||
const handleClear = vi.fn()
|
||||
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
|
||||
const closeWrapper = container.querySelector('.cursor-pointer')
|
||||
expect(closeWrapper).toHaveClass('hover:bg-state-base-hover', 'hover:text-text-secondary')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render correctly when switching readOnly state', () => {
|
||||
const handleClear = vi.fn()
|
||||
const { container, rerender } = render(<InputHasSetMultipleValue onClear={handleClear} />)
|
||||
|
||||
// Initially not readOnly
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
|
||||
// Switch to readOnly
|
||||
rerender(<InputHasSetMultipleValue onClear={handleClear} readOnly />)
|
||||
expect(container.querySelector('svg')).not.toBeInTheDocument()
|
||||
|
||||
// Switch back to not readOnly
|
||||
rerender(<InputHasSetMultipleValue onClear={handleClear} readOnly={false} />)
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user