Compare commits

..

5 Commits

Author SHA1 Message Date
CodingOnStar
ed9623647e Merge remote-tracking branch 'origin/main' into refactor/base-comp 2026-03-03 16:00:41 +08:00
CodingOnStar
aa5a22991b refactor(toast): streamline toast component structure and improve cleanup logic
- Adjusted class names for consistency in the Toast component.
- Refactored the toastHandler.clear function to improve cleanup logic by using a dedicated unmountAndRemove function.
- Ensured proper handling of the timer for toast notifications.
2026-03-02 11:54:51 +08:00
CodingOnStar
4928917878 Merge remote-tracking branch 'origin/main' into refactor/base-comp 2026-03-02 11:51:03 +08:00
CodingOnStar
b00afff61e fix(tests): correct import paths in chat and context block test files 2026-03-02 11:31:59 +08:00
CodingOnStar
691248f477 test: add unit tests for various components including Alert, AppUnavailable, Badge, ThemeSelector, ThemeSwitcher, ActionButton, and AgentLogModal 2026-03-02 11:11:08 +08:00
122 changed files with 4419 additions and 9501 deletions

View File

@@ -204,16 +204,6 @@ When assigned to test a directory/path, test **ALL content** within that path:
> See [Test Structure Template](#test-structure-template) for correct import/mock patterns.
### `nuqs` Query State Testing (Required for URL State Hooks)
When a component or hook uses `useQueryState` / `useQueryStates`:
- ✅ Use `NuqsTestingAdapter` (prefer shared helpers in `web/test/nuqs-testing.tsx`)
- ✅ Assert URL synchronization via `onUrlUpdate` (`searchParams`, `options.history`)
- ✅ For custom parsers (`createParser`), keep `parse` and `serialize` bijective and add round-trip edge cases (`%2F`, `%25`, spaces, legacy encoded values)
- ✅ Verify default-clearing behavior (default values should be removed from URL when applicable)
- ⚠️ Only mock `nuqs` directly when URL behavior is explicitly out of scope for the test
## Core Principles
### 1. AAA Pattern (Arrange-Act-Assert)

View File

@@ -80,9 +80,6 @@ Use this checklist when generating or reviewing tests for Dify frontend componen
- [ ] Router mocks match actual Next.js API
- [ ] Mocks reflect actual component conditional behavior
- [ ] Only mock: API services, complex context providers, third-party libs
- [ ] For `nuqs` URL-state tests, wrap with `NuqsTestingAdapter` (prefer `web/test/nuqs-testing.tsx`)
- [ ] For `nuqs` URL-state tests, assert `onUrlUpdate` payload (`searchParams`, `options.history`)
- [ ] If custom `nuqs` parser exists, add round-trip tests for encoded edge cases (`%2F`, `%25`, spaces, legacy encoded values)
### Queries

View File

@@ -125,31 +125,6 @@ describe('Component', () => {
})
```
### 2.1 `nuqs` Query State (Preferred: Testing Adapter)
For tests that validate URL query behavior, use `NuqsTestingAdapter` instead of mocking `nuqs` directly.
```typescript
import { renderHookWithNuqs } from '@/test/nuqs-testing'
it('should sync query to URL with push history', async () => {
const { result, onUrlUpdate } = renderHookWithNuqs(() => useMyQueryState(), {
searchParams: '?page=1',
})
act(() => {
result.current.setQuery({ page: 2 })
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.options.history).toBe('push')
expect(update.searchParams.get('page')).toBe('2')
})
```
Use direct `vi.mock('nuqs')` only when URL synchronization is intentionally out of scope.
### 3. Portal Components (with Shared State)
```typescript

View File

@@ -1,37 +1,25 @@
version: 2
multi-ecosystem-groups:
python:
schedule:
interval: "weekly" # or whatever schedule you want
updates:
- package-ecosystem: "pip"
directory: "/api"
open-pull-requests-limit: 2
patterns: ["*"]
schedule:
interval: "weekly"
groups:
python-dependencies:
patterns:
- "*"
- package-ecosystem: "uv"
directory: "/api"
open-pull-requests-limit: 2
patterns: ["*"]
schedule:
interval: "weekly"
groups:
uv-dependencies:
patterns:
- "*"
- package-ecosystem: "npm"
directory: "/web"
schedule:
interval: "weekly"
open-pull-requests-limit: 2
groups:
storybook:
patterns:
- "storybook"
- "@storybook/*"
npm-dependencies:
patterns:
- "*"
exclude-patterns:
- "storybook"
- "@storybook/*"

View File

@@ -89,7 +89,7 @@ jobs:
uses: actions/setup-node@v6
if: steps.changed-files.outputs.any_changed == 'true'
with:
node-version: 22
node-version: 24
cache: pnpm
cache-dependency-path: ./web/pnpm-lock.yaml

View File

@@ -28,7 +28,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
cache: ''
cache-dependency-path: 'pnpm-lock.yaml'

View File

@@ -57,7 +57,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
cache: pnpm
cache-dependency-path: ./web/pnpm-lock.yaml

View File

@@ -39,7 +39,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
cache: pnpm
cache-dependency-path: ./web/pnpm-lock.yaml
@@ -83,7 +83,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
cache: pnpm
cache-dependency-path: ./web/pnpm-lock.yaml
@@ -457,7 +457,7 @@ jobs:
uses: actions/setup-node@v6
if: steps.changed-files.outputs.any_changed == 'true'
with:
node-version: 22
node-version: 24
cache: pnpm
cache-dependency-path: ./web/pnpm-lock.yaml

View File

@@ -120,8 +120,7 @@ class TencentTraceClient:
# Metrics exporter and instruments
try:
from opentelemetry.sdk.metrics import Histogram as SdkHistogram
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics import Histogram, MeterProvider
from opentelemetry.sdk.metrics.export import AggregationTemporality, PeriodicExportingMetricReader
protocol = os.getenv("OTEL_EXPORTER_OTLP_PROTOCOL", "").strip().lower()
@@ -129,7 +128,7 @@ class TencentTraceClient:
use_http_json = protocol in {"http/json", "http-json"}
# Tencent APM works best with delta aggregation temporality
preferred_temporality: dict[type, AggregationTemporality] = {SdkHistogram: AggregationTemporality.DELTA}
preferred_temporality: dict[type, AggregationTemporality] = {Histogram: AggregationTemporality.DELTA}
def _create_metric_exporter(exporter_cls, **kwargs):
"""Create metric exporter with preferred_temporality support"""

View File

@@ -14,10 +14,9 @@ from core.rag.retrieval.retrieval_methods import RetrievalMethod
from dify_graph.model_runtime.entities.model_entities import ModelType
from extensions.ext_database import db
from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole
from models.dataset import Dataset, DatasetPermissionEnum, Document, ExternalKnowledgeBindings, Pipeline
from models.dataset import Dataset, DatasetPermissionEnum, Document, ExternalKnowledgeBindings
from services.dataset_service import DatasetService
from services.entities.knowledge_entities.knowledge_entities import RerankingModel, RetrievalModel
from services.entities.knowledge_entities.rag_pipeline_entities import IconInfo, RagPipelineDatasetCreateEntity
from services.errors.dataset import DatasetNameDuplicateError
@@ -275,276 +274,6 @@ class TestDatasetServiceCreateDataset:
assert result.retrieval_model == retrieval_model.model_dump()
mock_check_reranking.assert_called_once_with(tenant.id, "cohere", "rerank-english-v2.0")
def test_create_internal_dataset_with_high_quality_indexing_custom_embedding(self, db_session_with_containers):
"""Create high-quality dataset with explicitly configured embedding model."""
# Arrange
account, tenant = DatasetServiceIntegrationDataFactory.create_account_with_tenant()
embedding_provider = "openai"
embedding_model_name = "text-embedding-3-small"
embedding_model = DatasetServiceIntegrationDataFactory.create_embedding_model(
provider=embedding_provider, model_name=embedding_model_name
)
# Act
with (
patch("services.dataset_service.ModelManager") as mock_model_manager,
patch("services.dataset_service.DatasetService.check_embedding_model_setting") as mock_check_embedding,
):
mock_model_manager.return_value.get_model_instance.return_value = embedding_model
result = DatasetService.create_empty_dataset(
tenant_id=tenant.id,
name="Custom Embedding Dataset",
description=None,
indexing_technique="high_quality",
account=account,
embedding_model_provider=embedding_provider,
embedding_model_name=embedding_model_name,
)
# Assert
db.session.refresh(result)
assert result.indexing_technique == "high_quality"
assert result.embedding_model_provider == embedding_provider
assert result.embedding_model == embedding_model_name
mock_check_embedding.assert_called_once_with(tenant.id, embedding_provider, embedding_model_name)
mock_model_manager.return_value.get_model_instance.assert_called_once_with(
tenant_id=tenant.id,
provider=embedding_provider,
model_type=ModelType.TEXT_EMBEDDING,
model=embedding_model_name,
)
def test_create_internal_dataset_with_retrieval_model(self, db_session_with_containers):
"""Persist retrieval model settings when creating an internal dataset."""
# Arrange
account, tenant = DatasetServiceIntegrationDataFactory.create_account_with_tenant()
retrieval_model = RetrievalModel(
search_method=RetrievalMethod.SEMANTIC_SEARCH,
reranking_enable=False,
top_k=2,
score_threshold_enabled=True,
score_threshold=0.0,
)
# Act
result = DatasetService.create_empty_dataset(
tenant_id=tenant.id,
name="Retrieval Model Dataset",
description=None,
indexing_technique=None,
account=account,
retrieval_model=retrieval_model,
)
# Assert
db.session.refresh(result)
assert result.retrieval_model == retrieval_model.model_dump()
def test_create_internal_dataset_with_custom_permission(self, db_session_with_containers):
"""Persist canonical custom permission when creating an internal dataset."""
# Arrange
account, tenant = DatasetServiceIntegrationDataFactory.create_account_with_tenant()
# Act
result = DatasetService.create_empty_dataset(
tenant_id=tenant.id,
name="Custom Permission Dataset",
description=None,
indexing_technique=None,
account=account,
permission=DatasetPermissionEnum.ALL_TEAM,
)
# Assert
db.session.refresh(result)
assert result.permission == DatasetPermissionEnum.ALL_TEAM
def test_create_external_dataset_missing_api_id_error(self, db_session_with_containers):
"""Raise error when external API template does not exist."""
# Arrange
account, tenant = DatasetServiceIntegrationDataFactory.create_account_with_tenant()
external_knowledge_api_id = str(uuid4())
# Act / Assert
with patch("services.dataset_service.ExternalDatasetService.get_external_knowledge_api") as mock_get_api:
mock_get_api.return_value = None
with pytest.raises(ValueError, match=r"External API template not found\.?"):
DatasetService.create_empty_dataset(
tenant_id=tenant.id,
name="External Missing API Dataset",
description=None,
indexing_technique=None,
account=account,
provider="external",
external_knowledge_api_id=external_knowledge_api_id,
external_knowledge_id="knowledge-123",
)
def test_create_external_dataset_missing_knowledge_id_error(self, db_session_with_containers):
"""Raise error when external knowledge id is missing for external dataset creation."""
# Arrange
account, tenant = DatasetServiceIntegrationDataFactory.create_account_with_tenant()
external_knowledge_api_id = str(uuid4())
# Act / Assert
with patch("services.dataset_service.ExternalDatasetService.get_external_knowledge_api") as mock_get_api:
mock_get_api.return_value = Mock(id=external_knowledge_api_id)
with pytest.raises(ValueError, match="external_knowledge_id is required"):
DatasetService.create_empty_dataset(
tenant_id=tenant.id,
name="External Missing Knowledge Dataset",
description=None,
indexing_technique=None,
account=account,
provider="external",
external_knowledge_api_id=external_knowledge_api_id,
external_knowledge_id=None,
)
class TestDatasetServiceCreateRagPipelineDataset:
"""Integration coverage for DatasetService.create_empty_rag_pipeline_dataset."""
def test_create_rag_pipeline_dataset_with_name_success(self, db_session_with_containers):
"""Create rag-pipeline dataset and pipeline rows when a name is provided."""
# Arrange
account, tenant = DatasetServiceIntegrationDataFactory.create_account_with_tenant()
icon_info = IconInfo(icon="📙", icon_background="#FFF4ED", icon_type="emoji")
entity = RagPipelineDatasetCreateEntity(
name="RAG Pipeline Dataset",
description="RAG Pipeline Description",
icon_info=icon_info,
permission=DatasetPermissionEnum.ONLY_ME,
)
# Act
with patch("services.dataset_service.current_user", account):
result = DatasetService.create_empty_rag_pipeline_dataset(
tenant_id=tenant.id, rag_pipeline_dataset_create_entity=entity
)
# Assert
created_dataset = db.session.get(Dataset, result.id)
created_pipeline = db.session.get(Pipeline, result.pipeline_id)
assert created_dataset is not None
assert created_dataset.name == entity.name
assert created_dataset.runtime_mode == "rag_pipeline"
assert created_dataset.created_by == account.id
assert created_dataset.permission == DatasetPermissionEnum.ONLY_ME
assert created_pipeline is not None
assert created_pipeline.name == entity.name
assert created_pipeline.created_by == account.id
def test_create_rag_pipeline_dataset_with_auto_generated_name(self, db_session_with_containers):
"""Create rag-pipeline dataset with generated incremental name when input name is empty."""
# Arrange
account, tenant = DatasetServiceIntegrationDataFactory.create_account_with_tenant()
generated_name = "Untitled 1"
icon_info = IconInfo(icon="📙", icon_background="#FFF4ED", icon_type="emoji")
entity = RagPipelineDatasetCreateEntity(
name="",
description="",
icon_info=icon_info,
permission=DatasetPermissionEnum.ONLY_ME,
)
# Act
with (
patch("services.dataset_service.current_user", account),
patch("services.dataset_service.generate_incremental_name") as mock_generate_name,
):
mock_generate_name.return_value = generated_name
result = DatasetService.create_empty_rag_pipeline_dataset(
tenant_id=tenant.id, rag_pipeline_dataset_create_entity=entity
)
# Assert
db.session.refresh(result)
created_pipeline = db.session.get(Pipeline, result.pipeline_id)
assert result.name == generated_name
assert created_pipeline is not None
assert created_pipeline.name == generated_name
mock_generate_name.assert_called_once()
def test_create_rag_pipeline_dataset_duplicate_name_error(self, db_session_with_containers):
"""Raise duplicate-name error when rag-pipeline dataset name already exists."""
# Arrange
account, tenant = DatasetServiceIntegrationDataFactory.create_account_with_tenant()
duplicate_name = "Duplicate RAG Dataset"
DatasetServiceIntegrationDataFactory.create_dataset(
tenant_id=tenant.id,
created_by=account.id,
name=duplicate_name,
indexing_technique=None,
)
db.session.commit()
icon_info = IconInfo(icon="📙", icon_background="#FFF4ED", icon_type="emoji")
entity = RagPipelineDatasetCreateEntity(
name=duplicate_name,
description="",
icon_info=icon_info,
permission=DatasetPermissionEnum.ONLY_ME,
)
# Act / Assert
with (
patch("services.dataset_service.current_user", account),
pytest.raises(DatasetNameDuplicateError, match=f"Dataset with name {duplicate_name} already exists"),
):
DatasetService.create_empty_rag_pipeline_dataset(
tenant_id=tenant.id, rag_pipeline_dataset_create_entity=entity
)
def test_create_rag_pipeline_dataset_with_custom_permission(self, db_session_with_containers):
"""Persist canonical custom permission for rag-pipeline dataset creation."""
# Arrange
account, tenant = DatasetServiceIntegrationDataFactory.create_account_with_tenant()
icon_info = IconInfo(icon="📙", icon_background="#FFF4ED", icon_type="emoji")
entity = RagPipelineDatasetCreateEntity(
name="Custom Permission RAG Dataset",
description="",
icon_info=icon_info,
permission=DatasetPermissionEnum.ALL_TEAM,
)
# Act
with patch("services.dataset_service.current_user", account):
result = DatasetService.create_empty_rag_pipeline_dataset(
tenant_id=tenant.id, rag_pipeline_dataset_create_entity=entity
)
# Assert
db.session.refresh(result)
assert result.permission == DatasetPermissionEnum.ALL_TEAM
def test_create_rag_pipeline_dataset_with_icon_info(self, db_session_with_containers):
"""Persist icon metadata when creating rag-pipeline dataset."""
# Arrange
account, tenant = DatasetServiceIntegrationDataFactory.create_account_with_tenant()
icon_info = IconInfo(
icon="📚",
icon_background="#E8F5E9",
icon_type="emoji",
icon_url="https://example.com/icon.png",
)
entity = RagPipelineDatasetCreateEntity(
name="Icon Info RAG Dataset",
description="",
icon_info=icon_info,
permission=DatasetPermissionEnum.ONLY_ME,
)
# Act
with patch("services.dataset_service.current_user", account):
result = DatasetService.create_empty_rag_pipeline_dataset(
tenant_id=tenant.id, rag_pipeline_dataset_create_entity=entity
)
# Assert
db.session.refresh(result)
assert result.icon_info == icon_info.model_dump()
class TestDatasetServiceUpdateAndDeleteDataset:
"""Integration coverage for SQL-backed update and delete behavior."""

View File

@@ -1,660 +0,0 @@
"""Integration tests for DocumentService.batch_update_document_status.
This suite validates SQL-backed batch status updates with testcontainers.
It keeps database access real and only patches non-DB side effects.
"""
import datetime
import json
from dataclasses import dataclass
from unittest.mock import call, patch
from uuid import uuid4
import pytest
from extensions.ext_database import db
from models.dataset import Dataset, Document
from services.dataset_service import DocumentService
from services.errors.document import DocumentIndexingError
FIXED_TIME = datetime.datetime(2023, 1, 1, 12, 0, 0)
@dataclass
class UserDouble:
"""Minimal user object for batch update operations."""
id: str
class DocumentBatchUpdateIntegrationDataFactory:
"""Factory for creating persisted entities used in integration tests."""
@staticmethod
def create_dataset(
dataset_id: str | None = None,
tenant_id: str | None = None,
name: str = "Test Dataset",
created_by: str | None = None,
) -> Dataset:
"""Create and persist a dataset."""
dataset = Dataset(
tenant_id=tenant_id or str(uuid4()),
name=name,
data_source_type="upload_file",
created_by=created_by or str(uuid4()),
)
if dataset_id:
dataset.id = dataset_id
db.session.add(dataset)
db.session.commit()
return dataset
@staticmethod
def create_document(
dataset: Dataset,
document_id: str | None = None,
name: str = "test_document.pdf",
enabled: bool = True,
archived: bool = False,
indexing_status: str = "completed",
completed_at: datetime.datetime | None = None,
position: int = 1,
created_by: str | None = None,
commit: bool = True,
**kwargs,
) -> Document:
"""Create a document bound to the given dataset and persist it."""
document = Document(
tenant_id=dataset.tenant_id,
dataset_id=dataset.id,
position=position,
data_source_type="upload_file",
data_source_info=json.dumps({"upload_file_id": str(uuid4())}),
batch=f"batch-{uuid4()}",
name=name,
created_from="web",
created_by=created_by or str(uuid4()),
doc_form="text_model",
)
document.id = document_id or str(uuid4())
document.enabled = enabled
document.archived = archived
document.indexing_status = indexing_status
document.completed_at = (
completed_at if completed_at is not None else (FIXED_TIME if indexing_status == "completed" else None)
)
for key, value in kwargs.items():
setattr(document, key, value)
db.session.add(document)
if commit:
db.session.commit()
return document
@staticmethod
def create_multiple_documents(
dataset: Dataset,
document_ids: list[str],
enabled: bool = True,
archived: bool = False,
indexing_status: str = "completed",
) -> list[Document]:
"""Create and persist multiple documents for one dataset in a single transaction."""
documents: list[Document] = []
for index, doc_id in enumerate(document_ids, start=1):
document = DocumentBatchUpdateIntegrationDataFactory.create_document(
dataset=dataset,
document_id=doc_id,
name=f"document_{doc_id}.pdf",
enabled=enabled,
archived=archived,
indexing_status=indexing_status,
position=index,
commit=False,
)
documents.append(document)
db.session.commit()
return documents
@staticmethod
def create_user(user_id: str | None = None) -> UserDouble:
"""Create a lightweight user for update metadata fields."""
return UserDouble(id=user_id or str(uuid4()))
class TestDatasetServiceBatchUpdateDocumentStatus:
"""Integration coverage for batch document status updates."""
@pytest.fixture
def patched_dependencies(self):
"""Patch non-DB collaborators only."""
with (
patch("services.dataset_service.redis_client") as redis_client,
patch("services.dataset_service.add_document_to_index_task") as add_task,
patch("services.dataset_service.remove_document_from_index_task") as remove_task,
patch("services.dataset_service.naive_utc_now") as naive_utc_now,
):
naive_utc_now.return_value = FIXED_TIME
redis_client.get.return_value = None
yield {
"redis_client": redis_client,
"add_task": add_task,
"remove_task": remove_task,
"naive_utc_now": naive_utc_now,
}
def _assert_document_enabled(self, document: Document, current_time: datetime.datetime):
"""Verify enabled-state fields after action=enable."""
assert document.enabled is True
assert document.disabled_at is None
assert document.disabled_by is None
assert document.updated_at == current_time
def _assert_document_disabled(self, document: Document, user_id: str, current_time: datetime.datetime):
"""Verify disabled-state fields after action=disable."""
assert document.enabled is False
assert document.disabled_at == current_time
assert document.disabled_by == user_id
assert document.updated_at == current_time
def _assert_document_archived(self, document: Document, user_id: str, current_time: datetime.datetime):
"""Verify archived-state fields after action=archive."""
assert document.archived is True
assert document.archived_at == current_time
assert document.archived_by == user_id
assert document.updated_at == current_time
def _assert_document_unarchived(self, document: Document):
"""Verify unarchived-state fields after action=un_archive."""
assert document.archived is False
assert document.archived_at is None
assert document.archived_by is None
def test_batch_update_enable_documents_success(self, db_session_with_containers, patched_dependencies):
"""Enable disabled documents and trigger indexing side effects."""
# Arrange
dataset = DocumentBatchUpdateIntegrationDataFactory.create_dataset()
user = DocumentBatchUpdateIntegrationDataFactory.create_user()
document_ids = [str(uuid4()), str(uuid4())]
disabled_docs = DocumentBatchUpdateIntegrationDataFactory.create_multiple_documents(
dataset=dataset,
document_ids=document_ids,
enabled=False,
)
# Act
DocumentService.batch_update_document_status(
dataset=dataset, document_ids=document_ids, action="enable", user=user
)
# Assert
for document in disabled_docs:
db.session.refresh(document)
self._assert_document_enabled(document, FIXED_TIME)
expected_get_calls = [call(f"document_{doc_id}_indexing") for doc_id in document_ids]
expected_setex_calls = [call(f"document_{doc_id}_indexing", 600, 1) for doc_id in document_ids]
expected_add_calls = [call(doc_id) for doc_id in document_ids]
patched_dependencies["redis_client"].get.assert_has_calls(expected_get_calls)
patched_dependencies["redis_client"].setex.assert_has_calls(expected_setex_calls)
patched_dependencies["add_task"].delay.assert_has_calls(expected_add_calls)
def test_batch_update_enable_already_enabled_document_skipped(
self, db_session_with_containers, patched_dependencies
):
"""Skip enable operation for already-enabled documents."""
# Arrange
dataset = DocumentBatchUpdateIntegrationDataFactory.create_dataset()
user = DocumentBatchUpdateIntegrationDataFactory.create_user()
document = DocumentBatchUpdateIntegrationDataFactory.create_document(dataset=dataset, enabled=True)
# Act
DocumentService.batch_update_document_status(
dataset=dataset,
document_ids=[document.id],
action="enable",
user=user,
)
# Assert
db.session.refresh(document)
assert document.enabled is True
patched_dependencies["redis_client"].setex.assert_not_called()
patched_dependencies["add_task"].delay.assert_not_called()
def test_batch_update_disable_documents_success(self, db_session_with_containers, patched_dependencies):
"""Disable completed documents and trigger remove-index tasks."""
# Arrange
dataset = DocumentBatchUpdateIntegrationDataFactory.create_dataset()
user = DocumentBatchUpdateIntegrationDataFactory.create_user()
document_ids = [str(uuid4()), str(uuid4())]
enabled_docs = DocumentBatchUpdateIntegrationDataFactory.create_multiple_documents(
dataset=dataset,
document_ids=document_ids,
enabled=True,
indexing_status="completed",
)
# Act
DocumentService.batch_update_document_status(
dataset=dataset,
document_ids=document_ids,
action="disable",
user=user,
)
# Assert
for document in enabled_docs:
db.session.refresh(document)
self._assert_document_disabled(document, user.id, FIXED_TIME)
expected_get_calls = [call(f"document_{doc_id}_indexing") for doc_id in document_ids]
expected_setex_calls = [call(f"document_{doc_id}_indexing", 600, 1) for doc_id in document_ids]
expected_remove_calls = [call(doc_id) for doc_id in document_ids]
patched_dependencies["redis_client"].get.assert_has_calls(expected_get_calls)
patched_dependencies["redis_client"].setex.assert_has_calls(expected_setex_calls)
patched_dependencies["remove_task"].delay.assert_has_calls(expected_remove_calls)
def test_batch_update_disable_already_disabled_document_skipped(
self, db_session_with_containers, patched_dependencies
):
"""Skip disable operation for already-disabled documents."""
# Arrange
dataset = DocumentBatchUpdateIntegrationDataFactory.create_dataset()
user = DocumentBatchUpdateIntegrationDataFactory.create_user()
disabled_doc = DocumentBatchUpdateIntegrationDataFactory.create_document(
dataset=dataset,
enabled=False,
indexing_status="completed",
completed_at=FIXED_TIME,
)
# Act
DocumentService.batch_update_document_status(
dataset=dataset,
document_ids=[disabled_doc.id],
action="disable",
user=user,
)
# Assert
db.session.refresh(disabled_doc)
assert disabled_doc.enabled is False
patched_dependencies["redis_client"].setex.assert_not_called()
patched_dependencies["remove_task"].delay.assert_not_called()
def test_batch_update_disable_non_completed_document_error(self, db_session_with_containers, patched_dependencies):
"""Raise error when disabling a non-completed document."""
# Arrange
dataset = DocumentBatchUpdateIntegrationDataFactory.create_dataset()
user = DocumentBatchUpdateIntegrationDataFactory.create_user()
non_completed_doc = DocumentBatchUpdateIntegrationDataFactory.create_document(
dataset=dataset,
enabled=True,
indexing_status="indexing",
completed_at=None,
)
# Act / Assert
with pytest.raises(DocumentIndexingError, match="is not completed"):
DocumentService.batch_update_document_status(
dataset=dataset,
document_ids=[non_completed_doc.id],
action="disable",
user=user,
)
def test_batch_update_archive_documents_success(self, db_session_with_containers, patched_dependencies):
"""Archive enabled documents and trigger remove-index task."""
# Arrange
dataset = DocumentBatchUpdateIntegrationDataFactory.create_dataset()
user = DocumentBatchUpdateIntegrationDataFactory.create_user()
document = DocumentBatchUpdateIntegrationDataFactory.create_document(
dataset=dataset, enabled=True, archived=False
)
# Act
DocumentService.batch_update_document_status(
dataset=dataset,
document_ids=[document.id],
action="archive",
user=user,
)
# Assert
db.session.refresh(document)
self._assert_document_archived(document, user.id, FIXED_TIME)
patched_dependencies["redis_client"].get.assert_called_once_with(f"document_{document.id}_indexing")
patched_dependencies["redis_client"].setex.assert_called_once_with(f"document_{document.id}_indexing", 600, 1)
patched_dependencies["remove_task"].delay.assert_called_once_with(document.id)
def test_batch_update_archive_already_archived_document_skipped(
self, db_session_with_containers, patched_dependencies
):
"""Skip archive operation for already-archived documents."""
# Arrange
dataset = DocumentBatchUpdateIntegrationDataFactory.create_dataset()
user = DocumentBatchUpdateIntegrationDataFactory.create_user()
document = DocumentBatchUpdateIntegrationDataFactory.create_document(
dataset=dataset, enabled=True, archived=True
)
# Act
DocumentService.batch_update_document_status(
dataset=dataset,
document_ids=[document.id],
action="archive",
user=user,
)
# Assert
db.session.refresh(document)
assert document.archived is True
patched_dependencies["redis_client"].setex.assert_not_called()
patched_dependencies["remove_task"].delay.assert_not_called()
def test_batch_update_archive_disabled_document_no_index_removal(
self, db_session_with_containers, patched_dependencies
):
"""Archive disabled document without index-removal side effects."""
# Arrange
dataset = DocumentBatchUpdateIntegrationDataFactory.create_dataset()
user = DocumentBatchUpdateIntegrationDataFactory.create_user()
document = DocumentBatchUpdateIntegrationDataFactory.create_document(
dataset=dataset, enabled=False, archived=False
)
# Act
DocumentService.batch_update_document_status(
dataset=dataset,
document_ids=[document.id],
action="archive",
user=user,
)
# Assert
db.session.refresh(document)
self._assert_document_archived(document, user.id, FIXED_TIME)
patched_dependencies["redis_client"].setex.assert_not_called()
patched_dependencies["remove_task"].delay.assert_not_called()
def test_batch_update_unarchive_documents_success(self, db_session_with_containers, patched_dependencies):
"""Unarchive enabled documents and trigger add-index task."""
# Arrange
dataset = DocumentBatchUpdateIntegrationDataFactory.create_dataset()
user = DocumentBatchUpdateIntegrationDataFactory.create_user()
document = DocumentBatchUpdateIntegrationDataFactory.create_document(
dataset=dataset, enabled=True, archived=True
)
# Act
DocumentService.batch_update_document_status(
dataset=dataset,
document_ids=[document.id],
action="un_archive",
user=user,
)
# Assert
db.session.refresh(document)
self._assert_document_unarchived(document)
assert document.updated_at == FIXED_TIME
patched_dependencies["redis_client"].get.assert_called_once_with(f"document_{document.id}_indexing")
patched_dependencies["redis_client"].setex.assert_called_once_with(f"document_{document.id}_indexing", 600, 1)
patched_dependencies["add_task"].delay.assert_called_once_with(document.id)
def test_batch_update_unarchive_already_unarchived_document_skipped(
self, db_session_with_containers, patched_dependencies
):
"""Skip unarchive operation for already-unarchived documents."""
# Arrange
dataset = DocumentBatchUpdateIntegrationDataFactory.create_dataset()
user = DocumentBatchUpdateIntegrationDataFactory.create_user()
document = DocumentBatchUpdateIntegrationDataFactory.create_document(
dataset=dataset, enabled=True, archived=False
)
# Act
DocumentService.batch_update_document_status(
dataset=dataset,
document_ids=[document.id],
action="un_archive",
user=user,
)
# Assert
db.session.refresh(document)
assert document.archived is False
patched_dependencies["redis_client"].setex.assert_not_called()
patched_dependencies["add_task"].delay.assert_not_called()
def test_batch_update_unarchive_disabled_document_no_index_addition(
self, db_session_with_containers, patched_dependencies
):
"""Unarchive disabled document without index-add side effects."""
# Arrange
dataset = DocumentBatchUpdateIntegrationDataFactory.create_dataset()
user = DocumentBatchUpdateIntegrationDataFactory.create_user()
document = DocumentBatchUpdateIntegrationDataFactory.create_document(
dataset=dataset, enabled=False, archived=True
)
# Act
DocumentService.batch_update_document_status(
dataset=dataset,
document_ids=[document.id],
action="un_archive",
user=user,
)
# Assert
db.session.refresh(document)
self._assert_document_unarchived(document)
assert document.updated_at == FIXED_TIME
patched_dependencies["redis_client"].setex.assert_not_called()
patched_dependencies["add_task"].delay.assert_not_called()
def test_batch_update_document_indexing_error_redis_cache_hit(
self, db_session_with_containers, patched_dependencies
):
"""Raise DocumentIndexingError when redis indicates active indexing."""
# Arrange
dataset = DocumentBatchUpdateIntegrationDataFactory.create_dataset()
user = DocumentBatchUpdateIntegrationDataFactory.create_user()
document = DocumentBatchUpdateIntegrationDataFactory.create_document(
dataset=dataset,
name="test_document.pdf",
enabled=True,
)
patched_dependencies["redis_client"].get.return_value = "indexing"
# Act / Assert
with pytest.raises(DocumentIndexingError, match="is being indexed") as exc_info:
DocumentService.batch_update_document_status(
dataset=dataset,
document_ids=[document.id],
action="enable",
user=user,
)
assert "test_document.pdf" in str(exc_info.value)
patched_dependencies["redis_client"].get.assert_called_once_with(f"document_{document.id}_indexing")
def test_batch_update_async_task_error_handling(self, db_session_with_containers, patched_dependencies):
"""Persist DB update, then propagate async task error."""
# Arrange
dataset = DocumentBatchUpdateIntegrationDataFactory.create_dataset()
user = DocumentBatchUpdateIntegrationDataFactory.create_user()
document = DocumentBatchUpdateIntegrationDataFactory.create_document(dataset=dataset, enabled=False)
patched_dependencies["add_task"].delay.side_effect = Exception("Celery task error")
# Act / Assert
with pytest.raises(Exception, match="Celery task error"):
DocumentService.batch_update_document_status(
dataset=dataset,
document_ids=[document.id],
action="enable",
user=user,
)
db.session.refresh(document)
self._assert_document_enabled(document, FIXED_TIME)
patched_dependencies["redis_client"].setex.assert_called_once_with(f"document_{document.id}_indexing", 600, 1)
def test_batch_update_empty_document_list(self, db_session_with_containers, patched_dependencies):
"""Return early when document_ids is empty."""
# Arrange
dataset = DocumentBatchUpdateIntegrationDataFactory.create_dataset()
user = DocumentBatchUpdateIntegrationDataFactory.create_user()
# Act
result = DocumentService.batch_update_document_status(
dataset=dataset, document_ids=[], action="enable", user=user
)
# Assert
assert result is None
patched_dependencies["redis_client"].get.assert_not_called()
patched_dependencies["redis_client"].setex.assert_not_called()
def test_batch_update_document_not_found_skipped(self, db_session_with_containers, patched_dependencies):
"""Skip IDs that do not map to existing dataset documents."""
# Arrange
dataset = DocumentBatchUpdateIntegrationDataFactory.create_dataset()
user = DocumentBatchUpdateIntegrationDataFactory.create_user()
missing_document_id = str(uuid4())
# Act
DocumentService.batch_update_document_status(
dataset=dataset,
document_ids=[missing_document_id],
action="enable",
user=user,
)
# Assert
patched_dependencies["redis_client"].get.assert_not_called()
patched_dependencies["redis_client"].setex.assert_not_called()
patched_dependencies["add_task"].delay.assert_not_called()
def test_batch_update_mixed_document_states_and_actions(self, db_session_with_containers, patched_dependencies):
"""Process only the applicable document in a mixed-state enable batch."""
# Arrange
dataset = DocumentBatchUpdateIntegrationDataFactory.create_dataset()
user = DocumentBatchUpdateIntegrationDataFactory.create_user()
disabled_doc = DocumentBatchUpdateIntegrationDataFactory.create_document(dataset=dataset, enabled=False)
enabled_doc = DocumentBatchUpdateIntegrationDataFactory.create_document(
dataset=dataset,
enabled=True,
position=2,
)
archived_doc = DocumentBatchUpdateIntegrationDataFactory.create_document(
dataset=dataset,
enabled=True,
archived=True,
position=3,
)
document_ids = [disabled_doc.id, enabled_doc.id, archived_doc.id]
# Act
DocumentService.batch_update_document_status(
dataset=dataset,
document_ids=document_ids,
action="enable",
user=user,
)
# Assert
db.session.refresh(disabled_doc)
db.session.refresh(enabled_doc)
db.session.refresh(archived_doc)
self._assert_document_enabled(disabled_doc, FIXED_TIME)
assert enabled_doc.enabled is True
assert archived_doc.enabled is True
patched_dependencies["redis_client"].setex.assert_called_once_with(
f"document_{disabled_doc.id}_indexing",
600,
1,
)
patched_dependencies["add_task"].delay.assert_called_once_with(disabled_doc.id)
def test_batch_update_large_document_list_performance(self, db_session_with_containers, patched_dependencies):
"""Handle large document lists with consistent updates and side effects."""
# Arrange
dataset = DocumentBatchUpdateIntegrationDataFactory.create_dataset()
user = DocumentBatchUpdateIntegrationDataFactory.create_user()
document_ids = [str(uuid4()) for _ in range(100)]
documents = DocumentBatchUpdateIntegrationDataFactory.create_multiple_documents(
dataset=dataset,
document_ids=document_ids,
enabled=False,
)
# Act
DocumentService.batch_update_document_status(
dataset=dataset,
document_ids=document_ids,
action="enable",
user=user,
)
# Assert
for document in documents:
db.session.refresh(document)
self._assert_document_enabled(document, FIXED_TIME)
assert patched_dependencies["redis_client"].setex.call_count == len(document_ids)
assert patched_dependencies["add_task"].delay.call_count == len(document_ids)
expected_setex_calls = [call(f"document_{doc_id}_indexing", 600, 1) for doc_id in document_ids]
expected_task_calls = [call(doc_id) for doc_id in document_ids]
patched_dependencies["redis_client"].setex.assert_has_calls(expected_setex_calls)
patched_dependencies["add_task"].delay.assert_has_calls(expected_task_calls)
def test_batch_update_mixed_document_states_complex_scenario(
self, db_session_with_containers, patched_dependencies
):
"""Process a complex mixed-state batch and update only eligible records."""
# Arrange
dataset = DocumentBatchUpdateIntegrationDataFactory.create_dataset()
user = DocumentBatchUpdateIntegrationDataFactory.create_user()
doc1 = DocumentBatchUpdateIntegrationDataFactory.create_document(dataset=dataset, enabled=False)
doc2 = DocumentBatchUpdateIntegrationDataFactory.create_document(dataset=dataset, enabled=True, position=2)
doc3 = DocumentBatchUpdateIntegrationDataFactory.create_document(dataset=dataset, enabled=True, position=3)
doc4 = DocumentBatchUpdateIntegrationDataFactory.create_document(dataset=dataset, enabled=True, position=4)
doc5 = DocumentBatchUpdateIntegrationDataFactory.create_document(
dataset=dataset,
enabled=True,
archived=True,
position=5,
)
missing_id = str(uuid4())
document_ids = [doc1.id, doc2.id, doc3.id, doc4.id, doc5.id, missing_id]
# Act
DocumentService.batch_update_document_status(
dataset=dataset,
document_ids=document_ids,
action="enable",
user=user,
)
# Assert
db.session.refresh(doc1)
db.session.refresh(doc2)
db.session.refresh(doc3)
db.session.refresh(doc4)
db.session.refresh(doc5)
self._assert_document_enabled(doc1, FIXED_TIME)
assert doc2.enabled is True
assert doc3.enabled is True
assert doc4.enabled is True
assert doc5.enabled is True
patched_dependencies["redis_client"].setex.assert_called_once_with(f"document_{doc1.id}_indexing", 600, 1)
patched_dependencies["add_task"].delay.assert_called_once_with(doc1.id)

View File

@@ -1,10 +1,13 @@
import datetime
from unittest.mock import Mock, patch
# Mock redis_client before importing dataset_service
from unittest.mock import Mock, call, patch
import pytest
from models.dataset import Dataset, Document
from services.dataset_service import DocumentService
from services.errors.document import DocumentIndexingError
from tests.unit_tests.conftest import redis_mock
@@ -45,6 +48,7 @@ class DocumentBatchUpdateTestDataFactory:
document.indexing_status = indexing_status
document.completed_at = completed_at or datetime.datetime.now()
# Set default values for optional fields
document.disabled_at = None
document.disabled_by = None
document.archived_at = None
@@ -55,9 +59,32 @@ class DocumentBatchUpdateTestDataFactory:
setattr(document, key, value)
return document
@staticmethod
def create_multiple_documents(
document_ids: list[str], enabled: bool = True, archived: bool = False, indexing_status: str = "completed"
) -> list[Mock]:
"""Create multiple mock documents with specified attributes."""
documents = []
for doc_id in document_ids:
doc = DocumentBatchUpdateTestDataFactory.create_document_mock(
document_id=doc_id,
name=f"document_{doc_id}.pdf",
enabled=enabled,
archived=archived,
indexing_status=indexing_status,
)
documents.append(doc)
return documents
class TestDatasetServiceBatchUpdateDocumentStatus:
"""Unit tests for non-SQL path in DocumentService.batch_update_document_status."""
"""
Comprehensive unit tests for DocumentService.batch_update_document_status method.
This test suite covers all supported actions (enable, disable, archive, un_archive),
error conditions, edge cases, and validates proper interaction with Redis cache,
database operations, and async task triggers.
"""
@pytest.fixture
def mock_document_service_dependencies(self):
@@ -77,24 +104,697 @@ class TestDatasetServiceBatchUpdateDocumentStatus:
"current_time": current_time,
}
@pytest.fixture
def mock_async_task_dependencies(self):
"""Mock setup for async task dependencies."""
with (
patch("services.dataset_service.add_document_to_index_task") as mock_add_task,
patch("services.dataset_service.remove_document_from_index_task") as mock_remove_task,
):
yield {"add_task": mock_add_task, "remove_task": mock_remove_task}
def _assert_document_enabled(self, document: Mock, user_id: str, current_time: datetime.datetime):
"""Helper method to verify document was enabled correctly."""
assert document.enabled == True
assert document.disabled_at is None
assert document.disabled_by is None
assert document.updated_at == current_time
def _assert_document_disabled(self, document: Mock, user_id: str, current_time: datetime.datetime):
"""Helper method to verify document was disabled correctly."""
assert document.enabled == False
assert document.disabled_at == current_time
assert document.disabled_by == user_id
assert document.updated_at == current_time
def _assert_document_archived(self, document: Mock, user_id: str, current_time: datetime.datetime):
"""Helper method to verify document was archived correctly."""
assert document.archived == True
assert document.archived_at == current_time
assert document.archived_by == user_id
assert document.updated_at == current_time
def _assert_document_unarchived(self, document: Mock):
"""Helper method to verify document was unarchived correctly."""
assert document.archived == False
assert document.archived_at is None
assert document.archived_by is None
def _assert_redis_cache_operations(self, document_ids: list[str], action: str = "setex"):
"""Helper method to verify Redis cache operations."""
if action == "setex":
expected_calls = [call(f"document_{doc_id}_indexing", 600, 1) for doc_id in document_ids]
redis_mock.setex.assert_has_calls(expected_calls)
elif action == "get":
expected_calls = [call(f"document_{doc_id}_indexing") for doc_id in document_ids]
redis_mock.get.assert_has_calls(expected_calls)
def _assert_async_task_calls(self, mock_task, document_ids: list[str], task_type: str):
"""Helper method to verify async task calls."""
expected_calls = [call(doc_id) for doc_id in document_ids]
if task_type in {"add", "remove"}:
mock_task.delay.assert_has_calls(expected_calls)
# ==================== Enable Document Tests ====================
def test_batch_update_enable_documents_success(
self, mock_document_service_dependencies, mock_async_task_dependencies
):
"""Test successful enabling of disabled documents."""
dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
user = DocumentBatchUpdateTestDataFactory.create_user_mock()
# Create disabled documents
disabled_docs = DocumentBatchUpdateTestDataFactory.create_multiple_documents(["doc-1", "doc-2"], enabled=False)
mock_document_service_dependencies["get_document"].side_effect = disabled_docs
# Reset module-level Redis mock
redis_mock.reset_mock()
redis_mock.get.return_value = None
# Call the method to enable documents
DocumentService.batch_update_document_status(
dataset=dataset, document_ids=["doc-1", "doc-2"], action="enable", user=user
)
# Verify document attributes were updated correctly
for doc in disabled_docs:
self._assert_document_enabled(doc, user.id, mock_document_service_dependencies["current_time"])
# Verify Redis cache operations
self._assert_redis_cache_operations(["doc-1", "doc-2"], "get")
self._assert_redis_cache_operations(["doc-1", "doc-2"], "setex")
# Verify async tasks were triggered for indexing
self._assert_async_task_calls(mock_async_task_dependencies["add_task"], ["doc-1", "doc-2"], "add")
# Verify database operations
mock_db = mock_document_service_dependencies["db_session"]
assert mock_db.add.call_count == 2
assert mock_db.commit.call_count == 1
def test_batch_update_enable_already_enabled_document_skipped(self, mock_document_service_dependencies):
"""Test enabling documents that are already enabled."""
dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
user = DocumentBatchUpdateTestDataFactory.create_user_mock()
# Create already enabled document
enabled_doc = DocumentBatchUpdateTestDataFactory.create_document_mock(enabled=True)
mock_document_service_dependencies["get_document"].return_value = enabled_doc
# Reset module-level Redis mock
redis_mock.reset_mock()
redis_mock.get.return_value = None
# Attempt to enable already enabled document
DocumentService.batch_update_document_status(
dataset=dataset, document_ids=["doc-1"], action="enable", user=user
)
# Verify no database operations occurred (document was skipped)
mock_db = mock_document_service_dependencies["db_session"]
mock_db.commit.assert_not_called()
# Verify no Redis setex operations occurred (document was skipped)
redis_mock.setex.assert_not_called()
# ==================== Disable Document Tests ====================
def test_batch_update_disable_documents_success(
self, mock_document_service_dependencies, mock_async_task_dependencies
):
"""Test successful disabling of enabled and completed documents."""
dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
user = DocumentBatchUpdateTestDataFactory.create_user_mock()
# Create enabled documents
enabled_docs = DocumentBatchUpdateTestDataFactory.create_multiple_documents(["doc-1", "doc-2"], enabled=True)
mock_document_service_dependencies["get_document"].side_effect = enabled_docs
# Reset module-level Redis mock
redis_mock.reset_mock()
redis_mock.get.return_value = None
# Call the method to disable documents
DocumentService.batch_update_document_status(
dataset=dataset, document_ids=["doc-1", "doc-2"], action="disable", user=user
)
# Verify document attributes were updated correctly
for doc in enabled_docs:
self._assert_document_disabled(doc, user.id, mock_document_service_dependencies["current_time"])
# Verify Redis cache operations for indexing prevention
self._assert_redis_cache_operations(["doc-1", "doc-2"], "setex")
# Verify async tasks were triggered to remove from index
self._assert_async_task_calls(mock_async_task_dependencies["remove_task"], ["doc-1", "doc-2"], "remove")
# Verify database operations
mock_db = mock_document_service_dependencies["db_session"]
assert mock_db.add.call_count == 2
assert mock_db.commit.call_count == 1
def test_batch_update_disable_already_disabled_document_skipped(
self, mock_document_service_dependencies, mock_async_task_dependencies
):
"""Test disabling documents that are already disabled."""
dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
user = DocumentBatchUpdateTestDataFactory.create_user_mock()
# Create already disabled document
disabled_doc = DocumentBatchUpdateTestDataFactory.create_document_mock(enabled=False)
mock_document_service_dependencies["get_document"].return_value = disabled_doc
# Reset module-level Redis mock
redis_mock.reset_mock()
redis_mock.get.return_value = None
# Attempt to disable already disabled document
DocumentService.batch_update_document_status(
dataset=dataset, document_ids=["doc-1"], action="disable", user=user
)
# Verify no database operations occurred (document was skipped)
mock_db = mock_document_service_dependencies["db_session"]
mock_db.commit.assert_not_called()
# Verify no Redis setex operations occurred (document was skipped)
redis_mock.setex.assert_not_called()
# Verify no async tasks were triggered (document was skipped)
mock_async_task_dependencies["add_task"].delay.assert_not_called()
def test_batch_update_disable_non_completed_document_error(self, mock_document_service_dependencies):
"""Test that DocumentIndexingError is raised when trying to disable non-completed documents."""
dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
user = DocumentBatchUpdateTestDataFactory.create_user_mock()
# Create a document that's not completed
non_completed_doc = DocumentBatchUpdateTestDataFactory.create_document_mock(
enabled=True,
indexing_status="indexing", # Not completed
completed_at=None, # Not completed
)
mock_document_service_dependencies["get_document"].return_value = non_completed_doc
# Verify that DocumentIndexingError is raised
with pytest.raises(DocumentIndexingError) as exc_info:
DocumentService.batch_update_document_status(
dataset=dataset, document_ids=["doc-1"], action="disable", user=user
)
# Verify error message indicates document is not completed
assert "is not completed" in str(exc_info.value)
# ==================== Archive Document Tests ====================
def test_batch_update_archive_documents_success(
self, mock_document_service_dependencies, mock_async_task_dependencies
):
"""Test successful archiving of unarchived documents."""
dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
user = DocumentBatchUpdateTestDataFactory.create_user_mock()
# Create unarchived enabled document
unarchived_doc = DocumentBatchUpdateTestDataFactory.create_document_mock(enabled=True, archived=False)
mock_document_service_dependencies["get_document"].return_value = unarchived_doc
# Reset module-level Redis mock
redis_mock.reset_mock()
redis_mock.get.return_value = None
# Call the method to archive documents
DocumentService.batch_update_document_status(
dataset=dataset, document_ids=["doc-1"], action="archive", user=user
)
# Verify document attributes were updated correctly
self._assert_document_archived(unarchived_doc, user.id, mock_document_service_dependencies["current_time"])
# Verify Redis cache was set (because document was enabled)
redis_mock.setex.assert_called_once_with("document_doc-1_indexing", 600, 1)
# Verify async task was triggered to remove from index (because enabled)
mock_async_task_dependencies["remove_task"].delay.assert_called_once_with("doc-1")
# Verify database operations
mock_db = mock_document_service_dependencies["db_session"]
mock_db.add.assert_called_once()
mock_db.commit.assert_called_once()
def test_batch_update_archive_already_archived_document_skipped(self, mock_document_service_dependencies):
"""Test archiving documents that are already archived."""
dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
user = DocumentBatchUpdateTestDataFactory.create_user_mock()
# Create already archived document
archived_doc = DocumentBatchUpdateTestDataFactory.create_document_mock(enabled=True, archived=True)
mock_document_service_dependencies["get_document"].return_value = archived_doc
# Reset module-level Redis mock
redis_mock.reset_mock()
redis_mock.get.return_value = None
# Attempt to archive already archived document
DocumentService.batch_update_document_status(
dataset=dataset, document_ids=["doc-3"], action="archive", user=user
)
# Verify no database operations occurred (document was skipped)
mock_db = mock_document_service_dependencies["db_session"]
mock_db.commit.assert_not_called()
# Verify no Redis setex operations occurred (document was skipped)
redis_mock.setex.assert_not_called()
def test_batch_update_archive_disabled_document_no_index_removal(
self, mock_document_service_dependencies, mock_async_task_dependencies
):
"""Test archiving disabled documents (should not trigger index removal)."""
dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
user = DocumentBatchUpdateTestDataFactory.create_user_mock()
# Set up disabled, unarchived document
disabled_unarchived_doc = DocumentBatchUpdateTestDataFactory.create_document_mock(enabled=False, archived=False)
mock_document_service_dependencies["get_document"].return_value = disabled_unarchived_doc
# Reset module-level Redis mock
redis_mock.reset_mock()
redis_mock.get.return_value = None
# Archive the disabled document
DocumentService.batch_update_document_status(
dataset=dataset, document_ids=["doc-1"], action="archive", user=user
)
# Verify document was archived
self._assert_document_archived(
disabled_unarchived_doc, user.id, mock_document_service_dependencies["current_time"]
)
# Verify no Redis cache was set (document is disabled)
redis_mock.setex.assert_not_called()
# Verify no index removal task was triggered (document is disabled)
mock_async_task_dependencies["remove_task"].delay.assert_not_called()
# Verify database operations still occurred
mock_db = mock_document_service_dependencies["db_session"]
mock_db.add.assert_called_once()
mock_db.commit.assert_called_once()
# ==================== Unarchive Document Tests ====================
def test_batch_update_unarchive_documents_success(
self, mock_document_service_dependencies, mock_async_task_dependencies
):
"""Test successful unarchiving of archived documents."""
dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
user = DocumentBatchUpdateTestDataFactory.create_user_mock()
# Create mock archived document
archived_doc = DocumentBatchUpdateTestDataFactory.create_document_mock(enabled=True, archived=True)
mock_document_service_dependencies["get_document"].return_value = archived_doc
# Reset module-level Redis mock
redis_mock.reset_mock()
redis_mock.get.return_value = None
# Call the method to unarchive documents
DocumentService.batch_update_document_status(
dataset=dataset, document_ids=["doc-1"], action="un_archive", user=user
)
# Verify document attributes were updated correctly
self._assert_document_unarchived(archived_doc)
assert archived_doc.updated_at == mock_document_service_dependencies["current_time"]
# Verify Redis cache was set (because document is enabled)
redis_mock.setex.assert_called_once_with("document_doc-1_indexing", 600, 1)
# Verify async task was triggered to add back to index (because enabled)
mock_async_task_dependencies["add_task"].delay.assert_called_once_with("doc-1")
# Verify database operations
mock_db = mock_document_service_dependencies["db_session"]
mock_db.add.assert_called_once()
mock_db.commit.assert_called_once()
def test_batch_update_unarchive_already_unarchived_document_skipped(
self, mock_document_service_dependencies, mock_async_task_dependencies
):
"""Test unarchiving documents that are already unarchived."""
dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
user = DocumentBatchUpdateTestDataFactory.create_user_mock()
# Create already unarchived document
unarchived_doc = DocumentBatchUpdateTestDataFactory.create_document_mock(enabled=True, archived=False)
mock_document_service_dependencies["get_document"].return_value = unarchived_doc
# Reset module-level Redis mock
redis_mock.reset_mock()
redis_mock.get.return_value = None
# Attempt to unarchive already unarchived document
DocumentService.batch_update_document_status(
dataset=dataset, document_ids=["doc-1"], action="un_archive", user=user
)
# Verify no database operations occurred (document was skipped)
mock_db = mock_document_service_dependencies["db_session"]
mock_db.commit.assert_not_called()
# Verify no Redis setex operations occurred (document was skipped)
redis_mock.setex.assert_not_called()
# Verify no async tasks were triggered (document was skipped)
mock_async_task_dependencies["add_task"].delay.assert_not_called()
def test_batch_update_unarchive_disabled_document_no_index_addition(
self, mock_document_service_dependencies, mock_async_task_dependencies
):
"""Test unarchiving disabled documents (should not trigger index addition)."""
dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
user = DocumentBatchUpdateTestDataFactory.create_user_mock()
# Create mock archived but disabled document
archived_disabled_doc = DocumentBatchUpdateTestDataFactory.create_document_mock(enabled=False, archived=True)
mock_document_service_dependencies["get_document"].return_value = archived_disabled_doc
# Reset module-level Redis mock
redis_mock.reset_mock()
redis_mock.get.return_value = None
# Unarchive the disabled document
DocumentService.batch_update_document_status(
dataset=dataset, document_ids=["doc-1"], action="un_archive", user=user
)
# Verify document was unarchived
self._assert_document_unarchived(archived_disabled_doc)
assert archived_disabled_doc.updated_at == mock_document_service_dependencies["current_time"]
# Verify no Redis cache was set (document is disabled)
redis_mock.setex.assert_not_called()
# Verify no index addition task was triggered (document is disabled)
mock_async_task_dependencies["add_task"].delay.assert_not_called()
# Verify database operations still occurred
mock_db = mock_document_service_dependencies["db_session"]
mock_db.add.assert_called_once()
mock_db.commit.assert_called_once()
# ==================== Error Handling Tests ====================
def test_batch_update_document_indexing_error_redis_cache_hit(self, mock_document_service_dependencies):
"""Test that DocumentIndexingError is raised when documents are currently being indexed."""
dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
user = DocumentBatchUpdateTestDataFactory.create_user_mock()
# Create mock enabled document
enabled_doc = DocumentBatchUpdateTestDataFactory.create_document_mock(enabled=True)
mock_document_service_dependencies["get_document"].return_value = enabled_doc
# Set up mock to indicate document is being indexed
redis_mock.reset_mock()
redis_mock.get.return_value = "indexing"
# Verify that DocumentIndexingError is raised
with pytest.raises(DocumentIndexingError) as exc_info:
DocumentService.batch_update_document_status(
dataset=dataset, document_ids=["doc-1"], action="enable", user=user
)
# Verify error message contains document name
assert "test_document.pdf" in str(exc_info.value)
assert "is being indexed" in str(exc_info.value)
# Verify Redis cache was checked
redis_mock.get.assert_called_once_with("document_doc-1_indexing")
def test_batch_update_invalid_action_error(self, mock_document_service_dependencies):
"""Test that ValueError is raised when an invalid action is provided."""
dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
user = DocumentBatchUpdateTestDataFactory.create_user_mock()
# Create mock document
doc = DocumentBatchUpdateTestDataFactory.create_document_mock(enabled=True)
mock_document_service_dependencies["get_document"].return_value = doc
# Reset module-level Redis mock
redis_mock.reset_mock()
redis_mock.get.return_value = None
# Test with invalid action
invalid_action = "invalid_action"
with pytest.raises(ValueError) as exc_info:
DocumentService.batch_update_document_status(
dataset=dataset, document_ids=["doc-1"], action=invalid_action, user=user
)
# Verify error message contains the invalid action
assert invalid_action in str(exc_info.value)
assert "Invalid action" in str(exc_info.value)
# Verify no Redis operations occurred
redis_mock.setex.assert_not_called()
def test_batch_update_async_task_error_handling(
self, mock_document_service_dependencies, mock_async_task_dependencies
):
"""Test handling of async task errors during batch operations."""
dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
user = DocumentBatchUpdateTestDataFactory.create_user_mock()
# Create mock disabled document
disabled_doc = DocumentBatchUpdateTestDataFactory.create_document_mock(enabled=False)
mock_document_service_dependencies["get_document"].return_value = disabled_doc
# Mock async task to raise an exception
mock_async_task_dependencies["add_task"].delay.side_effect = Exception("Celery task error")
# Reset module-level Redis mock
redis_mock.reset_mock()
redis_mock.get.return_value = None
# Verify that async task error is propagated
with pytest.raises(Exception) as exc_info:
DocumentService.batch_update_document_status(
dataset=dataset, document_ids=["doc-1"], action="enable", user=user
)
# Verify error message
assert "Celery task error" in str(exc_info.value)
# Verify database operations completed successfully
mock_db = mock_document_service_dependencies["db_session"]
mock_db.add.assert_called_once()
mock_db.commit.assert_called_once()
# Verify Redis cache was set successfully
redis_mock.setex.assert_called_once_with("document_doc-1_indexing", 600, 1)
# Verify document was updated
self._assert_document_enabled(disabled_doc, user.id, mock_document_service_dependencies["current_time"])
# ==================== Edge Case Tests ====================
def test_batch_update_empty_document_list(self, mock_document_service_dependencies):
"""Test batch operations with an empty document ID list."""
dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
user = DocumentBatchUpdateTestDataFactory.create_user_mock()
# Call method with empty document list
result = DocumentService.batch_update_document_status(
dataset=dataset, document_ids=[], action="enable", user=user
)
# Verify no document lookups were performed
mock_document_service_dependencies["get_document"].assert_not_called()
# Verify method returns None (early return)
assert result is None
def test_batch_update_document_not_found_skipped(self, mock_document_service_dependencies):
"""Test behavior when some documents don't exist in the database."""
dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
user = DocumentBatchUpdateTestDataFactory.create_user_mock()
# Mock document service to return None (document not found)
mock_document_service_dependencies["get_document"].return_value = None
# Call method with non-existent document ID
# This should not raise an error, just skip the missing document
try:
DocumentService.batch_update_document_status(
dataset=dataset, document_ids=["non-existent-doc"], action="enable", user=user
)
except Exception as e:
pytest.fail(f"Method should not raise exception for missing documents: {e}")
# Verify document lookup was attempted
mock_document_service_dependencies["get_document"].assert_called_once_with(dataset.id, "non-existent-doc")
def test_batch_update_mixed_document_states_and_actions(
self, mock_document_service_dependencies, mock_async_task_dependencies
):
"""Test batch operations on documents with mixed states and various scenarios."""
dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
user = DocumentBatchUpdateTestDataFactory.create_user_mock()
# Create documents in various states
disabled_doc = DocumentBatchUpdateTestDataFactory.create_document_mock("doc-1", enabled=False)
enabled_doc = DocumentBatchUpdateTestDataFactory.create_document_mock("doc-2", enabled=True)
archived_doc = DocumentBatchUpdateTestDataFactory.create_document_mock("doc-3", enabled=True, archived=True)
# Mix of different document states
documents = [disabled_doc, enabled_doc, archived_doc]
mock_document_service_dependencies["get_document"].side_effect = documents
# Reset module-level Redis mock
redis_mock.reset_mock()
redis_mock.get.return_value = None
# Perform enable operation on mixed state documents
DocumentService.batch_update_document_status(
dataset=dataset, document_ids=["doc-1", "doc-2", "doc-3"], action="enable", user=user
)
# Verify only the disabled document was processed
# (enabled and archived documents should be skipped for enable action)
# Only one add should occur (for the disabled document that was enabled)
mock_db = mock_document_service_dependencies["db_session"]
mock_db.add.assert_called_once()
# Only one commit should occur
mock_db.commit.assert_called_once()
# Only one Redis setex should occur (for the document that was enabled)
redis_mock.setex.assert_called_once_with("document_doc-1_indexing", 600, 1)
# Only one async task should be triggered (for the document that was enabled)
mock_async_task_dependencies["add_task"].delay.assert_called_once_with("doc-1")
# ==================== Performance Tests ====================
def test_batch_update_large_document_list_performance(
self, mock_document_service_dependencies, mock_async_task_dependencies
):
"""Test batch operations with a large number of documents."""
dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
user = DocumentBatchUpdateTestDataFactory.create_user_mock()
# Create large list of document IDs
document_ids = [f"doc-{i}" for i in range(1, 101)] # 100 documents
# Create mock documents
mock_documents = DocumentBatchUpdateTestDataFactory.create_multiple_documents(
document_ids,
enabled=False, # All disabled, will be enabled
)
mock_document_service_dependencies["get_document"].side_effect = mock_documents
# Reset module-level Redis mock
redis_mock.reset_mock()
redis_mock.get.return_value = None
# Perform batch enable operation
DocumentService.batch_update_document_status(
dataset=dataset, document_ids=document_ids, action="enable", user=user
)
# Verify all documents were processed
assert mock_document_service_dependencies["get_document"].call_count == 100
# Verify all documents were updated
for mock_doc in mock_documents:
self._assert_document_enabled(mock_doc, user.id, mock_document_service_dependencies["current_time"])
# Verify database operations
mock_db = mock_document_service_dependencies["db_session"]
assert mock_db.add.call_count == 100
assert mock_db.commit.call_count == 1
# Verify Redis cache operations occurred for each document
assert redis_mock.setex.call_count == 100
# Verify async tasks were triggered for each document
assert mock_async_task_dependencies["add_task"].delay.call_count == 100
# Verify correct Redis cache keys were set
expected_redis_calls = [call(f"document_doc-{i}_indexing", 600, 1) for i in range(1, 101)]
redis_mock.setex.assert_has_calls(expected_redis_calls)
# Verify correct async task calls
expected_task_calls = [call(f"doc-{i}") for i in range(1, 101)]
mock_async_task_dependencies["add_task"].delay.assert_has_calls(expected_task_calls)
def test_batch_update_mixed_document_states_complex_scenario(
self, mock_document_service_dependencies, mock_async_task_dependencies
):
"""Test complex batch operations with documents in various states."""
dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
user = DocumentBatchUpdateTestDataFactory.create_user_mock()
# Create documents in various states
doc1 = DocumentBatchUpdateTestDataFactory.create_document_mock("doc-1", enabled=False) # Will be enabled
doc2 = DocumentBatchUpdateTestDataFactory.create_document_mock(
"doc-2", enabled=True
) # Already enabled, will be skipped
doc3 = DocumentBatchUpdateTestDataFactory.create_document_mock(
"doc-3", enabled=True
) # Already enabled, will be skipped
doc4 = DocumentBatchUpdateTestDataFactory.create_document_mock(
"doc-4", enabled=True
) # Not affected by enable action
doc5 = DocumentBatchUpdateTestDataFactory.create_document_mock(
"doc-5", enabled=True, archived=True
) # Not affected by enable action
doc6 = None # Non-existent, will be skipped
mock_document_service_dependencies["get_document"].side_effect = [doc1, doc2, doc3, doc4, doc5, doc6]
# Reset module-level Redis mock
redis_mock.reset_mock()
redis_mock.get.return_value = None
# Perform mixed batch operations
DocumentService.batch_update_document_status(
dataset=dataset,
document_ids=["doc-1", "doc-2", "doc-3", "doc-4", "doc-5", "doc-6"],
action="enable", # This will only affect doc1
user=user,
)
# Verify document 1 was enabled
self._assert_document_enabled(doc1, user.id, mock_document_service_dependencies["current_time"])
# Verify other documents were skipped appropriately
assert doc2.enabled == True # No change
assert doc3.enabled == True # No change
assert doc4.enabled == True # No change
assert doc5.enabled == True # No change
# Verify database commits occurred for processed documents
# Only doc1 should be added (others were skipped, doc6 doesn't exist)
mock_db = mock_document_service_dependencies["db_session"]
assert mock_db.add.call_count == 1
assert mock_db.commit.call_count == 1
# Verify Redis cache operations occurred for processed documents
# Only doc1 should have Redis operations
assert redis_mock.setex.call_count == 1
# Verify async tasks were triggered for processed documents
# Only doc1 should trigger tasks
assert mock_async_task_dependencies["add_task"].delay.call_count == 1
# Verify correct Redis cache keys were set
expected_redis_calls = [call("document_doc-1_indexing", 600, 1)]
redis_mock.setex.assert_has_calls(expected_redis_calls)
# Verify correct async task calls
expected_task_calls = [call("doc-1")]
mock_async_task_dependencies["add_task"].delay.assert_has_calls(expected_task_calls)

View File

@@ -1,39 +1,726 @@
"""Unit tests for non-SQL validation paths in DatasetService dataset creation."""
"""
Comprehensive unit tests for DatasetService creation methods.
from unittest.mock import Mock, patch
This test suite covers:
- create_empty_dataset for internal datasets
- create_empty_dataset for external datasets
- create_empty_rag_pipeline_dataset
- Error conditions and edge cases
"""
from unittest.mock import Mock, create_autospec, patch
from uuid import uuid4
import pytest
from dify_graph.model_runtime.entities.model_entities import ModelType
from models.account import Account
from models.dataset import Dataset, Pipeline
from services.dataset_service import DatasetService
from services.entities.knowledge_entities.rag_pipeline_entities import IconInfo, RagPipelineDatasetCreateEntity
from services.entities.knowledge_entities.knowledge_entities import RetrievalModel
from services.entities.knowledge_entities.rag_pipeline_entities import (
IconInfo,
RagPipelineDatasetCreateEntity,
)
from services.errors.dataset import DatasetNameDuplicateError
class TestDatasetServiceCreateRagPipelineDatasetNonSQL:
"""Unit coverage for non-SQL validation in create_empty_rag_pipeline_dataset."""
class DatasetCreateTestDataFactory:
"""Factory class for creating test data and mock objects for dataset creation tests."""
@staticmethod
def create_account_mock(
account_id: str = "account-123",
tenant_id: str = "tenant-123",
**kwargs,
) -> Mock:
"""Create a mock account."""
account = create_autospec(Account, instance=True)
account.id = account_id
account.current_tenant_id = tenant_id
for key, value in kwargs.items():
setattr(account, key, value)
return account
@staticmethod
def create_embedding_model_mock(model: str = "text-embedding-ada-002", provider: str = "openai") -> Mock:
"""Create a mock embedding model."""
embedding_model = Mock()
embedding_model.model_name = model
embedding_model.provider = provider
return embedding_model
@staticmethod
def create_retrieval_model_mock() -> Mock:
"""Create a mock retrieval model."""
retrieval_model = Mock(spec=RetrievalModel)
retrieval_model.model_dump.return_value = {
"search_method": "semantic_search",
"top_k": 2,
"score_threshold": 0.0,
}
retrieval_model.reranking_model = None
return retrieval_model
@staticmethod
def create_external_knowledge_api_mock(api_id: str = "api-123", **kwargs) -> Mock:
"""Create a mock external knowledge API."""
api = Mock()
api.id = api_id
for key, value in kwargs.items():
setattr(api, key, value)
return api
@staticmethod
def create_dataset_mock(
dataset_id: str = "dataset-123",
name: str = "Test Dataset",
tenant_id: str = "tenant-123",
**kwargs,
) -> Mock:
"""Create a mock dataset."""
dataset = create_autospec(Dataset, instance=True)
dataset.id = dataset_id
dataset.name = name
dataset.tenant_id = tenant_id
for key, value in kwargs.items():
setattr(dataset, key, value)
return dataset
@staticmethod
def create_pipeline_mock(
pipeline_id: str = "pipeline-123",
name: str = "Test Pipeline",
**kwargs,
) -> Mock:
"""Create a mock pipeline."""
pipeline = Mock(spec=Pipeline)
pipeline.id = pipeline_id
pipeline.name = name
for key, value in kwargs.items():
setattr(pipeline, key, value)
return pipeline
class TestDatasetServiceCreateEmptyDataset:
"""
Comprehensive unit tests for DatasetService.create_empty_dataset method.
This test suite covers:
- Internal dataset creation (vendor provider)
- External dataset creation
- High quality indexing technique with embedding models
- Economy indexing technique
- Retrieval model configuration
- Error conditions (duplicate names, missing external knowledge IDs)
"""
@pytest.fixture
def mock_rag_pipeline_dependencies(self):
"""Patch database session and current_user for validation-only unit coverage."""
def mock_dataset_service_dependencies(self):
"""Common mock setup for dataset service dependencies."""
with (
patch("services.dataset_service.db.session") as mock_db,
patch("services.dataset_service.current_user") as mock_current_user,
patch("services.dataset_service.ModelManager") as mock_model_manager,
patch("services.dataset_service.DatasetService.check_embedding_model_setting") as mock_check_embedding,
patch("services.dataset_service.DatasetService.check_reranking_model_setting") as mock_check_reranking,
patch("services.dataset_service.ExternalDatasetService") as mock_external_service,
):
yield {
"db_session": mock_db,
"current_user_mock": mock_current_user,
"model_manager": mock_model_manager,
"check_embedding": mock_check_embedding,
"check_reranking": mock_check_reranking,
"external_service": mock_external_service,
}
def test_create_rag_pipeline_dataset_missing_current_user_error(self, mock_rag_pipeline_dependencies):
"""Raise ValueError when current_user.id is unavailable before SQL persistence."""
# ==================== Internal Dataset Creation Tests ====================
def test_create_internal_dataset_basic_success(self, mock_dataset_service_dependencies):
"""Test successful creation of basic internal dataset."""
# Arrange
tenant_id = str(uuid4())
mock_rag_pipeline_dependencies["current_user_mock"].id = None
account = DatasetCreateTestDataFactory.create_account_mock(tenant_id=tenant_id)
name = "Test Dataset"
description = "Test description"
# Mock database query to return None (no duplicate name)
mock_query = Mock()
mock_query.filter_by.return_value.first.return_value = None
mock_dataset_service_dependencies["db_session"].query.return_value = mock_query
# Mock database session operations
mock_db = mock_dataset_service_dependencies["db_session"]
mock_db.add = Mock()
mock_db.flush = Mock()
mock_db.commit = Mock()
# Act
result = DatasetService.create_empty_dataset(
tenant_id=tenant_id,
name=name,
description=description,
indexing_technique=None,
account=account,
)
# Assert
assert result is not None
assert result.name == name
assert result.description == description
assert result.tenant_id == tenant_id
assert result.created_by == account.id
assert result.updated_by == account.id
assert result.provider == "vendor"
assert result.permission == "only_me"
mock_db.add.assert_called_once()
mock_db.commit.assert_called_once()
def test_create_internal_dataset_with_economy_indexing(self, mock_dataset_service_dependencies):
"""Test successful creation of internal dataset with economy indexing."""
# Arrange
tenant_id = str(uuid4())
account = DatasetCreateTestDataFactory.create_account_mock(tenant_id=tenant_id)
name = "Economy Dataset"
# Mock database query
mock_query = Mock()
mock_query.filter_by.return_value.first.return_value = None
mock_dataset_service_dependencies["db_session"].query.return_value = mock_query
mock_db = mock_dataset_service_dependencies["db_session"]
mock_db.add = Mock()
mock_db.flush = Mock()
mock_db.commit = Mock()
# Act
result = DatasetService.create_empty_dataset(
tenant_id=tenant_id,
name=name,
description=None,
indexing_technique="economy",
account=account,
)
# Assert
assert result.indexing_technique == "economy"
assert result.embedding_model_provider is None
assert result.embedding_model is None
mock_db.commit.assert_called_once()
def test_create_internal_dataset_with_high_quality_indexing_default_embedding(
self, mock_dataset_service_dependencies
):
"""Test creation with high_quality indexing using default embedding model."""
# Arrange
tenant_id = str(uuid4())
account = DatasetCreateTestDataFactory.create_account_mock(tenant_id=tenant_id)
name = "High Quality Dataset"
# Mock database query
mock_query = Mock()
mock_query.filter_by.return_value.first.return_value = None
mock_dataset_service_dependencies["db_session"].query.return_value = mock_query
# Mock model manager
embedding_model = DatasetCreateTestDataFactory.create_embedding_model_mock()
mock_model_manager_instance = Mock()
mock_model_manager_instance.get_default_model_instance.return_value = embedding_model
mock_dataset_service_dependencies["model_manager"].return_value = mock_model_manager_instance
mock_db = mock_dataset_service_dependencies["db_session"]
mock_db.add = Mock()
mock_db.flush = Mock()
mock_db.commit = Mock()
# Act
result = DatasetService.create_empty_dataset(
tenant_id=tenant_id,
name=name,
description=None,
indexing_technique="high_quality",
account=account,
)
# Assert
assert result.indexing_technique == "high_quality"
assert result.embedding_model_provider == embedding_model.provider
assert result.embedding_model == embedding_model.model_name
mock_model_manager_instance.get_default_model_instance.assert_called_once_with(
tenant_id=tenant_id, model_type=ModelType.TEXT_EMBEDDING
)
mock_db.commit.assert_called_once()
def test_create_internal_dataset_with_high_quality_indexing_custom_embedding(
self, mock_dataset_service_dependencies
):
"""Test creation with high_quality indexing using custom embedding model."""
# Arrange
tenant_id = str(uuid4())
account = DatasetCreateTestDataFactory.create_account_mock(tenant_id=tenant_id)
name = "Custom Embedding Dataset"
embedding_provider = "openai"
embedding_model_name = "text-embedding-3-small"
# Mock database query
mock_query = Mock()
mock_query.filter_by.return_value.first.return_value = None
mock_dataset_service_dependencies["db_session"].query.return_value = mock_query
# Mock model manager
embedding_model = DatasetCreateTestDataFactory.create_embedding_model_mock(
model=embedding_model_name, provider=embedding_provider
)
mock_model_manager_instance = Mock()
mock_model_manager_instance.get_model_instance.return_value = embedding_model
mock_dataset_service_dependencies["model_manager"].return_value = mock_model_manager_instance
mock_db = mock_dataset_service_dependencies["db_session"]
mock_db.add = Mock()
mock_db.flush = Mock()
mock_db.commit = Mock()
# Act
result = DatasetService.create_empty_dataset(
tenant_id=tenant_id,
name=name,
description=None,
indexing_technique="high_quality",
account=account,
embedding_model_provider=embedding_provider,
embedding_model_name=embedding_model_name,
)
# Assert
assert result.indexing_technique == "high_quality"
assert result.embedding_model_provider == embedding_provider
assert result.embedding_model == embedding_model_name
mock_dataset_service_dependencies["check_embedding"].assert_called_once_with(
tenant_id, embedding_provider, embedding_model_name
)
mock_model_manager_instance.get_model_instance.assert_called_once_with(
tenant_id=tenant_id,
provider=embedding_provider,
model_type=ModelType.TEXT_EMBEDDING,
model=embedding_model_name,
)
mock_db.commit.assert_called_once()
def test_create_internal_dataset_with_retrieval_model(self, mock_dataset_service_dependencies):
"""Test creation with retrieval model configuration."""
# Arrange
tenant_id = str(uuid4())
account = DatasetCreateTestDataFactory.create_account_mock(tenant_id=tenant_id)
name = "Retrieval Model Dataset"
# Mock database query
mock_query = Mock()
mock_query.filter_by.return_value.first.return_value = None
mock_dataset_service_dependencies["db_session"].query.return_value = mock_query
# Mock retrieval model
retrieval_model = DatasetCreateTestDataFactory.create_retrieval_model_mock()
retrieval_model_dict = {"search_method": "semantic_search", "top_k": 2, "score_threshold": 0.0}
mock_db = mock_dataset_service_dependencies["db_session"]
mock_db.add = Mock()
mock_db.flush = Mock()
mock_db.commit = Mock()
# Act
result = DatasetService.create_empty_dataset(
tenant_id=tenant_id,
name=name,
description=None,
indexing_technique=None,
account=account,
retrieval_model=retrieval_model,
)
# Assert
assert result.retrieval_model == retrieval_model_dict
retrieval_model.model_dump.assert_called_once()
mock_db.commit.assert_called_once()
def test_create_internal_dataset_with_retrieval_model_reranking(self, mock_dataset_service_dependencies):
"""Test creation with retrieval model that includes reranking."""
# Arrange
tenant_id = str(uuid4())
account = DatasetCreateTestDataFactory.create_account_mock(tenant_id=tenant_id)
name = "Reranking Dataset"
# Mock database query
mock_query = Mock()
mock_query.filter_by.return_value.first.return_value = None
mock_dataset_service_dependencies["db_session"].query.return_value = mock_query
# Mock model manager
embedding_model = DatasetCreateTestDataFactory.create_embedding_model_mock()
mock_model_manager_instance = Mock()
mock_model_manager_instance.get_default_model_instance.return_value = embedding_model
mock_dataset_service_dependencies["model_manager"].return_value = mock_model_manager_instance
# Mock retrieval model with reranking
reranking_model = Mock()
reranking_model.reranking_provider_name = "cohere"
reranking_model.reranking_model_name = "rerank-english-v3.0"
retrieval_model = DatasetCreateTestDataFactory.create_retrieval_model_mock()
retrieval_model.reranking_model = reranking_model
mock_db = mock_dataset_service_dependencies["db_session"]
mock_db.add = Mock()
mock_db.flush = Mock()
mock_db.commit = Mock()
# Act
result = DatasetService.create_empty_dataset(
tenant_id=tenant_id,
name=name,
description=None,
indexing_technique="high_quality",
account=account,
retrieval_model=retrieval_model,
)
# Assert
mock_dataset_service_dependencies["check_reranking"].assert_called_once_with(
tenant_id, "cohere", "rerank-english-v3.0"
)
mock_db.commit.assert_called_once()
def test_create_internal_dataset_with_custom_permission(self, mock_dataset_service_dependencies):
"""Test creation with custom permission setting."""
# Arrange
tenant_id = str(uuid4())
account = DatasetCreateTestDataFactory.create_account_mock(tenant_id=tenant_id)
name = "Custom Permission Dataset"
# Mock database query
mock_query = Mock()
mock_query.filter_by.return_value.first.return_value = None
mock_dataset_service_dependencies["db_session"].query.return_value = mock_query
mock_db = mock_dataset_service_dependencies["db_session"]
mock_db.add = Mock()
mock_db.flush = Mock()
mock_db.commit = Mock()
# Act
result = DatasetService.create_empty_dataset(
tenant_id=tenant_id,
name=name,
description=None,
indexing_technique=None,
account=account,
permission="all_team_members",
)
# Assert
assert result.permission == "all_team_members"
mock_db.commit.assert_called_once()
# ==================== External Dataset Creation Tests ====================
def test_create_external_dataset_success(self, mock_dataset_service_dependencies):
"""Test successful creation of external dataset."""
# Arrange
tenant_id = str(uuid4())
account = DatasetCreateTestDataFactory.create_account_mock(tenant_id=tenant_id)
name = "External Dataset"
external_api_id = "external-api-123"
external_knowledge_id = "external-knowledge-456"
# Mock database query
mock_query = Mock()
mock_query.filter_by.return_value.first.return_value = None
mock_dataset_service_dependencies["db_session"].query.return_value = mock_query
# Mock external knowledge API
external_api = DatasetCreateTestDataFactory.create_external_knowledge_api_mock(api_id=external_api_id)
mock_dataset_service_dependencies["external_service"].get_external_knowledge_api.return_value = external_api
mock_db = mock_dataset_service_dependencies["db_session"]
mock_db.add = Mock()
mock_db.flush = Mock()
mock_db.commit = Mock()
# Act
result = DatasetService.create_empty_dataset(
tenant_id=tenant_id,
name=name,
description=None,
indexing_technique=None,
account=account,
provider="external",
external_knowledge_api_id=external_api_id,
external_knowledge_id=external_knowledge_id,
)
# Assert
assert result.provider == "external"
assert mock_db.add.call_count == 2 # Dataset + ExternalKnowledgeBindings
mock_dataset_service_dependencies["external_service"].get_external_knowledge_api.assert_called_once_with(
external_api_id
)
mock_db.commit.assert_called_once()
def test_create_external_dataset_missing_api_id_error(self, mock_dataset_service_dependencies):
"""Test error when external knowledge API is not found."""
# Arrange
tenant_id = str(uuid4())
account = DatasetCreateTestDataFactory.create_account_mock(tenant_id=tenant_id)
name = "External Dataset"
external_api_id = "non-existent-api"
# Mock database query
mock_query = Mock()
mock_query.filter_by.return_value.first.return_value = None
mock_dataset_service_dependencies["db_session"].query.return_value = mock_query
# Mock external knowledge API not found
mock_dataset_service_dependencies["external_service"].get_external_knowledge_api.return_value = None
mock_db = mock_dataset_service_dependencies["db_session"]
mock_db.add = Mock()
mock_db.flush = Mock()
# Act & Assert
with pytest.raises(ValueError, match="External API template not found"):
DatasetService.create_empty_dataset(
tenant_id=tenant_id,
name=name,
description=None,
indexing_technique=None,
account=account,
provider="external",
external_knowledge_api_id=external_api_id,
external_knowledge_id="knowledge-123",
)
def test_create_external_dataset_missing_knowledge_id_error(self, mock_dataset_service_dependencies):
"""Test error when external knowledge ID is missing."""
# Arrange
tenant_id = str(uuid4())
account = DatasetCreateTestDataFactory.create_account_mock(tenant_id=tenant_id)
name = "External Dataset"
external_api_id = "external-api-123"
# Mock database query
mock_query = Mock()
mock_query.filter_by.return_value.first.return_value = None
mock_dataset_service_dependencies["db_session"].query.return_value = mock_query
# Mock external knowledge API
external_api = DatasetCreateTestDataFactory.create_external_knowledge_api_mock(api_id=external_api_id)
mock_dataset_service_dependencies["external_service"].get_external_knowledge_api.return_value = external_api
mock_db = mock_dataset_service_dependencies["db_session"]
mock_db.add = Mock()
mock_db.flush = Mock()
# Act & Assert
with pytest.raises(ValueError, match="external_knowledge_id is required"):
DatasetService.create_empty_dataset(
tenant_id=tenant_id,
name=name,
description=None,
indexing_technique=None,
account=account,
provider="external",
external_knowledge_api_id=external_api_id,
external_knowledge_id=None,
)
# ==================== Error Handling Tests ====================
def test_create_dataset_duplicate_name_error(self, mock_dataset_service_dependencies):
"""Test error when dataset name already exists."""
# Arrange
tenant_id = str(uuid4())
account = DatasetCreateTestDataFactory.create_account_mock(tenant_id=tenant_id)
name = "Duplicate Dataset"
# Mock database query to return existing dataset
existing_dataset = DatasetCreateTestDataFactory.create_dataset_mock(name=name)
mock_query = Mock()
mock_query.filter_by.return_value.first.return_value = existing_dataset
mock_dataset_service_dependencies["db_session"].query.return_value = mock_query
# Act & Assert
with pytest.raises(DatasetNameDuplicateError, match=f"Dataset with name {name} already exists"):
DatasetService.create_empty_dataset(
tenant_id=tenant_id,
name=name,
description=None,
indexing_technique=None,
account=account,
)
class TestDatasetServiceCreateEmptyRagPipelineDataset:
"""
Comprehensive unit tests for DatasetService.create_empty_rag_pipeline_dataset method.
This test suite covers:
- RAG pipeline dataset creation with provided name
- RAG pipeline dataset creation with auto-generated name
- Pipeline creation
- Error conditions (duplicate names, missing current user)
"""
@pytest.fixture
def mock_rag_pipeline_dependencies(self):
"""Common mock setup for RAG pipeline dataset creation."""
with (
patch("services.dataset_service.db.session") as mock_db,
patch("services.dataset_service.current_user") as mock_current_user,
patch("services.dataset_service.generate_incremental_name") as mock_generate_name,
):
# Configure mock_current_user to behave like a Flask-Login proxy
# Default: no user (falsy)
mock_current_user.id = None
yield {
"db_session": mock_db,
"current_user_mock": mock_current_user,
"generate_name": mock_generate_name,
}
def test_create_rag_pipeline_dataset_with_name_success(self, mock_rag_pipeline_dependencies):
"""Test successful creation of RAG pipeline dataset with provided name."""
# Arrange
tenant_id = str(uuid4())
user_id = str(uuid4())
name = "RAG Pipeline Dataset"
description = "RAG Pipeline Description"
# Mock current user - set up the mock to have id attribute accessible directly
mock_rag_pipeline_dependencies["current_user_mock"].id = user_id
# Mock database query (no duplicate name)
mock_query = Mock()
mock_query.filter_by.return_value.first.return_value = None
mock_rag_pipeline_dependencies["db_session"].query.return_value = mock_query
# Mock database operations
mock_db = mock_rag_pipeline_dependencies["db_session"]
mock_db.add = Mock()
mock_db.flush = Mock()
mock_db.commit = Mock()
# Create entity
icon_info = IconInfo(icon="📙", icon_background="#FFF4ED", icon_type="emoji")
entity = RagPipelineDatasetCreateEntity(
name=name,
description=description,
icon_info=icon_info,
permission="only_me",
)
# Act
result = DatasetService.create_empty_rag_pipeline_dataset(
tenant_id=tenant_id, rag_pipeline_dataset_create_entity=entity
)
# Assert
assert result is not None
assert result.name == name
assert result.description == description
assert result.tenant_id == tenant_id
assert result.created_by == user_id
assert result.provider == "vendor"
assert result.runtime_mode == "rag_pipeline"
assert result.permission == "only_me"
assert mock_db.add.call_count == 2 # Pipeline + Dataset
mock_db.commit.assert_called_once()
def test_create_rag_pipeline_dataset_with_auto_generated_name(self, mock_rag_pipeline_dependencies):
"""Test creation of RAG pipeline dataset with auto-generated name."""
# Arrange
tenant_id = str(uuid4())
user_id = str(uuid4())
auto_name = "Untitled 1"
# Mock current user - set up the mock to have id attribute accessible directly
mock_rag_pipeline_dependencies["current_user_mock"].id = user_id
# Mock database query (empty name, need to generate)
mock_query = Mock()
mock_query.filter_by.return_value.all.return_value = []
mock_rag_pipeline_dependencies["db_session"].query.return_value = mock_query
# Mock name generation
mock_rag_pipeline_dependencies["generate_name"].return_value = auto_name
# Mock database operations
mock_db = mock_rag_pipeline_dependencies["db_session"]
mock_db.add = Mock()
mock_db.flush = Mock()
mock_db.commit = Mock()
# Create entity with empty name
icon_info = IconInfo(icon="📙", icon_background="#FFF4ED", icon_type="emoji")
entity = RagPipelineDatasetCreateEntity(
name="",
description="",
icon_info=icon_info,
permission="only_me",
)
# Act
result = DatasetService.create_empty_rag_pipeline_dataset(
tenant_id=tenant_id, rag_pipeline_dataset_create_entity=entity
)
# Assert
assert result.name == auto_name
mock_rag_pipeline_dependencies["generate_name"].assert_called_once()
mock_db.commit.assert_called_once()
def test_create_rag_pipeline_dataset_duplicate_name_error(self, mock_rag_pipeline_dependencies):
"""Test error when RAG pipeline dataset name already exists."""
# Arrange
tenant_id = str(uuid4())
user_id = str(uuid4())
name = "Duplicate RAG Dataset"
# Mock current user - set up the mock to have id attribute accessible directly
mock_rag_pipeline_dependencies["current_user_mock"].id = user_id
# Mock database query to return existing dataset
existing_dataset = DatasetCreateTestDataFactory.create_dataset_mock(name=name)
mock_query = Mock()
mock_query.filter_by.return_value.first.return_value = existing_dataset
mock_rag_pipeline_dependencies["db_session"].query.return_value = mock_query
# Create entity
icon_info = IconInfo(icon="📙", icon_background="#FFF4ED", icon_type="emoji")
entity = RagPipelineDatasetCreateEntity(
name=name,
description="",
icon_info=icon_info,
permission="only_me",
)
# Act & Assert
with pytest.raises(DatasetNameDuplicateError, match=f"Dataset with name {name} already exists"):
DatasetService.create_empty_rag_pipeline_dataset(
tenant_id=tenant_id, rag_pipeline_dataset_create_entity=entity
)
def test_create_rag_pipeline_dataset_missing_current_user_error(self, mock_rag_pipeline_dependencies):
"""Test error when current user is not available."""
# Arrange
tenant_id = str(uuid4())
# Mock current user as None - set id to None so the check fails
mock_rag_pipeline_dependencies["current_user_mock"].id = None
# Mock database query
mock_query = Mock()
mock_query.filter_by.return_value.first.return_value = None
mock_rag_pipeline_dependencies["db_session"].query.return_value = mock_query
# Create entity
icon_info = IconInfo(icon="📙", icon_background="#FFF4ED", icon_type="emoji")
entity = RagPipelineDatasetCreateEntity(
name="Test Dataset",
@@ -42,9 +729,91 @@ class TestDatasetServiceCreateRagPipelineDatasetNonSQL:
permission="only_me",
)
# Act / Assert
# Act & Assert
with pytest.raises(ValueError, match="Current user or current user id not found"):
DatasetService.create_empty_rag_pipeline_dataset(
tenant_id=tenant_id,
rag_pipeline_dataset_create_entity=entity,
tenant_id=tenant_id, rag_pipeline_dataset_create_entity=entity
)
def test_create_rag_pipeline_dataset_with_custom_permission(self, mock_rag_pipeline_dependencies):
"""Test creation with custom permission setting."""
# Arrange
tenant_id = str(uuid4())
user_id = str(uuid4())
name = "Custom Permission RAG Dataset"
# Mock current user - set up the mock to have id attribute accessible directly
mock_rag_pipeline_dependencies["current_user_mock"].id = user_id
# Mock database query
mock_query = Mock()
mock_query.filter_by.return_value.first.return_value = None
mock_rag_pipeline_dependencies["db_session"].query.return_value = mock_query
# Mock database operations
mock_db = mock_rag_pipeline_dependencies["db_session"]
mock_db.add = Mock()
mock_db.flush = Mock()
mock_db.commit = Mock()
# Create entity
icon_info = IconInfo(icon="📙", icon_background="#FFF4ED", icon_type="emoji")
entity = RagPipelineDatasetCreateEntity(
name=name,
description="",
icon_info=icon_info,
permission="all_team",
)
# Act
result = DatasetService.create_empty_rag_pipeline_dataset(
tenant_id=tenant_id, rag_pipeline_dataset_create_entity=entity
)
# Assert
assert result.permission == "all_team"
mock_db.commit.assert_called_once()
def test_create_rag_pipeline_dataset_with_icon_info(self, mock_rag_pipeline_dependencies):
"""Test creation with icon info configuration."""
# Arrange
tenant_id = str(uuid4())
user_id = str(uuid4())
name = "Icon Info RAG Dataset"
# Mock current user - set up the mock to have id attribute accessible directly
mock_rag_pipeline_dependencies["current_user_mock"].id = user_id
# Mock database query
mock_query = Mock()
mock_query.filter_by.return_value.first.return_value = None
mock_rag_pipeline_dependencies["db_session"].query.return_value = mock_query
# Mock database operations
mock_db = mock_rag_pipeline_dependencies["db_session"]
mock_db.add = Mock()
mock_db.flush = Mock()
mock_db.commit = Mock()
# Create entity with icon info
icon_info = IconInfo(
icon="📚",
icon_background="#E8F5E9",
icon_type="emoji",
icon_url="https://example.com/icon.png",
)
entity = RagPipelineDatasetCreateEntity(
name=name,
description="",
icon_info=icon_info,
permission="only_me",
)
# Act
result = DatasetService.create_empty_rag_pipeline_dataset(
tenant_id=tenant_id, rag_pipeline_dataset_create_entity=entity
)
# Assert
assert result.icon_info == icon_info.model_dump()
mock_db.commit.assert_called_once()

View File

@@ -1 +1 @@
22
24

View File

@@ -1,5 +1,5 @@
# base image
FROM node:22-alpine AS base
FROM node:24-alpine AS base
LABEL maintainer="takatost@gmail.com"
# if you located in China, you can use aliyun mirror to speed up

View File

@@ -8,11 +8,11 @@
*/
import type { AppListResponse } from '@/models/app'
import type { App } from '@/types/app'
import { fireEvent, screen } from '@testing-library/react'
import { fireEvent, render, screen } from '@testing-library/react'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import List from '@/app/components/apps/list'
import { AccessMode } from '@/models/access-control'
import { renderWithNuqs } from '@/test/nuqs-testing'
import { AppModeEnum } from '@/types/app'
let mockIsCurrentWorkspaceEditor = true
@@ -161,9 +161,10 @@ const createPage = (apps: App[], hasMore = false, page = 1): AppListResponse =>
})
const renderList = (searchParams?: Record<string, string>) => {
return renderWithNuqs(
<List controlRefreshList={0} />,
{ searchParams },
return render(
<NuqsTestingAdapter searchParams={searchParams}>
<List controlRefreshList={0} />
</NuqsTestingAdapter>,
)
}
@@ -208,7 +209,11 @@ describe('App List Browsing Flow', () => {
it('should transition from loading to content when data loads', () => {
mockIsLoading = true
const { rerender } = renderWithNuqs(<List controlRefreshList={0} />)
const { rerender } = render(
<NuqsTestingAdapter>
<List controlRefreshList={0} />
</NuqsTestingAdapter>,
)
const skeletonCards = document.querySelectorAll('.animate-pulse')
expect(skeletonCards.length).toBeGreaterThan(0)
@@ -219,7 +224,11 @@ describe('App List Browsing Flow', () => {
createMockApp({ id: 'app-1', name: 'Loaded App' }),
])]
rerender(<List controlRefreshList={0} />)
rerender(
<NuqsTestingAdapter>
<List controlRefreshList={0} />
</NuqsTestingAdapter>,
)
expect(screen.getByText('Loaded App')).toBeInTheDocument()
})
@@ -415,9 +424,17 @@ describe('App List Browsing Flow', () => {
it('should call refetch when controlRefreshList increments', () => {
mockPages = [createPage([createMockApp()])]
const { rerender } = renderWithNuqs(<List controlRefreshList={0} />)
const { rerender } = render(
<NuqsTestingAdapter>
<List controlRefreshList={0} />
</NuqsTestingAdapter>,
)
rerender(<List controlRefreshList={1} />)
rerender(
<NuqsTestingAdapter>
<List controlRefreshList={1} />
</NuqsTestingAdapter>,
)
expect(mockRefetch).toHaveBeenCalled()
})

View File

@@ -9,11 +9,11 @@
*/
import type { AppListResponse } from '@/models/app'
import type { App } from '@/types/app'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import List from '@/app/components/apps/list'
import { AccessMode } from '@/models/access-control'
import { renderWithNuqs } from '@/test/nuqs-testing'
import { AppModeEnum } from '@/types/app'
let mockIsCurrentWorkspaceEditor = true
@@ -214,7 +214,11 @@ const createPage = (apps: App[]): AppListResponse => ({
})
const renderList = () => {
return renderWithNuqs(<List controlRefreshList={0} />)
return render(
<NuqsTestingAdapter>
<List controlRefreshList={0} />
</NuqsTestingAdapter>,
)
}
describe('Create App Flow', () => {

View File

@@ -7,10 +7,9 @@
*/
import type { SimpleDocumentDetail } from '@/models/datasets'
import { act, renderHook, waitFor } from '@testing-library/react'
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DataSourceType } from '@/models/datasets'
import { renderHookWithNuqs } from '@/test/nuqs-testing'
const mockPush = vi.fn()
vi.mock('next/navigation', () => ({
@@ -29,16 +28,12 @@ const { useDocumentSort } = await import(
const { useDocumentSelection } = await import(
'@/app/components/datasets/documents/components/document-list/hooks/use-document-selection',
)
const { useDocumentListQueryState } = await import(
const { default: useDocumentListQueryState } = await import(
'@/app/components/datasets/documents/hooks/use-document-list-query-state',
)
type LocalDoc = SimpleDocumentDetail & { percent?: number }
const renderQueryStateHook = (searchParams = '') => {
return renderHookWithNuqs(() => useDocumentListQueryState(), { searchParams })
}
const createDoc = (overrides?: Partial<LocalDoc>): LocalDoc => ({
id: `doc-${Math.random().toString(36).slice(2, 8)}`,
name: 'test-doc.txt',
@@ -90,7 +85,7 @@ describe('Document Management Flow', () => {
describe('URL-based Query State', () => {
it('should parse default query from empty URL params', () => {
const { result } = renderQueryStateHook()
const { result } = renderHook(() => useDocumentListQueryState())
expect(result.current.query).toEqual({
page: 1,
@@ -101,85 +96,107 @@ describe('Document Management Flow', () => {
})
})
it('should update keyword query with replace history', async () => {
const { result, onUrlUpdate } = renderQueryStateHook()
it('should update query and push to router', () => {
const { result } = renderHook(() => useDocumentListQueryState())
act(() => {
result.current.updateQuery({ keyword: 'test', page: 2 })
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.options.history).toBe('replace')
expect(update.searchParams.get('keyword')).toBe('test')
expect(update.searchParams.get('page')).toBe('2')
expect(mockPush).toHaveBeenCalled()
// The push call should contain the updated query params
const pushUrl = mockPush.mock.calls[0][0] as string
expect(pushUrl).toContain('keyword=test')
expect(pushUrl).toContain('page=2')
})
it('should reset query to defaults', async () => {
const { result, onUrlUpdate } = renderQueryStateHook()
it('should reset query to defaults', () => {
const { result } = renderHook(() => useDocumentListQueryState())
act(() => {
result.current.resetQuery()
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.options.history).toBe('replace')
expect(update.searchParams.toString()).toBe('')
expect(mockPush).toHaveBeenCalled()
// Default query omits default values from URL
const pushUrl = mockPush.mock.calls[0][0] as string
expect(pushUrl).toBe('/datasets/ds-1/documents')
})
})
describe('Document Sort Integration', () => {
it('should derive sort field and order from remote sort value', () => {
it('should return documents unsorted when no sort field set', () => {
const docs = [
createDoc({ id: 'doc-1', name: 'Banana.txt', word_count: 300 }),
createDoc({ id: 'doc-2', name: 'Apple.txt', word_count: 100 }),
createDoc({ id: 'doc-3', name: 'Cherry.txt', word_count: 200 }),
]
const { result } = renderHook(() => useDocumentSort({
documents: docs,
statusFilterValue: '',
remoteSortValue: '-created_at',
onRemoteSortChange: vi.fn(),
}))
expect(result.current.sortField).toBe('created_at')
expect(result.current.sortField).toBeNull()
expect(result.current.sortedDocuments).toHaveLength(3)
})
it('should sort by name descending', () => {
const docs = [
createDoc({ id: 'doc-1', name: 'Banana.txt' }),
createDoc({ id: 'doc-2', name: 'Apple.txt' }),
createDoc({ id: 'doc-3', name: 'Cherry.txt' }),
]
const { result } = renderHook(() => useDocumentSort({
documents: docs,
statusFilterValue: '',
remoteSortValue: '-created_at',
}))
act(() => {
result.current.handleSort('name')
})
expect(result.current.sortField).toBe('name')
expect(result.current.sortOrder).toBe('desc')
const names = result.current.sortedDocuments.map(d => d.name)
expect(names).toEqual(['Cherry.txt', 'Banana.txt', 'Apple.txt'])
})
it('should call remote sort change with descending sort for a new field', () => {
const onRemoteSortChange = vi.fn()
it('should toggle sort order on same field click', () => {
const docs = [createDoc({ id: 'doc-1', name: 'A.txt' }), createDoc({ id: 'doc-2', name: 'B.txt' })]
const { result } = renderHook(() => useDocumentSort({
documents: docs,
statusFilterValue: '',
remoteSortValue: '-created_at',
onRemoteSortChange,
}))
act(() => {
result.current.handleSort('hit_count')
})
act(() => result.current.handleSort('name'))
expect(result.current.sortOrder).toBe('desc')
expect(onRemoteSortChange).toHaveBeenCalledWith('-hit_count')
act(() => result.current.handleSort('name'))
expect(result.current.sortOrder).toBe('asc')
})
it('should toggle descending to ascending when clicking active field', () => {
const onRemoteSortChange = vi.fn()
const { result } = renderHook(() => useDocumentSort({
remoteSortValue: '-hit_count',
onRemoteSortChange,
}))
act(() => {
result.current.handleSort('hit_count')
})
expect(onRemoteSortChange).toHaveBeenCalledWith('hit_count')
})
it('should ignore null sort field updates', () => {
const onRemoteSortChange = vi.fn()
it('should filter by status before sorting', () => {
const docs = [
createDoc({ id: 'doc-1', name: 'A.txt', display_status: 'available' }),
createDoc({ id: 'doc-2', name: 'B.txt', display_status: 'error' }),
createDoc({ id: 'doc-3', name: 'C.txt', display_status: 'available' }),
]
const { result } = renderHook(() => useDocumentSort({
documents: docs,
statusFilterValue: 'available',
remoteSortValue: '-created_at',
onRemoteSortChange,
}))
act(() => {
result.current.handleSort(null)
})
expect(onRemoteSortChange).not.toHaveBeenCalled()
// Only 'available' documents should remain
expect(result.current.sortedDocuments).toHaveLength(2)
expect(result.current.sortedDocuments.every(d => d.display_status === 'available')).toBe(true)
})
})
@@ -292,13 +309,14 @@ describe('Document Management Flow', () => {
describe('Cross-Module: Query State → Sort → Selection Pipeline', () => {
it('should maintain consistent default state across all hooks', () => {
const docs = [createDoc({ id: 'doc-1' })]
const { result: queryResult } = renderQueryStateHook()
const { result: queryResult } = renderHook(() => useDocumentListQueryState())
const { result: sortResult } = renderHook(() => useDocumentSort({
documents: docs,
statusFilterValue: queryResult.current.query.status,
remoteSortValue: queryResult.current.query.sort,
onRemoteSortChange: vi.fn(),
}))
const { result: selResult } = renderHook(() => useDocumentSelection({
documents: docs,
documents: sortResult.current.sortedDocuments,
selectedIds: [],
onSelectedIdChange: vi.fn(),
}))
@@ -307,9 +325,8 @@ describe('Document Management Flow', () => {
expect(queryResult.current.query.sort).toBe('-created_at')
expect(queryResult.current.query.status).toBe('all')
// Sort state is derived from URL default sort.
expect(sortResult.current.sortField).toBe('created_at')
expect(sortResult.current.sortOrder).toBe('desc')
// Sort inherits 'all' status → no filtering applied
expect(sortResult.current.sortedDocuments).toHaveLength(1)
// Selection starts empty
expect(selResult.current.isAllSelected).toBe(false)

View File

@@ -28,13 +28,9 @@ vi.mock('react-i18next', () => ({
}),
}))
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return {
...actual,
useQueryState: () => ['builtin', vi.fn()],
}
})
vi.mock('nuqs', () => ({
useQueryState: () => ['builtin', vi.fn()],
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: () => ({ enable_marketplace: false }),
@@ -216,12 +212,6 @@ vi.mock('@/app/components/tools/marketplace', () => ({
default: () => null,
}))
vi.mock('@/app/components/tools/marketplace/hooks', () => ({
useMarketplace: () => ({
handleScroll: vi.fn(),
}),
}))
vi.mock('@/app/components/tools/mcp', () => ({
default: () => <div data-testid="mcp-list">MCP List</div>,
}))

View File

@@ -1,7 +1,9 @@
import { act, fireEvent, screen } from '@testing-library/react'
import type { UrlUpdateEvent } from 'nuqs/adapters/testing'
import type { ReactNode } from 'react'
import { act, fireEvent, render, screen } from '@testing-library/react'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import * as React from 'react'
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
import { renderWithNuqs } from '@/test/nuqs-testing'
import { AppModeEnum } from '@/types/app'
import List from '../list'
@@ -184,14 +186,21 @@ beforeAll(() => {
} as unknown as typeof IntersectionObserver
})
// Render helper wrapping with shared nuqs testing helper.
// Render helper wrapping with NuqsTestingAdapter
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>()
const renderList = (searchParams = '') => {
return renderWithNuqs(<List />, { searchParams })
const wrapper = ({ children }: { children: ReactNode }) => (
<NuqsTestingAdapter searchParams={searchParams} onUrlUpdate={onUrlUpdate}>
{children}
</NuqsTestingAdapter>
)
return render(<List />, { wrapper })
}
describe('List', () => {
beforeEach(() => {
vi.clearAllMocks()
onUrlUpdate.mockClear()
useTagStore.setState({
tagList: [{ id: 'tag-1', name: 'Test Tag', type: 'app', binding_count: 0 }],
showTagManagementModal: false,
@@ -268,7 +277,7 @@ describe('List', () => {
describe('Tab Navigation', () => {
it('should update URL when workflow tab is clicked', async () => {
const { onUrlUpdate } = renderList()
renderList()
fireEvent.click(screen.getByText('app.types.workflow'))
@@ -278,7 +287,7 @@ describe('List', () => {
})
it('should update URL when all tab is clicked', async () => {
const { onUrlUpdate } = renderList('?category=workflow')
renderList('?category=workflow')
fireEvent.click(screen.getByText('app.types.all'))
@@ -382,10 +391,18 @@ describe('List', () => {
describe('Edge Cases', () => {
it('should handle multiple renders without issues', () => {
const { rerender } = renderWithNuqs(<List />)
const { rerender } = render(
<NuqsTestingAdapter>
<List />
</NuqsTestingAdapter>,
)
expect(screen.getByText('app.types.all')).toBeInTheDocument()
rerender(<List />)
rerender(
<NuqsTestingAdapter>
<List />
</NuqsTestingAdapter>,
)
expect(screen.getByText('app.types.all')).toBeInTheDocument()
})
@@ -431,7 +448,7 @@ describe('List', () => {
})
it('should update URL for each app type tab click', async () => {
const { onUrlUpdate } = renderList()
renderList()
const appTypeTexts = [
{ mode: AppModeEnum.WORKFLOW, text: 'app.types.workflow' },

View File

@@ -1,9 +1,18 @@
import { act, waitFor } from '@testing-library/react'
import { renderHookWithNuqs } from '@/test/nuqs-testing'
import type { UrlUpdateEvent } from 'nuqs/adapters/testing'
import type { ReactNode } from 'react'
import { act, renderHook, waitFor } from '@testing-library/react'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import useAppsQueryState from '../use-apps-query-state'
const renderWithAdapter = (searchParams = '') => {
return renderHookWithNuqs(() => useAppsQueryState(), { searchParams })
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>()
const wrapper = ({ children }: { children: ReactNode }) => (
<NuqsTestingAdapter searchParams={searchParams} onUrlUpdate={onUrlUpdate}>
{children}
</NuqsTestingAdapter>
)
const { result } = renderHook(() => useAppsQueryState(), { wrapper })
return { result, onUrlUpdate }
}
describe('useAppsQueryState', () => {

View File

@@ -3,7 +3,7 @@
import type { FC } from 'react'
import { useDebounceFn } from 'ahooks'
import dynamic from 'next/dynamic'
import { parseAsStringLiteral, useQueryState } from 'nuqs'
import { parseAsString, useQueryState } from 'nuqs'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
@@ -16,7 +16,7 @@ import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { CheckModal } from '@/hooks/use-pay'
import { useInfiniteAppList } from '@/service/use-apps'
import { AppModeEnum, AppModes } from '@/types/app'
import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames'
import AppCard from './app-card'
import { AppCardSkeleton } from './app-card-skeleton'
@@ -33,18 +33,6 @@ const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-fro
ssr: false,
})
const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const
type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number]
const appListCategorySet = new Set<string>(APP_LIST_CATEGORY_VALUES)
const isAppListCategory = (value: string): value is AppListCategory => {
return appListCategorySet.has(value)
}
const parseAsAppListCategory = parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
.withDefault('all')
.withOptions({ history: 'push' })
type Props = {
controlRefreshList?: number
}
@@ -57,7 +45,7 @@ const List: FC<Props> = ({
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
const [activeTab, setActiveTab] = useQueryState(
'category',
parseAsAppListCategory,
parseAsString.withDefault('all').withOptions({ history: 'push' }),
)
const { query: { tagIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = useAppsQueryState()
@@ -92,7 +80,7 @@ const List: FC<Props> = ({
name: searchKeywords,
tag_ids: tagIDs,
is_created_by_me: isCreatedByMe,
...(activeTab !== 'all' ? { mode: activeTab } : {}),
...(activeTab !== 'all' ? { mode: activeTab as AppModeEnum } : {}),
}
const {
@@ -198,10 +186,7 @@ const List: FC<Props> = ({
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-y-2 bg-background-body px-12 pb-5 pt-7">
<TabSliderNew
value={activeTab}
onChange={(nextValue) => {
if (isAppListCategory(nextValue))
setActiveTab(nextValue)
}}
onChange={setActiveTab}
options={options}
/>
<div className="flex items-center gap-2">

View File

@@ -1,8 +1,3 @@
/**
* @deprecated Use `@/app/components/base/ui/dialog` instead.
* This component will be removed after migration is complete.
* See: https://github.com/langgenius/dify/issues/32767
*/
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'
import { noop } from 'es-toolkit/function'
import { Fragment } from 'react'

View File

@@ -1,8 +1,3 @@
/**
* @deprecated Use `@/app/components/base/ui/dialog` instead.
* This component will be removed after migration is complete.
* See: https://github.com/langgenius/dify/issues/32767
*/
import type { ButtonProps } from '@/app/components/base/button'
import { noop } from 'es-toolkit/function'
import { memo } from 'react'

View File

@@ -1,16 +1,4 @@
'use client'
/**
* @deprecated Use semantic overlay primitives from `@/app/components/base/ui/` instead.
* This component will be removed after migration is complete.
* See: https://github.com/langgenius/dify/issues/32767
*
* Migration guide:
* - Tooltip → `@/app/components/base/ui/tooltip`
* - Menu/Dropdown → `@/app/components/base/ui/dropdown-menu`
* - Popover → `@/app/components/base/ui/popover`
* - Dialog/Modal → `@/app/components/base/ui/dialog`
* - Select → `@/app/components/base/ui/select`
*/
import type { OffsetOptions, Placement } from '@floating-ui/react'
import {
autoUpdate,
@@ -45,7 +33,6 @@ export type PortalToFollowElemOptions = {
triggerPopupSameWidth?: boolean
}
/** @deprecated Use semantic overlay primitives instead. See #32767. */
export function usePortalToFollowElem({
placement = 'bottom',
open: controlledOpen,
@@ -123,7 +110,6 @@ export function usePortalToFollowElemContext() {
return context
}
/** @deprecated Use semantic overlay primitives instead. See #32767. */
export function PortalToFollowElem({
children,
...options
@@ -138,7 +124,6 @@ export function PortalToFollowElem({
)
}
/** @deprecated Use semantic overlay primitives instead. See #32767. */
export const PortalToFollowElemTrigger = (
{
ref: propRef,
@@ -179,7 +164,6 @@ export const PortalToFollowElemTrigger = (
}
PortalToFollowElemTrigger.displayName = 'PortalToFollowElemTrigger'
/** @deprecated Use semantic overlay primitives instead. See #32767. */
export const PortalToFollowElemContent = (
{
ref: propRef,

View File

@@ -1,9 +1,4 @@
'use client'
/**
* @deprecated Use `@/app/components/base/ui/select` instead.
* This component will be removed after migration is complete.
* See: https://github.com/langgenius/dify/issues/32767
*/
import type { FC } from 'react'
import { Combobox, ComboboxButton, ComboboxInput, ComboboxOption, ComboboxOptions, Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react'
import { ChevronDownIcon, ChevronUpIcon, XMarkIcon } from '@heroicons/react/20/solid'
@@ -241,7 +236,7 @@ const SimpleSelect: FC<ISelectProps> = ({
}}
className={cn(`flex h-full w-full items-center rounded-lg border-0 bg-components-input-bg-normal pl-3 pr-10 focus-visible:bg-state-base-hover-alt focus-visible:outline-none group-hover/simple-select:bg-state-base-hover-alt sm:text-sm sm:leading-6 ${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`, className)}
>
<span className={cn('block truncate text-left text-components-input-text-filled system-sm-regular', !selectedItem?.name && 'text-components-input-text-placeholder')}>{selectedItem?.name ?? localPlaceholder}</span>
<span className={cn('system-sm-regular block truncate text-left text-components-input-text-filled', !selectedItem?.name && 'text-components-input-text-placeholder')}>{selectedItem?.name ?? localPlaceholder}</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-2">
{isLoading
? <RiLoader4Line className="h-3.5 w-3.5 animate-spin text-text-secondary" />

View File

@@ -13,50 +13,44 @@ export default function ThemeSwitcher() {
return (
<div className="flex items-center rounded-[10px] bg-components-segmented-control-bg-normal p-0.5">
<button
type="button"
<div
className={cn(
'rounded-lg px-2 py-1 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
theme === 'system' && 'bg-components-segmented-control-item-active-bg text-text-accent-light-mode-only shadow-sm hover:bg-components-segmented-control-item-active-bg hover:text-text-accent-light-mode-only',
)}
onClick={() => handleThemeChange('system')}
aria-label="System theme"
data-testid="system-theme-container"
>
<div className="p-0.5">
<span className="i-ri-computer-line h-4 w-4" />
</div>
</button>
</div>
<div className={cn('h-[14px] w-px bg-transparent', theme === 'dark' && 'bg-divider-regular')} data-testid="divider"></div>
<button
type="button"
<div
className={cn(
'rounded-lg px-2 py-1 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
theme === 'light' && 'bg-components-segmented-control-item-active-bg text-text-accent-light-mode-only shadow-sm hover:bg-components-segmented-control-item-active-bg hover:text-text-accent-light-mode-only',
)}
onClick={() => handleThemeChange('light')}
aria-label="Light theme"
data-testid="light-theme-container"
>
<div className="p-0.5">
<span className="i-ri-sun-line h-4 w-4" />
</div>
</button>
</div>
<div className={cn('h-[14px] w-px bg-transparent', theme === 'system' && 'bg-divider-regular')} data-testid="divider"></div>
<button
type="button"
<div
className={cn(
'rounded-lg px-2 py-1 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
theme === 'dark' && 'bg-components-segmented-control-item-active-bg text-text-accent-light-mode-only shadow-sm hover:bg-components-segmented-control-item-active-bg hover:text-text-accent-light-mode-only',
)}
onClick={() => handleThemeChange('dark')}
aria-label="Dark theme"
data-testid="dark-theme-container"
>
<div className="p-0.5">
<span className="i-ri-moon-line h-4 w-4" />
</div>
</button>
</div>
</div>
)
}

View File

@@ -77,11 +77,11 @@ const Toast = ({
</div>
<div className={cn('flex grow flex-col items-start gap-1 py-1', size === 'md' ? 'px-1' : 'px-0.5')}>
<div className="flex items-center gap-1">
<div className="system-sm-semibold text-text-primary [word-break:break-word]">{message}</div>
<div className="text-text-primary system-sm-semibold [word-break:break-word]">{message}</div>
{customComponent}
</div>
{!!children && (
<div className="system-xs-regular text-text-secondary">
<div className="text-text-secondary system-xs-regular">
{children}
</div>
)}
@@ -149,25 +149,26 @@ Toast.notify = ({
if (typeof window === 'object') {
const holder = document.createElement('div')
const root = createRoot(holder)
let timerId: ReturnType<typeof setTimeout> | undefined
toastHandler.clear = () => {
if (holder) {
const unmountAndRemove = () => {
if (timerId) {
clearTimeout(timerId)
timerId = undefined
}
if (typeof window !== 'undefined' && holder) {
root.unmount()
holder.remove()
}
onClose?.()
}
toastHandler.clear = unmountAndRemove
root.render(
<ToastContext.Provider value={{
notify: noop,
close: () => {
if (holder) {
root.unmount()
holder.remove()
}
onClose?.()
},
close: unmountAndRemove,
}}
>
<Toast type={type} size={size} message={message} duration={duration} className={className} customComponent={customComponent} />
@@ -176,7 +177,7 @@ Toast.notify = ({
document.body.appendChild(holder)
const d = duration ?? defaultDuring
if (d > 0)
setTimeout(toastHandler.clear, d)
timerId = setTimeout(unmountAndRemove, d)
}
return toastHandler

View File

@@ -1,9 +1,4 @@
'use client'
/**
* @deprecated Use `@/app/components/base/ui/tooltip` instead.
* This component will be removed after migration is complete.
* See: https://github.com/langgenius/dify/issues/32767
*/
import type { OffsetOptions, Placement } from '@floating-ui/react'
import type { FC } from 'react'
import { RiQuestionLine } from '@remixicon/react'
@@ -135,7 +130,7 @@ const Tooltip: FC<TooltipProps> = ({
{!!popupContent && (
<div
className={cn(
!noDecoration && 'relative max-w-[300px] break-words rounded-md bg-components-panel-bg px-3 py-2 text-left text-text-tertiary shadow-lg system-xs-regular',
!noDecoration && 'system-xs-regular relative max-w-[300px] break-words rounded-md bg-components-panel-bg px-3 py-2 text-left text-text-tertiary shadow-lg',
popupClassName,
)}
onMouseEnter={() => {

View File

@@ -1,70 +0,0 @@
import { Dialog as BaseDialog } from '@base-ui/react/dialog'
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogTitle,
DialogTrigger,
} from '../index'
describe('Dialog wrapper', () => {
describe('Rendering', () => {
it('should render dialog content when dialog is open', () => {
render(
<Dialog open>
<DialogContent>
<DialogTitle>Dialog Title</DialogTitle>
<DialogDescription>Dialog Description</DialogDescription>
</DialogContent>
</Dialog>,
)
const dialog = screen.getByRole('dialog')
expect(dialog).toHaveTextContent('Dialog Title')
expect(dialog).toHaveTextContent('Dialog Description')
})
})
describe('Props', () => {
it('should not render close button when closable is omitted', () => {
render(
<Dialog open>
<DialogContent>
<span>Dialog body</span>
</DialogContent>
</Dialog>,
)
expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument()
})
it('should render close button when closable is true', () => {
render(
<Dialog open>
<DialogContent closable>
<span>Dialog body</span>
</DialogContent>
</Dialog>,
)
const dialog = screen.getByRole('dialog')
const closeButton = screen.getByRole('button', { name: 'Close' })
expect(dialog).toContainElement(closeButton)
expect(closeButton).toHaveAttribute('aria-label', 'Close')
})
})
describe('Exports', () => {
it('should map dialog aliases to the matching base dialog primitives', () => {
expect(Dialog).toBe(BaseDialog.Root)
expect(DialogTrigger).toBe(BaseDialog.Trigger)
expect(DialogTitle).toBe(BaseDialog.Title)
expect(DialogDescription).toBe(BaseDialog.Description)
expect(DialogClose).toBe(BaseDialog.Close)
})
})
})

View File

@@ -1,58 +0,0 @@
'use client'
// z-index strategy (relies on root `isolation: isolate` in layout.tsx):
// All overlay primitives (Tooltip / Popover / Dropdown / Select / Dialog) — z-50
// Overlays share the same z-index; DOM order handles stacking when multiple are open.
// This ensures overlays inside a Dialog (e.g. a Tooltip on a dialog button) render
// above the dialog backdrop instead of being clipped by it.
// Toast — z-[99], always on top (defined in toast component)
import { Dialog as BaseDialog } from '@base-ui/react/dialog'
import * as React from 'react'
import { cn } from '@/utils/classnames'
export const Dialog = BaseDialog.Root
export const DialogTrigger = BaseDialog.Trigger
export const DialogTitle = BaseDialog.Title
export const DialogDescription = BaseDialog.Description
export const DialogClose = BaseDialog.Close
type DialogContentProps = {
children: React.ReactNode
className?: string
overlayClassName?: string
closable?: boolean
}
export function DialogContent({
children,
className,
overlayClassName,
closable = false,
}: DialogContentProps) {
return (
<BaseDialog.Portal>
<BaseDialog.Backdrop
className={cn(
'fixed inset-0 z-50 bg-background-overlay',
'transition-opacity duration-150 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none',
overlayClassName,
)}
/>
<BaseDialog.Popup
className={cn(
'fixed left-1/2 top-1/2 z-50 max-h-[80dvh] w-[480px] max-w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-6 shadow-xl',
'transition-[transform,scale,opacity] duration-150 data-[ending-style]:scale-95 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none',
className,
)}
>
{closable && (
<BaseDialog.Close aria-label="Close" className="absolute right-6 top-6 z-10 flex h-5 w-5 cursor-pointer items-center justify-center rounded-2xl hover:bg-state-base-hover">
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" />
</BaseDialog.Close>
)}
{children}
</BaseDialog.Popup>
</BaseDialog.Portal>
)
}

View File

@@ -1,294 +0,0 @@
import { Menu } from '@base-ui/react/menu'
import { fireEvent, render, screen, within } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '../index'
describe('dropdown-menu wrapper', () => {
describe('alias exports', () => {
it('should map direct aliases to the corresponding Menu primitive when importing menu roots', () => {
expect(DropdownMenu).toBe(Menu.Root)
expect(DropdownMenuPortal).toBe(Menu.Portal)
expect(DropdownMenuTrigger).toBe(Menu.Trigger)
expect(DropdownMenuSub).toBe(Menu.SubmenuRoot)
expect(DropdownMenuGroup).toBe(Menu.Group)
expect(DropdownMenuRadioGroup).toBe(Menu.RadioGroup)
})
})
describe('DropdownMenuContent', () => {
it('should position content at bottom-end with default placement when props are omitted', () => {
render(
<DropdownMenu open>
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
<DropdownMenuContent positionerProps={{ 'role': 'group', 'aria-label': 'content positioner' }}>
<DropdownMenuItem>Content action</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>,
)
const positioner = screen.getByRole('group', { name: 'content positioner' })
const popup = screen.getByRole('menu')
expect(positioner).toHaveAttribute('data-side', 'bottom')
expect(positioner).toHaveAttribute('data-align', 'end')
expect(within(popup).getByRole('menuitem', { name: 'Content action' })).toBeInTheDocument()
})
it('should apply custom placement when custom positioning props are provided', () => {
render(
<DropdownMenu open>
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
<DropdownMenuContent
placement="top-start"
sideOffset={12}
alignOffset={-3}
positionerProps={{ 'role': 'group', 'aria-label': 'custom content positioner' }}
>
<DropdownMenuItem>Custom content</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>,
)
const positioner = screen.getByRole('group', { name: 'custom content positioner' })
const popup = screen.getByRole('menu')
expect(positioner).toHaveAttribute('data-side', 'top')
expect(positioner).toHaveAttribute('data-align', 'start')
expect(within(popup).getByRole('menuitem', { name: 'Custom content' })).toBeInTheDocument()
})
it('should forward passthrough attributes and handlers when positionerProps and popupProps are provided', () => {
const handlePositionerMouseEnter = vi.fn()
const handlePopupClick = vi.fn()
render(
<DropdownMenu open>
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
<DropdownMenuContent
positionerProps={{
'role': 'group',
'aria-label': 'dropdown content positioner',
'id': 'dropdown-content-positioner',
'onMouseEnter': handlePositionerMouseEnter,
}}
popupProps={{
role: 'menu',
id: 'dropdown-content-popup',
onClick: handlePopupClick,
}}
>
<DropdownMenuItem>Passthrough content</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>,
)
const positioner = screen.getByRole('group', { name: 'dropdown content positioner' })
const popup = screen.getByRole('menu')
fireEvent.mouseEnter(positioner)
fireEvent.click(popup)
expect(positioner).toHaveAttribute('id', 'dropdown-content-positioner')
expect(popup).toHaveAttribute('id', 'dropdown-content-popup')
expect(handlePositionerMouseEnter).toHaveBeenCalledTimes(1)
expect(handlePopupClick).toHaveBeenCalledTimes(1)
})
})
describe('DropdownMenuSubContent', () => {
it('should position sub-content at left-start with default placement when props are omitted', () => {
render(
<DropdownMenu open>
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuSub open>
<DropdownMenuSubTrigger>More actions</DropdownMenuSubTrigger>
<DropdownMenuSubContent positionerProps={{ 'role': 'group', 'aria-label': 'sub positioner' }}>
<DropdownMenuItem>Sub action</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>,
)
const positioner = screen.getByRole('group', { name: 'sub positioner' })
expect(positioner).toHaveAttribute('data-side', 'left')
expect(positioner).toHaveAttribute('data-align', 'start')
expect(screen.getByRole('menuitem', { name: 'Sub action' })).toBeInTheDocument()
})
it('should apply custom placement and forward passthrough props for sub-content when custom props are provided', () => {
const handlePositionerFocus = vi.fn()
const handlePopupClick = vi.fn()
render(
<DropdownMenu open>
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuSub open>
<DropdownMenuSubTrigger>More actions</DropdownMenuSubTrigger>
<DropdownMenuSubContent
placement="right-end"
sideOffset={6}
alignOffset={2}
positionerProps={{
'role': 'group',
'aria-label': 'dropdown sub positioner',
'id': 'dropdown-sub-positioner',
'onFocus': handlePositionerFocus,
}}
popupProps={{
role: 'menu',
id: 'dropdown-sub-popup',
onClick: handlePopupClick,
}}
>
<DropdownMenuItem>Custom sub action</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>,
)
const positioner = screen.getByRole('group', { name: 'dropdown sub positioner' })
const popup = screen.getByRole('menu', { name: 'More actions' })
fireEvent.focus(positioner)
fireEvent.click(popup)
expect(positioner).toHaveAttribute('data-side', 'right')
expect(positioner).toHaveAttribute('data-align', 'end')
expect(positioner).toHaveAttribute('id', 'dropdown-sub-positioner')
expect(popup).toHaveAttribute('id', 'dropdown-sub-popup')
expect(handlePositionerFocus).toHaveBeenCalledTimes(1)
expect(handlePopupClick).toHaveBeenCalledTimes(1)
})
})
describe('DropdownMenuSubTrigger', () => {
it('should render submenu trigger content when trigger children are provided', () => {
render(
<DropdownMenu open>
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuSub open>
<DropdownMenuSubTrigger>Trigger item</DropdownMenuSubTrigger>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>,
)
expect(screen.getByRole('menuitem', { name: 'Trigger item' })).toBeInTheDocument()
})
it.each([true, false])('should remain interactive and not leak destructive prop when destructive is %s', (destructive) => {
const handleClick = vi.fn()
render(
<DropdownMenu open>
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuSub open>
<DropdownMenuSubTrigger
destructive={destructive}
aria-label="submenu action"
id={`submenu-trigger-${String(destructive)}`}
onClick={handleClick}
>
Trigger item
</DropdownMenuSubTrigger>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>,
)
const subTrigger = screen.getByRole('menuitem', { name: 'submenu action' })
fireEvent.click(subTrigger)
expect(subTrigger).toHaveAttribute('id', `submenu-trigger-${String(destructive)}`)
expect(subTrigger).not.toHaveAttribute('destructive')
expect(handleClick).toHaveBeenCalledTimes(1)
})
})
describe('DropdownMenuItem', () => {
it.each([true, false])('should remain interactive and not leak destructive prop when destructive is %s', (destructive) => {
const handleClick = vi.fn()
render(
<DropdownMenu open>
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
destructive={destructive}
aria-label="menu action"
id={`menu-item-${String(destructive)}`}
onClick={handleClick}
>
Item label
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>,
)
const item = screen.getByRole('menuitem', { name: 'menu action' })
fireEvent.click(item)
expect(item).toHaveAttribute('id', `menu-item-${String(destructive)}`)
expect(item).not.toHaveAttribute('destructive')
expect(handleClick).toHaveBeenCalledTimes(1)
})
})
describe('DropdownMenuSeparator', () => {
it('should forward passthrough props and handlers when separator props are provided', () => {
const handleMouseEnter = vi.fn()
render(
<DropdownMenu open>
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuSeparator
aria-label="actions divider"
id="menu-separator"
onMouseEnter={handleMouseEnter}
/>
</DropdownMenuContent>
</DropdownMenu>,
)
const separator = screen.getByRole('separator', { name: 'actions divider' })
fireEvent.mouseEnter(separator)
expect(separator).toHaveAttribute('id', 'menu-separator')
expect(handleMouseEnter).toHaveBeenCalledTimes(1)
})
it('should keep surrounding menu rows rendered when separator is placed between items', () => {
render(
<DropdownMenu open>
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>First action</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>Second action</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>,
)
expect(screen.getByRole('menuitem', { name: 'First action' })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: 'Second action' })).toBeInTheDocument()
expect(screen.getAllByRole('separator')).toHaveLength(1)
})
})
})

View File

@@ -1,317 +0,0 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { useState } from 'react'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuCheckboxItemIndicator,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuGroupLabel,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuRadioItemIndicator,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '.'
const TriggerButton = ({ label = 'Open Menu' }: { label?: string }) => (
<DropdownMenuTrigger
render={<button type="button" className="rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs hover:bg-state-base-hover" />}
>
{label}
</DropdownMenuTrigger>
)
const meta = {
title: 'Base/Navigation/DropdownMenu',
component: DropdownMenu,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Compound dropdown menu built on Base UI Menu. Supports items, separators, group labels, submenus, radio groups, checkbox items, destructive items, and disabled states.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof DropdownMenu>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => (
<DropdownMenu>
<TriggerButton />
<DropdownMenuContent>
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem>Duplicate</DropdownMenuItem>
<DropdownMenuItem>Archive</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
}
export const WithSeparator: Story = {
render: () => (
<DropdownMenu>
<TriggerButton />
<DropdownMenuContent>
<DropdownMenuItem>Cut</DropdownMenuItem>
<DropdownMenuItem>Copy</DropdownMenuItem>
<DropdownMenuItem>Paste</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>Select All</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>Find and Replace</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
}
export const WithGroupLabel: Story = {
render: () => (
<DropdownMenu>
<TriggerButton />
<DropdownMenuContent>
<DropdownMenuGroup>
<DropdownMenuGroupLabel>Actions</DropdownMenuGroupLabel>
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem>Duplicate</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuGroupLabel>Export</DropdownMenuGroupLabel>
<DropdownMenuItem>Export as PDF</DropdownMenuItem>
<DropdownMenuItem>Export as CSV</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
),
}
export const WithDestructiveItem: Story = {
render: () => (
<DropdownMenu>
<TriggerButton />
<DropdownMenuContent>
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem>Duplicate</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem destructive>Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
}
export const WithSubmenu: Story = {
render: () => (
<DropdownMenu>
<TriggerButton />
<DropdownMenuContent>
<DropdownMenuItem>New File</DropdownMenuItem>
<DropdownMenuItem>Open</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>Share</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem>Email</DropdownMenuItem>
<DropdownMenuItem>Slack</DropdownMenuItem>
<DropdownMenuItem>Copy Link</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem>Download</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
}
const WithRadioItemsDemo = () => {
const [value, setValue] = useState('comfortable')
return (
<DropdownMenu>
<TriggerButton label={`Density: ${value}`} />
<DropdownMenuContent>
<DropdownMenuRadioGroup value={value} onValueChange={setValue}>
<DropdownMenuRadioItem value="compact">
Compact
<DropdownMenuRadioItemIndicator />
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="comfortable">
Comfortable
<DropdownMenuRadioItemIndicator />
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="spacious">
Spacious
<DropdownMenuRadioItemIndicator />
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
export const WithRadioItems: Story = {
render: () => <WithRadioItemsDemo />,
}
const WithCheckboxItemsDemo = () => {
const [showToolbar, setShowToolbar] = useState(true)
const [showSidebar, setShowSidebar] = useState(false)
const [showStatusBar, setShowStatusBar] = useState(true)
return (
<DropdownMenu>
<TriggerButton label="View Options" />
<DropdownMenuContent>
<DropdownMenuCheckboxItem checked={showToolbar} onCheckedChange={setShowToolbar}>
Toolbar
<DropdownMenuCheckboxItemIndicator />
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem checked={showSidebar} onCheckedChange={setShowSidebar}>
Sidebar
<DropdownMenuCheckboxItemIndicator />
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem checked={showStatusBar} onCheckedChange={setShowStatusBar}>
Status Bar
<DropdownMenuCheckboxItemIndicator />
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
export const WithCheckboxItems: Story = {
render: () => <WithCheckboxItemsDemo />,
}
export const WithDisabledItems: Story = {
render: () => (
<DropdownMenu>
<TriggerButton />
<DropdownMenuContent>
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem disabled>Duplicate</DropdownMenuItem>
<DropdownMenuItem>Archive</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem disabled>Restore</DropdownMenuItem>
<DropdownMenuItem destructive>Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
}
export const WithIcons: Story = {
render: () => (
<DropdownMenu>
<TriggerButton />
<DropdownMenuContent>
<DropdownMenuItem>
<span aria-hidden className="i-ri-pencil-line size-4 shrink-0 text-text-tertiary" />
Edit
</DropdownMenuItem>
<DropdownMenuItem>
<span aria-hidden className="i-ri-file-copy-line size-4 shrink-0 text-text-tertiary" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem>
<span aria-hidden className="i-ri-archive-line size-4 shrink-0 text-text-tertiary" />
Archive
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem destructive>
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
}
const ComplexDemo = () => {
const [sortOrder, setSortOrder] = useState('newest')
const [showArchived, setShowArchived] = useState(false)
return (
<DropdownMenu>
<TriggerButton label="Actions" />
<DropdownMenuContent>
<DropdownMenuGroup>
<DropdownMenuGroupLabel>Edit</DropdownMenuGroupLabel>
<DropdownMenuItem>
<span aria-hidden className="i-ri-pencil-line size-4 shrink-0 text-text-tertiary" />
Rename
</DropdownMenuItem>
<DropdownMenuItem>
<span aria-hidden className="i-ri-file-copy-line size-4 shrink-0 text-text-tertiary" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem disabled>
<span aria-hidden className="i-ri-lock-line size-4 shrink-0 text-text-tertiary" />
Move to Workspace
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<span aria-hidden className="i-ri-share-line size-4 shrink-0 text-text-tertiary" />
Share
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem>
<span aria-hidden className="i-ri-mail-line size-4 shrink-0 text-text-tertiary" />
Email
</DropdownMenuItem>
<DropdownMenuItem>
<span aria-hidden className="i-ri-chat-1-line size-4 shrink-0 text-text-tertiary" />
Slack
</DropdownMenuItem>
<DropdownMenuItem>
<span aria-hidden className="i-ri-link size-4 shrink-0 text-text-tertiary" />
Copy Link
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuGroupLabel>Sort by</DropdownMenuGroupLabel>
<DropdownMenuRadioGroup value={sortOrder} onValueChange={setSortOrder}>
<DropdownMenuRadioItem value="newest">
Newest first
<DropdownMenuRadioItemIndicator />
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="oldest">
Oldest first
<DropdownMenuRadioItemIndicator />
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="name">
Name
<DropdownMenuRadioItemIndicator />
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem checked={showArchived} onCheckedChange={setShowArchived}>
<span aria-hidden className="i-ri-archive-line size-4 shrink-0 text-text-tertiary" />
Show Archived
<DropdownMenuCheckboxItemIndicator />
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuItem destructive>
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
export const Complex: Story = {
render: () => <ComplexDemo />,
}

View File

@@ -1,277 +0,0 @@
'use client'
import type { Placement } from '@/app/components/base/ui/placement'
import { Menu } from '@base-ui/react/menu'
import * as React from 'react'
import { parsePlacement } from '@/app/components/base/ui/placement'
import { cn } from '@/utils/classnames'
export const DropdownMenu = Menu.Root
export const DropdownMenuPortal = Menu.Portal
export const DropdownMenuTrigger = Menu.Trigger
export const DropdownMenuSub = Menu.SubmenuRoot
export const DropdownMenuGroup = Menu.Group
export const DropdownMenuRadioGroup = Menu.RadioGroup
const menuRowBaseClassName = 'mx-1 flex h-8 cursor-pointer select-none items-center gap-1 rounded-lg px-2 outline-none'
const menuRowStateClassName = 'data-[highlighted]:bg-state-base-hover data-[disabled]:cursor-not-allowed data-[disabled]:opacity-30'
export function DropdownMenuRadioItem({
className,
...props
}: React.ComponentPropsWithoutRef<typeof Menu.RadioItem>) {
return (
<Menu.RadioItem
className={cn(
menuRowBaseClassName,
menuRowStateClassName,
className,
)}
{...props}
/>
)
}
export function DropdownMenuRadioItemIndicator({
className,
...props
}: Omit<React.ComponentPropsWithoutRef<typeof Menu.RadioItemIndicator>, 'children'>) {
return (
<Menu.RadioItemIndicator
className={cn(
'ml-auto flex shrink-0 items-center text-text-accent',
className,
)}
{...props}
>
<span aria-hidden className="i-ri-check-line h-4 w-4" />
</Menu.RadioItemIndicator>
)
}
export function DropdownMenuCheckboxItem({
className,
...props
}: React.ComponentPropsWithoutRef<typeof Menu.CheckboxItem>) {
return (
<Menu.CheckboxItem
className={cn(
menuRowBaseClassName,
menuRowStateClassName,
className,
)}
{...props}
/>
)
}
export function DropdownMenuCheckboxItemIndicator({
className,
...props
}: Omit<React.ComponentPropsWithoutRef<typeof Menu.CheckboxItemIndicator>, 'children'>) {
return (
<Menu.CheckboxItemIndicator
className={cn(
'ml-auto flex shrink-0 items-center text-text-accent',
className,
)}
{...props}
>
<span aria-hidden className="i-ri-check-line h-4 w-4" />
</Menu.CheckboxItemIndicator>
)
}
export function DropdownMenuGroupLabel({
className,
...props
}: React.ComponentPropsWithoutRef<typeof Menu.GroupLabel>) {
return (
<Menu.GroupLabel
className={cn(
'px-3 pb-0.5 pt-1 text-text-tertiary system-xs-medium-uppercase',
className,
)}
{...props}
/>
)
}
type DropdownMenuContentProps = {
children: React.ReactNode
placement?: Placement
sideOffset?: number
alignOffset?: number
className?: string
popupClassName?: string
positionerProps?: Omit<
React.ComponentPropsWithoutRef<typeof Menu.Positioner>,
'children' | 'className' | 'side' | 'align' | 'sideOffset' | 'alignOffset'
>
popupProps?: Omit<
React.ComponentPropsWithoutRef<typeof Menu.Popup>,
'children' | 'className'
>
}
type DropdownMenuPopupRenderProps = Required<Pick<DropdownMenuContentProps, 'children'>> & {
placement: Placement
sideOffset: number
alignOffset: number
className?: string
popupClassName?: string
positionerProps?: DropdownMenuContentProps['positionerProps']
popupProps?: DropdownMenuContentProps['popupProps']
}
function renderDropdownMenuPopup({
children,
placement,
sideOffset,
alignOffset,
className,
popupClassName,
positionerProps,
popupProps,
}: DropdownMenuPopupRenderProps) {
const { side, align } = parsePlacement(placement)
return (
<Menu.Portal>
<Menu.Positioner
side={side}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
className={cn('z-50 outline-none', className)}
{...positionerProps}
>
<Menu.Popup
className={cn(
'max-h-[var(--available-height)] overflow-y-auto overflow-x-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur py-1 text-sm text-text-secondary shadow-lg backdrop-blur-[5px]',
'origin-[var(--transform-origin)] transition-[transform,scale,opacity] data-[ending-style]:scale-95 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none',
popupClassName,
)}
{...popupProps}
>
{children}
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
)
}
export function DropdownMenuContent({
children,
placement = 'bottom-end',
sideOffset = 4,
alignOffset = 0,
className,
popupClassName,
positionerProps,
popupProps,
}: DropdownMenuContentProps) {
return renderDropdownMenuPopup({
children,
placement,
sideOffset,
alignOffset,
className,
popupClassName,
positionerProps,
popupProps,
})
}
type DropdownMenuSubTriggerProps = React.ComponentPropsWithoutRef<typeof Menu.SubmenuTrigger> & {
destructive?: boolean
}
export function DropdownMenuSubTrigger({
className,
destructive,
children,
...props
}: DropdownMenuSubTriggerProps) {
return (
<Menu.SubmenuTrigger
className={cn(
menuRowBaseClassName,
menuRowStateClassName,
destructive && 'text-text-destructive',
className,
)}
{...props}
>
{children}
<span aria-hidden className="i-ri-arrow-right-s-line ml-auto size-4 shrink-0 text-text-tertiary" />
</Menu.SubmenuTrigger>
)
}
type DropdownMenuSubContentProps = {
children: React.ReactNode
placement?: Placement
sideOffset?: number
alignOffset?: number
className?: string
popupClassName?: string
positionerProps?: DropdownMenuContentProps['positionerProps']
popupProps?: DropdownMenuContentProps['popupProps']
}
export function DropdownMenuSubContent({
children,
placement = 'left-start',
sideOffset = 4,
alignOffset = 0,
className,
popupClassName,
positionerProps,
popupProps,
}: DropdownMenuSubContentProps) {
return renderDropdownMenuPopup({
children,
placement,
sideOffset,
alignOffset,
className,
popupClassName,
positionerProps,
popupProps,
})
}
type DropdownMenuItemProps = React.ComponentPropsWithoutRef<typeof Menu.Item> & {
destructive?: boolean
}
export function DropdownMenuItem({
className,
destructive,
...props
}: DropdownMenuItemProps) {
return (
<Menu.Item
className={cn(
menuRowBaseClassName,
menuRowStateClassName,
destructive && 'text-text-destructive',
className,
)}
{...props}
/>
)
}
export function DropdownMenuSeparator({
className,
...props
}: React.ComponentPropsWithoutRef<typeof Menu.Separator>) {
return (
<Menu.Separator
className={cn('my-1 h-px bg-divider-subtle', className)}
{...props}
/>
)
}

View File

@@ -1,29 +0,0 @@
// Placement type for overlay positioning.
// Mirrors the Floating UI Placement spec — a stable set of 12 CSS-based position values.
// Reference: https://floating-ui.com/docs/useFloating#placement
type Side = 'top' | 'bottom' | 'left' | 'right'
type Align = 'start' | 'center' | 'end'
export type Placement
= 'top'
| 'top-start'
| 'top-end'
| 'right'
| 'right-start'
| 'right-end'
| 'bottom'
| 'bottom-start'
| 'bottom-end'
| 'left'
| 'left-start'
| 'left-end'
export function parsePlacement(placement: Placement): { side: Side, align: Align } {
const [side, align] = placement.split('-') as [Side, Align | undefined]
return {
side,
align: align ?? 'center',
}
}

View File

@@ -1,107 +0,0 @@
import { Popover as BasePopover } from '@base-ui/react/popover'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import {
Popover,
PopoverClose,
PopoverContent,
PopoverDescription,
PopoverTitle,
PopoverTrigger,
} from '..'
describe('PopoverContent', () => {
describe('Placement', () => {
it('should use bottom placement and default offsets when placement props are not provided', () => {
render(
<Popover open>
<PopoverTrigger aria-label="popover trigger">Open</PopoverTrigger>
<PopoverContent
positionerProps={{ 'role': 'group', 'aria-label': 'default positioner' }}
popupProps={{ 'role': 'dialog', 'aria-label': 'default popover' }}
>
<span>Default content</span>
</PopoverContent>
</Popover>,
)
const positioner = screen.getByRole('group', { name: 'default positioner' })
const popup = screen.getByRole('dialog', { name: 'default popover' })
expect(positioner).toHaveAttribute('data-side', 'bottom')
expect(positioner).toHaveAttribute('data-align', 'center')
expect(popup).toHaveTextContent('Default content')
})
it('should apply parsed custom placement and custom offsets when placement props are provided', () => {
render(
<Popover open>
<PopoverTrigger aria-label="popover trigger">Open</PopoverTrigger>
<PopoverContent
placement="top-end"
sideOffset={14}
alignOffset={6}
positionerProps={{ 'role': 'group', 'aria-label': 'custom positioner' }}
popupProps={{ 'role': 'dialog', 'aria-label': 'custom popover' }}
>
<span>Custom placement content</span>
</PopoverContent>
</Popover>,
)
const positioner = screen.getByRole('group', { name: 'custom positioner' })
const popup = screen.getByRole('dialog', { name: 'custom popover' })
expect(positioner).toHaveAttribute('data-side', 'top')
expect(positioner).toHaveAttribute('data-align', 'end')
expect(popup).toHaveTextContent('Custom placement content')
})
})
describe('Passthrough props', () => {
it('should forward positionerProps and popupProps when passthrough props are provided', () => {
const onPopupClick = vi.fn()
render(
<Popover open>
<PopoverTrigger aria-label="popover trigger">Open</PopoverTrigger>
<PopoverContent
positionerProps={{
'role': 'group',
'aria-label': 'popover positioner',
'id': 'popover-positioner-id',
}}
popupProps={{
'id': 'popover-popup-id',
'role': 'dialog',
'aria-label': 'popover content',
'onClick': onPopupClick,
}}
>
<span>Popover body</span>
</PopoverContent>
</Popover>,
)
const positioner = screen.getByRole('group', { name: 'popover positioner' })
const popup = screen.getByRole('dialog', { name: 'popover content' })
fireEvent.click(popup)
expect(positioner).toHaveAttribute('id', 'popover-positioner-id')
expect(popup).toHaveAttribute('id', 'popover-popup-id')
expect(onPopupClick).toHaveBeenCalledTimes(1)
})
})
})
describe('Popover aliases', () => {
describe('Export mapping', () => {
it('should map aliases to the matching base popover primitives when wrapper exports are imported', () => {
expect(Popover).toBe(BasePopover.Root)
expect(PopoverTrigger).toBe(BasePopover.Trigger)
expect(PopoverClose).toBe(BasePopover.Close)
expect(PopoverTitle).toBe(BasePopover.Title)
expect(PopoverDescription).toBe(BasePopover.Description)
})
})
})

View File

@@ -1,67 +0,0 @@
'use client'
import type { Placement } from '@/app/components/base/ui/placement'
import { Popover as BasePopover } from '@base-ui/react/popover'
import * as React from 'react'
import { parsePlacement } from '@/app/components/base/ui/placement'
import { cn } from '@/utils/classnames'
export const Popover = BasePopover.Root
export const PopoverTrigger = BasePopover.Trigger
export const PopoverClose = BasePopover.Close
export const PopoverTitle = BasePopover.Title
export const PopoverDescription = BasePopover.Description
type PopoverContentProps = {
children: React.ReactNode
placement?: Placement
sideOffset?: number
alignOffset?: number
className?: string
popupClassName?: string
positionerProps?: Omit<
React.ComponentPropsWithoutRef<typeof BasePopover.Positioner>,
'children' | 'className' | 'side' | 'align' | 'sideOffset' | 'alignOffset'
>
popupProps?: Omit<
React.ComponentPropsWithoutRef<typeof BasePopover.Popup>,
'children' | 'className'
>
}
export function PopoverContent({
children,
placement = 'bottom',
sideOffset = 8,
alignOffset = 0,
className,
popupClassName,
positionerProps,
popupProps,
}: PopoverContentProps) {
const { side, align } = parsePlacement(placement)
return (
<BasePopover.Portal>
<BasePopover.Positioner
side={side}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
className={cn('z-50 outline-none', className)}
{...positionerProps}
>
<BasePopover.Popup
className={cn(
'rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg',
'origin-[var(--transform-origin)] transition-[transform,scale,opacity] data-[ending-style]:scale-95 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none',
popupClassName,
)}
{...popupProps}
>
{children}
</BasePopover.Popup>
</BasePopover.Positioner>
</BasePopover.Portal>
)
}

View File

@@ -1,219 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../index'
const renderOpenSelect = ({
triggerProps = {},
contentProps = {},
onValueChange,
}: {
triggerProps?: Record<string, unknown>
contentProps?: Record<string, unknown>
onValueChange?: (value: string | null) => void
} = {}) => {
return render(
<Select open defaultValue="seattle" onValueChange={onValueChange}>
<SelectTrigger aria-label="city select" {...triggerProps}>
<SelectValue />
</SelectTrigger>
<SelectContent
positionerProps={{
'role': 'group',
'aria-label': 'select positioner',
}}
popupProps={{
'role': 'dialog',
'aria-label': 'select popup',
}}
listProps={{
'role': 'listbox',
'aria-label': 'select list',
}}
{...contentProps}
>
<SelectItem value="seattle">Seattle</SelectItem>
<SelectItem value="new-york">New York</SelectItem>
</SelectContent>
</Select>,
)
}
describe('Select wrappers', () => {
describe('SelectTrigger', () => {
it('should render clear button when clearable is true and loading is false', () => {
renderOpenSelect({
triggerProps: { clearable: true },
})
expect(screen.getByRole('button', { name: /clear selection/i })).toBeInTheDocument()
})
it('should hide clear button when loading is true', () => {
renderOpenSelect({
triggerProps: { clearable: true, loading: true },
})
expect(screen.queryByRole('button', { name: /clear selection/i })).not.toBeInTheDocument()
})
it('should forward native trigger props when trigger props are provided', () => {
renderOpenSelect({
triggerProps: {
'aria-label': 'Choose option',
'disabled': true,
},
})
const trigger = screen.getByRole('combobox', { name: 'Choose option' })
expect(trigger).toBeDisabled()
})
it('should call onClear and stop click propagation when clear button is clicked', () => {
const onClear = vi.fn()
const onTriggerClick = vi.fn()
renderOpenSelect({
triggerProps: {
clearable: true,
onClear,
onClick: onTriggerClick,
},
})
fireEvent.click(screen.getByRole('button', { name: /clear selection/i }))
expect(onClear).toHaveBeenCalledTimes(1)
expect(onTriggerClick).not.toHaveBeenCalled()
})
it('should stop mouse down propagation when clear button receives mouse down', () => {
const onTriggerMouseDown = vi.fn()
renderOpenSelect({
triggerProps: {
clearable: true,
onMouseDown: onTriggerMouseDown,
},
})
fireEvent.mouseDown(screen.getByRole('button', { name: /clear selection/i }))
expect(onTriggerMouseDown).not.toHaveBeenCalled()
})
it('should not throw when clear button is clicked without onClear handler', () => {
renderOpenSelect({
triggerProps: { clearable: true },
})
const clearButton = screen.getByRole('button', { name: /clear selection/i })
expect(() => fireEvent.click(clearButton)).not.toThrow()
})
})
describe('SelectContent', () => {
it('should use default placement when placement is not provided', () => {
renderOpenSelect()
const positioner = screen.getByRole('group', { name: 'select positioner' })
expect(positioner).toHaveAttribute('data-side', 'bottom')
expect(positioner).toHaveAttribute('data-align', 'start')
})
it('should apply custom placement when placement props are provided', () => {
renderOpenSelect({
contentProps: {
placement: 'top-end',
sideOffset: 12,
alignOffset: 6,
},
})
const positioner = screen.getByRole('group', { name: 'select positioner' })
expect(positioner).toHaveAttribute('data-side', 'top')
expect(positioner).toHaveAttribute('data-align', 'end')
})
it('should forward passthrough props to positioner popup and list when passthrough props are provided', () => {
const onPositionerMouseEnter = vi.fn()
const onPopupClick = vi.fn()
const onListFocus = vi.fn()
render(
<Select open defaultValue="seattle">
<SelectTrigger aria-label="city select">
<SelectValue />
</SelectTrigger>
<SelectContent
positionerProps={{
'role': 'group',
'aria-label': 'select positioner',
'id': 'select-positioner',
'onMouseEnter': onPositionerMouseEnter,
}}
popupProps={{
'role': 'dialog',
'aria-label': 'select popup',
'id': 'select-popup',
'onClick': onPopupClick,
}}
listProps={{
'role': 'listbox',
'aria-label': 'select list',
'id': 'select-list',
'onFocus': onListFocus,
}}
>
<SelectItem value="seattle">Seattle</SelectItem>
</SelectContent>
</Select>,
)
const positioner = screen.getByRole('group', { name: 'select positioner' })
const popup = screen.getByRole('dialog', { name: 'select popup' })
const list = screen.getByRole('listbox', { name: 'select list' })
fireEvent.mouseEnter(positioner)
fireEvent.click(popup)
fireEvent.focus(list)
expect(positioner).toHaveAttribute('id', 'select-positioner')
expect(popup).toHaveAttribute('id', 'select-popup')
expect(list).toHaveAttribute('id', 'select-list')
expect(onPositionerMouseEnter).toHaveBeenCalledTimes(1)
expect(onPopupClick).toHaveBeenCalledTimes(1)
expect(onListFocus).toHaveBeenCalledTimes(1)
})
})
describe('SelectItem', () => {
it('should render options when children are provided', () => {
renderOpenSelect()
expect(screen.getByRole('option', { name: 'Seattle' })).toBeInTheDocument()
expect(screen.getByRole('option', { name: 'New York' })).toBeInTheDocument()
})
it('should not call onValueChange when disabled item is clicked', () => {
const onValueChange = vi.fn()
render(
<Select open defaultValue="seattle" onValueChange={onValueChange}>
<SelectTrigger aria-label="city select">
<SelectValue />
</SelectTrigger>
<SelectContent listProps={{ 'role': 'listbox', 'aria-label': 'select list' }}>
<SelectItem value="seattle">Seattle</SelectItem>
<SelectItem value="new-york" disabled aria-label="Disabled New York">
New York
</SelectItem>
</SelectContent>
</Select>,
)
fireEvent.click(screen.getByRole('option', { name: 'Disabled New York' }))
expect(onValueChange).not.toHaveBeenCalled()
})
})
})

View File

@@ -1,163 +0,0 @@
'use client'
import type { Placement } from '@/app/components/base/ui/placement'
import { Select as BaseSelect } from '@base-ui/react/select'
import * as React from 'react'
import { parsePlacement } from '@/app/components/base/ui/placement'
import { cn } from '@/utils/classnames'
export const Select = BaseSelect.Root
export const SelectValue = BaseSelect.Value
export const SelectGroup = BaseSelect.Group
export const SelectGroupLabel = BaseSelect.GroupLabel
export const SelectSeparator = BaseSelect.Separator
type SelectTriggerProps = React.ComponentPropsWithoutRef<typeof BaseSelect.Trigger> & {
clearable?: boolean
onClear?: () => void
loading?: boolean
}
export function SelectTrigger({
className,
children,
clearable = false,
onClear,
loading = false,
...props
}: SelectTriggerProps) {
const showClear = clearable && !loading
return (
<BaseSelect.Trigger
className={cn(
'group relative flex h-8 w-full items-center rounded-lg border-0 bg-components-input-bg-normal px-2 text-left text-components-input-text-filled outline-none',
'hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
<span className="grow truncate">{children}</span>
{loading
? (
<span className="ml-1 shrink-0 text-text-quaternary">
<span className="i-ri-loader-4-line h-3.5 w-3.5 animate-spin" />
</span>
)
: showClear
? (
<span
role="button"
aria-label="Clear selection"
tabIndex={-1}
className="ml-1 shrink-0 cursor-pointer text-text-quaternary hover:text-text-secondary"
onClick={(e) => {
e.stopPropagation()
onClear?.()
}}
onMouseDown={(e) => {
e.stopPropagation()
}}
>
<span className="i-ri-close-circle-fill h-3.5 w-3.5" />
</span>
)
: (
<BaseSelect.Icon className="ml-1 shrink-0 text-text-quaternary transition-colors group-hover:text-text-secondary data-[open]:text-text-secondary">
<span className="i-ri-arrow-down-s-line h-4 w-4" />
</BaseSelect.Icon>
)}
</BaseSelect.Trigger>
)
}
type SelectContentProps = {
children: React.ReactNode
placement?: Placement
sideOffset?: number
alignOffset?: number
className?: string
popupClassName?: string
listClassName?: string
positionerProps?: Omit<
React.ComponentPropsWithoutRef<typeof BaseSelect.Positioner>,
'children' | 'className' | 'side' | 'align' | 'sideOffset' | 'alignOffset'
>
popupProps?: Omit<
React.ComponentPropsWithoutRef<typeof BaseSelect.Popup>,
'children' | 'className'
>
listProps?: Omit<
React.ComponentPropsWithoutRef<typeof BaseSelect.List>,
'children' | 'className'
>
}
export function SelectContent({
children,
placement = 'bottom-start',
sideOffset = 4,
alignOffset = 0,
className,
popupClassName,
listClassName,
positionerProps,
popupProps,
listProps,
}: SelectContentProps) {
const { side, align } = parsePlacement(placement)
return (
<BaseSelect.Portal>
<BaseSelect.Positioner
side={side}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
alignItemWithTrigger={false}
className={cn('z-50 outline-none', className)}
{...positionerProps}
>
<BaseSelect.Popup
className={cn(
'rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg',
'origin-[var(--transform-origin)] transition-[transform,scale,opacity] data-[ending-style]:scale-95 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none',
popupClassName,
)}
{...popupProps}
>
<BaseSelect.List
className={cn('max-h-80 min-w-[10rem] overflow-auto p-1 outline-none', listClassName)}
{...listProps}
>
{children}
</BaseSelect.List>
</BaseSelect.Popup>
</BaseSelect.Positioner>
</BaseSelect.Portal>
)
}
export function SelectItem({
className,
children,
...props
}: React.ComponentPropsWithoutRef<typeof BaseSelect.Item>) {
return (
<BaseSelect.Item
className={cn(
'flex h-8 cursor-pointer items-center rounded-lg px-2 text-text-secondary outline-none system-sm-medium',
'data-[disabled]:cursor-not-allowed data-[highlighted]:bg-state-base-hover data-[disabled]:opacity-50',
className,
)}
{...props}
>
<BaseSelect.ItemText className="mr-1 grow truncate px-1">
{children}
</BaseSelect.ItemText>
<BaseSelect.ItemIndicator className="flex shrink-0 items-center text-text-accent">
<span className="i-ri-check-line h-4 w-4" />
</BaseSelect.ItemIndicator>
</BaseSelect.Item>
)
}

View File

@@ -1,95 +0,0 @@
import { Tooltip as BaseTooltip } from '@base-ui/react/tooltip'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../index'
describe('TooltipContent', () => {
describe('Placement and offsets', () => {
it('should use default top placement when placement is not provided', () => {
render(
<Tooltip open>
<TooltipTrigger aria-label="tooltip trigger">Trigger</TooltipTrigger>
<TooltipContent role="tooltip" aria-label="default tooltip">
Tooltip body
</TooltipContent>
</Tooltip>,
)
const popup = screen.getByRole('tooltip', { name: 'default tooltip' })
expect(popup).toHaveAttribute('data-side', 'top')
expect(popup).toHaveAttribute('data-align', 'center')
expect(popup).toHaveTextContent('Tooltip body')
})
it('should apply custom placement when placement props are provided', () => {
render(
<Tooltip open>
<TooltipTrigger aria-label="tooltip trigger">Trigger</TooltipTrigger>
<TooltipContent
placement="bottom-start"
sideOffset={16}
alignOffset={6}
role="tooltip"
aria-label="custom tooltip"
>
Custom tooltip body
</TooltipContent>
</Tooltip>,
)
const popup = screen.getByRole('tooltip', { name: 'custom tooltip' })
expect(popup).toHaveAttribute('data-side', 'bottom')
expect(popup).toHaveAttribute('data-align', 'start')
expect(popup).toHaveTextContent('Custom tooltip body')
})
})
describe('Variant and popup props', () => {
it('should render popup content when variant is plain', () => {
render(
<Tooltip open>
<TooltipTrigger aria-label="tooltip trigger">Trigger</TooltipTrigger>
<TooltipContent variant="plain" role="tooltip" aria-label="plain tooltip">
Plain tooltip body
</TooltipContent>
</Tooltip>,
)
expect(screen.getByRole('tooltip', { name: 'plain tooltip' })).toHaveTextContent('Plain tooltip body')
})
it('should forward popup props and handlers when popup props are provided', () => {
const onMouseEnter = vi.fn()
render(
<Tooltip open>
<TooltipTrigger aria-label="tooltip trigger">Trigger</TooltipTrigger>
<TooltipContent
id="tooltip-popup-id"
role="tooltip"
aria-label="help text"
data-track-id="tooltip-track"
onMouseEnter={onMouseEnter}
>
Tooltip body
</TooltipContent>
</Tooltip>,
)
const popup = screen.getByRole('tooltip', { name: 'help text' })
fireEvent.mouseEnter(popup)
expect(popup).toHaveAttribute('id', 'tooltip-popup-id')
expect(popup).toHaveAttribute('data-track-id', 'tooltip-track')
expect(onMouseEnter).toHaveBeenCalledTimes(1)
})
})
})
describe('Tooltip aliases', () => {
it('should map alias exports to BaseTooltip components when wrapper exports are imported', () => {
expect(TooltipProvider).toBe(BaseTooltip.Provider)
expect(Tooltip).toBe(BaseTooltip.Root)
expect(TooltipTrigger).toBe(BaseTooltip.Trigger)
})
})

View File

@@ -1,59 +0,0 @@
'use client'
import type { Placement } from '@/app/components/base/ui/placement'
import { Tooltip as BaseTooltip } from '@base-ui/react/tooltip'
import * as React from 'react'
import { parsePlacement } from '@/app/components/base/ui/placement'
import { cn } from '@/utils/classnames'
type TooltipContentVariant = 'default' | 'plain'
export type TooltipContentProps = {
children: React.ReactNode
placement?: Placement
sideOffset?: number
alignOffset?: number
className?: string
popupClassName?: string
variant?: TooltipContentVariant
} & Omit<React.ComponentPropsWithoutRef<typeof BaseTooltip.Popup>, 'children' | 'className'>
export function TooltipContent({
children,
placement = 'top',
sideOffset = 8,
alignOffset = 0,
className,
popupClassName,
variant = 'default',
...props
}: TooltipContentProps) {
const { side, align } = parsePlacement(placement)
return (
<BaseTooltip.Portal>
<BaseTooltip.Positioner
side={side}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
className={cn('z-50 outline-none', className)}
>
<BaseTooltip.Popup
className={cn(
variant === 'default' && 'max-w-[300px] break-words rounded-md bg-components-panel-bg px-3 py-2 text-left text-text-tertiary shadow-lg system-xs-regular',
'origin-[var(--transform-origin)] transition-[opacity] data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 data-[instant]:transition-none motion-reduce:transition-none',
popupClassName,
)}
{...props}
>
{children}
</BaseTooltip.Popup>
</BaseTooltip.Positioner>
</BaseTooltip.Portal>
)
}
export const TooltipProvider = BaseTooltip.Provider
export const Tooltip = BaseTooltip.Root
export const TooltipTrigger = BaseTooltip.Trigger

View File

@@ -4,7 +4,7 @@ import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useProviderContext } from '@/context/provider-context'
import { DataSourceType } from '@/models/datasets'
import { useDocumentList } from '@/service/knowledge/use-document'
import { useDocumentsPageState } from '../hooks/use-documents-page-state'
import useDocumentsPageState from '../hooks/use-documents-page-state'
import Documents from '../index'
// Type for mock selector function - use `as MockState` to bypass strict type checking in tests
@@ -117,10 +117,13 @@ const mockHandleStatusFilterClear = vi.fn()
const mockHandleSortChange = vi.fn()
const mockHandlePageChange = vi.fn()
const mockHandleLimitChange = vi.fn()
const mockUpdatePollingState = vi.fn()
const mockAdjustPageForTotal = vi.fn()
vi.mock('../hooks/use-documents-page-state', () => ({
useDocumentsPageState: vi.fn(() => ({
default: vi.fn(() => ({
inputValue: '',
searchValue: '',
debouncedSearchValue: '',
handleInputChange: mockHandleInputChange,
statusFilterValue: 'all',
@@ -135,6 +138,9 @@ vi.mock('../hooks/use-documents-page-state', () => ({
handleLimitChange: mockHandleLimitChange,
selectedIds: [] as string[],
setSelectedIds: mockSetSelectedIds,
timerCanRun: false,
updatePollingState: mockUpdatePollingState,
adjustPageForTotal: mockAdjustPageForTotal,
})),
}))
@@ -313,33 +319,6 @@ describe('Documents', () => {
expect(screen.queryByTestId('documents-list')).not.toBeInTheDocument()
})
it('should keep rendering list when loading with existing data', () => {
vi.mocked(useDocumentList).mockReturnValueOnce({
data: {
data: [
{
id: 'doc-1',
name: 'Document 1',
indexing_status: 'completed',
data_source_type: 'upload_file',
position: 1,
enabled: true,
},
],
total: 1,
page: 1,
limit: 10,
has_more: false,
} as DocumentListResponse,
isLoading: true,
refetch: vi.fn(),
} as unknown as ReturnType<typeof useDocumentList>)
render(<Documents {...defaultProps} />)
expect(screen.getByTestId('documents-list')).toBeInTheDocument()
expect(screen.getByTestId('list-documents-count')).toHaveTextContent('1')
})
it('should render empty element when no documents exist', () => {
vi.mocked(useDocumentList).mockReturnValueOnce({
data: { data: [], total: 0, page: 1, limit: 10, has_more: false },
@@ -505,75 +484,17 @@ describe('Documents', () => {
})
})
describe('Query Options', () => {
it('should pass function refetchInterval to useDocumentList', () => {
describe('Side Effects and Cleanup', () => {
it('should call updatePollingState when documents response changes', () => {
render(<Documents {...defaultProps} />)
const payload = vi.mocked(useDocumentList).mock.calls.at(-1)?.[0]
expect(payload).toBeDefined()
expect(typeof payload?.refetchInterval).toBe('function')
expect(mockUpdatePollingState).toHaveBeenCalled()
})
it('should stop polling when all documents are in terminal statuses', () => {
it('should call adjustPageForTotal when documents response changes', () => {
render(<Documents {...defaultProps} />)
const payload = vi.mocked(useDocumentList).mock.calls.at(-1)?.[0]
const refetchInterval = payload?.refetchInterval
expect(typeof refetchInterval).toBe('function')
if (typeof refetchInterval !== 'function')
throw new Error('Expected function refetchInterval')
const interval = refetchInterval({
state: {
data: {
data: [
{ indexing_status: 'completed' },
{ indexing_status: 'paused' },
{ indexing_status: 'error' },
],
},
},
} as unknown as Parameters<typeof refetchInterval>[0])
expect(interval).toBe(false)
})
it('should keep polling for transient status filters', () => {
vi.mocked(useDocumentsPageState).mockReturnValueOnce({
inputValue: '',
debouncedSearchValue: '',
handleInputChange: mockHandleInputChange,
statusFilterValue: 'indexing',
sortValue: '-created_at' as const,
normalizedStatusFilterValue: 'indexing',
handleStatusFilterChange: mockHandleStatusFilterChange,
handleStatusFilterClear: mockHandleStatusFilterClear,
handleSortChange: mockHandleSortChange,
currPage: 0,
limit: 10,
handlePageChange: mockHandlePageChange,
handleLimitChange: mockHandleLimitChange,
selectedIds: [] as string[],
setSelectedIds: mockSetSelectedIds,
})
render(<Documents {...defaultProps} />)
const payload = vi.mocked(useDocumentList).mock.calls.at(-1)?.[0]
const refetchInterval = payload?.refetchInterval
expect(typeof refetchInterval).toBe('function')
if (typeof refetchInterval !== 'function')
throw new Error('Expected function refetchInterval')
const interval = refetchInterval({
state: {
data: {
data: [{ indexing_status: 'completed' }],
},
},
} as unknown as Parameters<typeof refetchInterval>[0])
expect(interval).toBe(2500)
expect(mockAdjustPageForTotal).toHaveBeenCalled()
})
})
@@ -670,6 +591,36 @@ describe('Documents', () => {
})
})
describe('Polling State', () => {
it('should enable polling when documents are indexing', () => {
vi.mocked(useDocumentsPageState).mockReturnValueOnce({
inputValue: '',
searchValue: '',
debouncedSearchValue: '',
handleInputChange: mockHandleInputChange,
statusFilterValue: 'all',
sortValue: '-created_at' as const,
normalizedStatusFilterValue: 'all',
handleStatusFilterChange: mockHandleStatusFilterChange,
handleStatusFilterClear: mockHandleStatusFilterClear,
handleSortChange: mockHandleSortChange,
currPage: 0,
limit: 10,
handlePageChange: mockHandlePageChange,
handleLimitChange: mockHandleLimitChange,
selectedIds: [] as string[],
setSelectedIds: mockSetSelectedIds,
timerCanRun: true,
updatePollingState: mockUpdatePollingState,
adjustPageForTotal: mockAdjustPageForTotal,
})
render(<Documents {...defaultProps} />)
expect(screen.getByTestId('documents-list')).toBeInTheDocument()
})
})
describe('Pagination', () => {
it('should display correct total in list', () => {
render(<Documents {...defaultProps} />)
@@ -684,6 +635,7 @@ describe('Documents', () => {
it('should handle page changes', () => {
vi.mocked(useDocumentsPageState).mockReturnValueOnce({
inputValue: '',
searchValue: '',
debouncedSearchValue: '',
handleInputChange: mockHandleInputChange,
statusFilterValue: 'all',
@@ -698,6 +650,9 @@ describe('Documents', () => {
handleLimitChange: mockHandleLimitChange,
selectedIds: [] as string[],
setSelectedIds: mockSetSelectedIds,
timerCanRun: false,
updatePollingState: mockUpdatePollingState,
adjustPageForTotal: mockAdjustPageForTotal,
})
render(<Documents {...defaultProps} />)
@@ -709,6 +664,7 @@ describe('Documents', () => {
it('should display selected count', () => {
vi.mocked(useDocumentsPageState).mockReturnValueOnce({
inputValue: '',
searchValue: '',
debouncedSearchValue: '',
handleInputChange: mockHandleInputChange,
statusFilterValue: 'all',
@@ -723,6 +679,9 @@ describe('Documents', () => {
handleLimitChange: mockHandleLimitChange,
selectedIds: ['doc-1', 'doc-2'],
setSelectedIds: mockSetSelectedIds,
timerCanRun: false,
updatePollingState: mockUpdatePollingState,
adjustPageForTotal: mockAdjustPageForTotal,
})
render(<Documents {...defaultProps} />)
@@ -734,6 +693,7 @@ describe('Documents', () => {
it('should pass filter value to list', () => {
vi.mocked(useDocumentsPageState).mockReturnValueOnce({
inputValue: 'test search',
searchValue: 'test search',
debouncedSearchValue: 'test search',
handleInputChange: mockHandleInputChange,
statusFilterValue: 'completed',
@@ -748,6 +708,9 @@ describe('Documents', () => {
handleLimitChange: mockHandleLimitChange,
selectedIds: [] as string[],
setSelectedIds: mockSetSelectedIds,
timerCanRun: false,
updatePollingState: mockUpdatePollingState,
adjustPageForTotal: mockAdjustPageForTotal,
})
render(<Documents {...defaultProps} />)

View File

@@ -20,8 +20,9 @@ const mockHandleSave = vi.fn()
vi.mock('../document-list/hooks', () => ({
useDocumentSort: vi.fn(() => ({
sortField: null,
sortOrder: 'desc',
sortOrder: null,
handleSort: mockHandleSort,
sortedDocuments: [],
})),
useDocumentSelection: vi.fn(() => ({
isAllSelected: false,
@@ -124,8 +125,8 @@ const defaultProps = {
pagination: { total: 0, current: 1, limit: 10, onChange: vi.fn() },
onUpdate: vi.fn(),
onManageMetadata: vi.fn(),
remoteSortValue: '-created_at',
onSortChange: vi.fn(),
statusFilterValue: 'all',
remoteSortValue: '',
}
describe('DocumentList', () => {
@@ -139,6 +140,8 @@ describe('DocumentList', () => {
render(<DocumentList {...defaultProps} />)
expect(screen.getByText('#')).toBeInTheDocument()
expect(screen.getByTestId('sort-name')).toBeInTheDocument()
expect(screen.getByTestId('sort-word_count')).toBeInTheDocument()
expect(screen.getByTestId('sort-hit_count')).toBeInTheDocument()
expect(screen.getByTestId('sort-created_at')).toBeInTheDocument()
})
@@ -161,9 +164,10 @@ describe('DocumentList', () => {
it('should render document rows from sortedDocuments', () => {
const docs = [createDoc({ id: 'a', name: 'Doc A' }), createDoc({ id: 'b', name: 'Doc B' })]
vi.mocked(useDocumentSort).mockReturnValue({
sortField: 'created_at',
sortField: null,
sortOrder: 'desc',
handleSort: mockHandleSort,
sortedDocuments: docs,
} as unknown as ReturnType<typeof useDocumentSort>)
render(<DocumentList {...defaultProps} documents={docs} />)
@@ -178,9 +182,9 @@ describe('DocumentList', () => {
it('should call handleSort when sort header is clicked', () => {
render(<DocumentList {...defaultProps} />)
fireEvent.click(screen.getByTestId('sort-created_at'))
fireEvent.click(screen.getByTestId('sort-name'))
expect(mockHandleSort).toHaveBeenCalledWith('created_at')
expect(mockHandleSort).toHaveBeenCalledWith('name')
})
})
@@ -225,6 +229,7 @@ describe('DocumentList', () => {
sortField: null,
sortOrder: 'desc',
handleSort: mockHandleSort,
sortedDocuments: [],
} as unknown as ReturnType<typeof useDocumentSort>)
render(<DocumentList {...defaultProps} documents={[]} />)

View File

@@ -2,7 +2,7 @@ import type { ReactNode } from 'react'
import type { Props as PaginationProps } from '@/app/components/base/pagination'
import type { SimpleDocumentDetail } from '@/models/datasets'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChunkingMode, DataSourceType } from '@/models/datasets'
import DocumentList from '../../list'
@@ -13,7 +13,6 @@ vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
useSearchParams: () => new URLSearchParams(),
}))
vi.mock('@/context/dataset-detail', () => ({
@@ -91,8 +90,8 @@ describe('DocumentList', () => {
pagination: defaultPagination,
onUpdate: vi.fn(),
onManageMetadata: vi.fn(),
remoteSortValue: '-created_at',
onSortChange: vi.fn(),
statusFilterValue: '',
remoteSortValue: '',
}
beforeEach(() => {
@@ -221,15 +220,16 @@ describe('DocumentList', () => {
expect(sortIcons.length).toBeGreaterThan(0)
})
it('should call onSortChange when sortable header is clicked', () => {
const onSortChange = vi.fn()
const { container } = render(<DocumentList {...defaultProps} onSortChange={onSortChange} />, { wrapper: createWrapper() })
it('should update sort order when sort header is clicked', () => {
render(<DocumentList {...defaultProps} />, { wrapper: createWrapper() })
const sortableHeaders = container.querySelectorAll('thead button')
if (sortableHeaders.length > 0)
// Find and click a sort header by its parent div containing the label text
const sortableHeaders = document.querySelectorAll('[class*="cursor-pointer"]')
if (sortableHeaders.length > 0) {
fireEvent.click(sortableHeaders[0])
}
expect(onSortChange).toHaveBeenCalled()
expect(screen.getByRole('table')).toBeInTheDocument()
})
})
@@ -360,15 +360,13 @@ describe('DocumentList', () => {
expect(modal).not.toBeInTheDocument()
})
it('should show rename modal when rename button is clicked', async () => {
it('should show rename modal when rename button is clicked', () => {
const { container } = render(<DocumentList {...defaultProps} />, { wrapper: createWrapper() })
// Find and click the rename button in the first row
const renameButtons = container.querySelectorAll('.cursor-pointer.rounded-md')
if (renameButtons.length > 0) {
await act(async () => {
fireEvent.click(renameButtons[0])
})
fireEvent.click(renameButtons[0])
}
// After clicking rename, the modal should potentially be visible
@@ -386,7 +384,7 @@ describe('DocumentList', () => {
})
describe('Edit Metadata Modal', () => {
it('should handle edit metadata action', async () => {
it('should handle edit metadata action', () => {
const props = {
...defaultProps,
selectedIds: ['doc-1'],
@@ -395,9 +393,7 @@ describe('DocumentList', () => {
const editButton = screen.queryByRole('button', { name: /metadata/i })
if (editButton) {
await act(async () => {
fireEvent.click(editButton)
})
fireEvent.click(editButton)
}
expect(screen.getByRole('table')).toBeInTheDocument()
@@ -458,6 +454,16 @@ describe('DocumentList', () => {
expect(screen.getByRole('table')).toBeInTheDocument()
})
it('should handle status filter value', () => {
const props = {
...defaultProps,
statusFilterValue: 'completed',
}
render(<DocumentList {...props} />, { wrapper: createWrapper() })
expect(screen.getByRole('table')).toBeInTheDocument()
})
it('should handle remote sort value', () => {
const props = {
...defaultProps,

View File

@@ -7,13 +7,11 @@ import { DataSourceType } from '@/models/datasets'
import DocumentTableRow from '../document-table-row'
const mockPush = vi.fn()
let mockSearchParams = ''
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
useSearchParams: () => new URLSearchParams(mockSearchParams),
}))
const createTestQueryClient = () => new QueryClient({
@@ -97,7 +95,6 @@ describe('DocumentTableRow', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSearchParams = ''
})
describe('Rendering', () => {
@@ -189,15 +186,6 @@ describe('DocumentTableRow', () => {
expect(mockPush).toHaveBeenCalledWith('/datasets/custom-dataset/documents/custom-doc')
})
it('should preserve search params when navigating to detail', () => {
mockSearchParams = 'page=2&status=error'
render(<DocumentTableRow {...defaultProps} />, { wrapper: createWrapper() })
fireEvent.click(screen.getByRole('row'))
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/documents/doc-1?page=2&status=error')
})
})
describe('Word Count Display', () => {

View File

@@ -4,8 +4,8 @@ import SortHeader from '../sort-header'
describe('SortHeader', () => {
const defaultProps = {
field: 'created_at' as const,
label: 'Upload Time',
field: 'name' as const,
label: 'File Name',
currentSortField: null,
sortOrder: 'desc' as const,
onSort: vi.fn(),
@@ -14,12 +14,12 @@ describe('SortHeader', () => {
describe('rendering', () => {
it('should render the label', () => {
render(<SortHeader {...defaultProps} />)
expect(screen.getByText('Upload Time')).toBeInTheDocument()
expect(screen.getByText('File Name')).toBeInTheDocument()
})
it('should render the sort icon', () => {
const { container } = render(<SortHeader {...defaultProps} />)
const icon = container.querySelector('button span')
const icon = container.querySelector('svg')
expect(icon).toBeInTheDocument()
})
})
@@ -27,13 +27,13 @@ describe('SortHeader', () => {
describe('inactive state', () => {
it('should have disabled text color when not active', () => {
const { container } = render(<SortHeader {...defaultProps} />)
const icon = container.querySelector('button span')
const icon = container.querySelector('svg')
expect(icon).toHaveClass('text-text-disabled')
})
it('should not be rotated when not active', () => {
const { container } = render(<SortHeader {...defaultProps} />)
const icon = container.querySelector('button span')
const icon = container.querySelector('svg')
expect(icon).not.toHaveClass('rotate-180')
})
})
@@ -41,25 +41,25 @@ describe('SortHeader', () => {
describe('active state', () => {
it('should have tertiary text color when active', () => {
const { container } = render(
<SortHeader {...defaultProps} currentSortField="created_at" />,
<SortHeader {...defaultProps} currentSortField="name" />,
)
const icon = container.querySelector('button span')
const icon = container.querySelector('svg')
expect(icon).toHaveClass('text-text-tertiary')
})
it('should not be rotated when active and desc', () => {
const { container } = render(
<SortHeader {...defaultProps} currentSortField="created_at" sortOrder="desc" />,
<SortHeader {...defaultProps} currentSortField="name" sortOrder="desc" />,
)
const icon = container.querySelector('button span')
const icon = container.querySelector('svg')
expect(icon).not.toHaveClass('rotate-180')
})
it('should be rotated when active and asc', () => {
const { container } = render(
<SortHeader {...defaultProps} currentSortField="created_at" sortOrder="asc" />,
<SortHeader {...defaultProps} currentSortField="name" sortOrder="asc" />,
)
const icon = container.querySelector('button span')
const icon = container.querySelector('svg')
expect(icon).toHaveClass('rotate-180')
})
})
@@ -69,22 +69,34 @@ describe('SortHeader', () => {
const onSort = vi.fn()
render(<SortHeader {...defaultProps} onSort={onSort} />)
fireEvent.click(screen.getByText('Upload Time'))
fireEvent.click(screen.getByText('File Name'))
expect(onSort).toHaveBeenCalledWith('created_at')
expect(onSort).toHaveBeenCalledWith('name')
})
it('should call onSort with correct field', () => {
const onSort = vi.fn()
render(<SortHeader {...defaultProps} field="hit_count" onSort={onSort} />)
render(<SortHeader {...defaultProps} field="word_count" onSort={onSort} />)
fireEvent.click(screen.getByText('Upload Time'))
fireEvent.click(screen.getByText('File Name'))
expect(onSort).toHaveBeenCalledWith('hit_count')
expect(onSort).toHaveBeenCalledWith('word_count')
})
})
describe('different fields', () => {
it('should work with word_count field', () => {
render(
<SortHeader
{...defaultProps}
field="word_count"
label="Words"
currentSortField="word_count"
/>,
)
expect(screen.getByText('Words')).toBeInTheDocument()
})
it('should work with hit_count field', () => {
render(
<SortHeader

View File

@@ -1,7 +1,8 @@
import type { FC } from 'react'
import type { SimpleDocumentDetail } from '@/models/datasets'
import { RiEditLine } from '@remixicon/react'
import { pick } from 'es-toolkit/object'
import { useRouter, useSearchParams } from 'next/navigation'
import { useRouter } from 'next/navigation'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
@@ -61,15 +62,13 @@ const DocumentTableRow: FC<DocumentTableRowProps> = React.memo(({
const { t } = useTranslation()
const { formatTime } = useTimestamp()
const router = useRouter()
const searchParams = useSearchParams()
const isFile = doc.data_source_type === DataSourceType.FILE
const fileType = isFile ? doc.data_source_detail_dict?.upload_file?.extension : ''
const queryString = searchParams.toString()
const handleRowClick = useCallback(() => {
router.push(`/datasets/${datasetId}/documents/${doc.id}${queryString ? `?${queryString}` : ''}`)
}, [router, datasetId, doc.id, queryString])
router.push(`/datasets/${datasetId}/documents/${doc.id}`)
}, [router, datasetId, doc.id])
const handleCheckboxClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
@@ -101,7 +100,7 @@ const DocumentTableRow: FC<DocumentTableRowProps> = React.memo(({
<DocumentSourceIcon doc={doc} fileType={fileType} />
</div>
<Tooltip popupContent={doc.name}>
<span className="grow truncate text-sm">{doc.name}</span>
<span className="grow-1 truncate text-sm">{doc.name}</span>
</Tooltip>
{doc.summary_index_status && (
<div className="ml-1 hidden shrink-0 group-hover:flex">
@@ -114,7 +113,7 @@ const DocumentTableRow: FC<DocumentTableRowProps> = React.memo(({
className="cursor-pointer rounded-md p-1 hover:bg-state-base-hover"
onClick={handleRenameClick}
>
<span className="i-ri-edit-line h-4 w-4 text-text-tertiary" />
<RiEditLine className="h-4 w-4 text-text-tertiary" />
</div>
</Tooltip>
</div>

View File

@@ -1,5 +1,6 @@
import type { FC } from 'react'
import type { SortField, SortOrder } from '../hooks'
import { RiArrowDownLine } from '@remixicon/react'
import * as React from 'react'
import { cn } from '@/utils/classnames'
@@ -22,20 +23,19 @@ const SortHeader: FC<SortHeaderProps> = React.memo(({
const isDesc = isActive && sortOrder === 'desc'
return (
<button
type="button"
className="flex items-center bg-transparent p-0 text-left hover:text-text-secondary"
<div
className="flex cursor-pointer items-center hover:text-text-secondary"
onClick={() => onSort(field)}
>
{label}
<span
<RiArrowDownLine
className={cn(
'i-ri-arrow-down-line ml-0.5 h-3 w-3 transition-all',
'ml-0.5 h-3 w-3 transition-all',
isActive ? 'text-text-tertiary' : 'text-text-disabled',
isActive && !isDesc ? 'rotate-180' : '',
)}
/>
</button>
</div>
)
})

View File

@@ -1,98 +1,340 @@
import type { SimpleDocumentDetail } from '@/models/datasets'
import { act, renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import { useDocumentSort } from '../use-document-sort'
describe('useDocumentSort', () => {
describe('remote state parsing', () => {
it('should parse descending created_at sort', () => {
const onRemoteSortChange = vi.fn()
const { result } = renderHook(() => useDocumentSort({
remoteSortValue: '-created_at',
onRemoteSortChange,
}))
type LocalDoc = SimpleDocumentDetail & { percent?: number }
expect(result.current.sortField).toBe('created_at')
const createMockDocument = (overrides: Partial<LocalDoc> = {}): LocalDoc => ({
id: 'doc1',
name: 'Test Document',
data_source_type: 'upload_file',
data_source_info: {},
data_source_detail_dict: {},
word_count: 100,
hit_count: 10,
created_at: 1000000,
position: 1,
doc_form: 'text_model',
enabled: true,
archived: false,
display_status: 'available',
created_from: 'api',
...overrides,
} as LocalDoc)
describe('useDocumentSort', () => {
describe('initial state', () => {
it('should return null sortField initially', () => {
const { result } = renderHook(() =>
useDocumentSort({
documents: [],
statusFilterValue: '',
remoteSortValue: '',
}),
)
expect(result.current.sortField).toBeNull()
expect(result.current.sortOrder).toBe('desc')
})
it('should parse ascending hit_count sort', () => {
const onRemoteSortChange = vi.fn()
const { result } = renderHook(() => useDocumentSort({
remoteSortValue: 'hit_count',
onRemoteSortChange,
}))
it('should return documents unchanged when no sort is applied', () => {
const docs = [
createMockDocument({ id: 'doc1', name: 'B' }),
createMockDocument({ id: 'doc2', name: 'A' }),
]
expect(result.current.sortField).toBe('hit_count')
expect(result.current.sortOrder).toBe('asc')
const { result } = renderHook(() =>
useDocumentSort({
documents: docs,
statusFilterValue: '',
remoteSortValue: '',
}),
)
expect(result.current.sortedDocuments).toEqual(docs)
})
})
describe('handleSort', () => {
it('should set sort field when called', () => {
const { result } = renderHook(() =>
useDocumentSort({
documents: [],
statusFilterValue: '',
remoteSortValue: '',
}),
)
act(() => {
result.current.handleSort('name')
})
expect(result.current.sortField).toBe('name')
expect(result.current.sortOrder).toBe('desc')
})
it('should fallback to inactive field for unsupported sort key', () => {
const onRemoteSortChange = vi.fn()
const { result } = renderHook(() => useDocumentSort({
remoteSortValue: '-name',
onRemoteSortChange,
}))
it('should toggle sort order when same field is clicked twice', () => {
const { result } = renderHook(() =>
useDocumentSort({
documents: [],
statusFilterValue: '',
remoteSortValue: '',
}),
)
act(() => {
result.current.handleSort('name')
})
expect(result.current.sortOrder).toBe('desc')
act(() => {
result.current.handleSort('name')
})
expect(result.current.sortOrder).toBe('asc')
act(() => {
result.current.handleSort('name')
})
expect(result.current.sortOrder).toBe('desc')
})
it('should reset to desc when different field is selected', () => {
const { result } = renderHook(() =>
useDocumentSort({
documents: [],
statusFilterValue: '',
remoteSortValue: '',
}),
)
act(() => {
result.current.handleSort('name')
})
act(() => {
result.current.handleSort('name')
})
expect(result.current.sortOrder).toBe('asc')
act(() => {
result.current.handleSort('word_count')
})
expect(result.current.sortField).toBe('word_count')
expect(result.current.sortOrder).toBe('desc')
})
it('should not change state when null is passed', () => {
const { result } = renderHook(() =>
useDocumentSort({
documents: [],
statusFilterValue: '',
remoteSortValue: '',
}),
)
act(() => {
result.current.handleSort(null)
})
expect(result.current.sortField).toBeNull()
})
})
describe('sorting documents', () => {
const docs = [
createMockDocument({ id: 'doc1', name: 'Banana', word_count: 200, hit_count: 5, created_at: 3000 }),
createMockDocument({ id: 'doc2', name: 'Apple', word_count: 100, hit_count: 10, created_at: 1000 }),
createMockDocument({ id: 'doc3', name: 'Cherry', word_count: 300, hit_count: 1, created_at: 2000 }),
]
it('should sort by name descending', () => {
const { result } = renderHook(() =>
useDocumentSort({
documents: docs,
statusFilterValue: '',
remoteSortValue: '',
}),
)
act(() => {
result.current.handleSort('name')
})
const names = result.current.sortedDocuments.map(d => d.name)
expect(names).toEqual(['Cherry', 'Banana', 'Apple'])
})
it('should sort by name ascending', () => {
const { result } = renderHook(() =>
useDocumentSort({
documents: docs,
statusFilterValue: '',
remoteSortValue: '',
}),
)
act(() => {
result.current.handleSort('name')
})
act(() => {
result.current.handleSort('name')
})
const names = result.current.sortedDocuments.map(d => d.name)
expect(names).toEqual(['Apple', 'Banana', 'Cherry'])
})
it('should sort by word_count descending', () => {
const { result } = renderHook(() =>
useDocumentSort({
documents: docs,
statusFilterValue: '',
remoteSortValue: '',
}),
)
act(() => {
result.current.handleSort('word_count')
})
const counts = result.current.sortedDocuments.map(d => d.word_count)
expect(counts).toEqual([300, 200, 100])
})
it('should sort by hit_count ascending', () => {
const { result } = renderHook(() =>
useDocumentSort({
documents: docs,
statusFilterValue: '',
remoteSortValue: '',
}),
)
act(() => {
result.current.handleSort('hit_count')
})
act(() => {
result.current.handleSort('hit_count')
})
const counts = result.current.sortedDocuments.map(d => d.hit_count)
expect(counts).toEqual([1, 5, 10])
})
it('should sort by created_at descending', () => {
const { result } = renderHook(() =>
useDocumentSort({
documents: docs,
statusFilterValue: '',
remoteSortValue: '',
}),
)
act(() => {
result.current.handleSort('created_at')
})
const times = result.current.sortedDocuments.map(d => d.created_at)
expect(times).toEqual([3000, 2000, 1000])
})
})
describe('status filtering', () => {
const docs = [
createMockDocument({ id: 'doc1', display_status: 'available' }),
createMockDocument({ id: 'doc2', display_status: 'error' }),
createMockDocument({ id: 'doc3', display_status: 'available' }),
]
it('should not filter when statusFilterValue is empty', () => {
const { result } = renderHook(() =>
useDocumentSort({
documents: docs,
statusFilterValue: '',
remoteSortValue: '',
}),
)
expect(result.current.sortedDocuments.length).toBe(3)
})
it('should not filter when statusFilterValue is all', () => {
const { result } = renderHook(() =>
useDocumentSort({
documents: docs,
statusFilterValue: 'all',
remoteSortValue: '',
}),
)
expect(result.current.sortedDocuments.length).toBe(3)
})
})
describe('remoteSortValue reset', () => {
it('should reset sort state when remoteSortValue changes', () => {
const { result, rerender } = renderHook(
({ remoteSortValue }) =>
useDocumentSort({
documents: [],
statusFilterValue: '',
remoteSortValue,
}),
{ initialProps: { remoteSortValue: 'initial' } },
)
act(() => {
result.current.handleSort('name')
})
act(() => {
result.current.handleSort('name')
})
expect(result.current.sortField).toBe('name')
expect(result.current.sortOrder).toBe('asc')
rerender({ remoteSortValue: 'changed' })
expect(result.current.sortField).toBeNull()
expect(result.current.sortOrder).toBe('desc')
})
})
describe('handleSort', () => {
it('should switch to desc when selecting a different field', () => {
const onRemoteSortChange = vi.fn()
const { result } = renderHook(() => useDocumentSort({
remoteSortValue: '-created_at',
onRemoteSortChange,
}))
describe('edge cases', () => {
it('should handle documents with missing values', () => {
const docs = [
createMockDocument({ id: 'doc1', name: undefined as unknown as string, word_count: undefined }),
createMockDocument({ id: 'doc2', name: 'Test', word_count: 100 }),
]
const { result } = renderHook(() =>
useDocumentSort({
documents: docs,
statusFilterValue: '',
remoteSortValue: '',
}),
)
act(() => {
result.current.handleSort('hit_count')
result.current.handleSort('name')
})
expect(onRemoteSortChange).toHaveBeenCalledWith('-hit_count')
expect(result.current.sortedDocuments.length).toBe(2)
})
it('should toggle desc -> asc when clicking active field', () => {
const onRemoteSortChange = vi.fn()
const { result } = renderHook(() => useDocumentSort({
remoteSortValue: '-hit_count',
onRemoteSortChange,
}))
it('should handle empty documents array', () => {
const { result } = renderHook(() =>
useDocumentSort({
documents: [],
statusFilterValue: '',
remoteSortValue: '',
}),
)
act(() => {
result.current.handleSort('hit_count')
result.current.handleSort('name')
})
expect(onRemoteSortChange).toHaveBeenCalledWith('hit_count')
})
it('should toggle asc -> desc when clicking active field', () => {
const onRemoteSortChange = vi.fn()
const { result } = renderHook(() => useDocumentSort({
remoteSortValue: 'created_at',
onRemoteSortChange,
}))
act(() => {
result.current.handleSort('created_at')
})
expect(onRemoteSortChange).toHaveBeenCalledWith('-created_at')
})
it('should ignore null field', () => {
const onRemoteSortChange = vi.fn()
const { result } = renderHook(() => useDocumentSort({
remoteSortValue: '-created_at',
onRemoteSortChange,
}))
act(() => {
result.current.handleSort(null)
})
expect(onRemoteSortChange).not.toHaveBeenCalled()
expect(result.current.sortedDocuments).toEqual([])
})
})
})

View File

@@ -1,42 +1,102 @@
import { useCallback, useMemo } from 'react'
import type { SimpleDocumentDetail } from '@/models/datasets'
import { useCallback, useMemo, useRef, useState } from 'react'
import { normalizeStatusForQuery } from '@/app/components/datasets/documents/status-filter'
type RemoteSortField = 'hit_count' | 'created_at'
const REMOTE_SORT_FIELDS = new Set<RemoteSortField>(['hit_count', 'created_at'])
export type SortField = RemoteSortField | null
export type SortField = 'name' | 'word_count' | 'hit_count' | 'created_at' | null
export type SortOrder = 'asc' | 'desc'
type LocalDoc = SimpleDocumentDetail & { percent?: number }
type UseDocumentSortOptions = {
documents: LocalDoc[]
statusFilterValue: string
remoteSortValue: string
onRemoteSortChange: (nextSortValue: string) => void
}
export const useDocumentSort = ({
documents,
statusFilterValue,
remoteSortValue,
onRemoteSortChange,
}: UseDocumentSortOptions) => {
const sortOrder: SortOrder = remoteSortValue.startsWith('-') ? 'desc' : 'asc'
const sortKey = remoteSortValue.startsWith('-') ? remoteSortValue.slice(1) : remoteSortValue
const [sortField, setSortField] = useState<SortField>(null)
const [sortOrder, setSortOrder] = useState<SortOrder>('desc')
const prevRemoteSortValueRef = useRef(remoteSortValue)
const sortField = useMemo<SortField>(() => {
return REMOTE_SORT_FIELDS.has(sortKey as RemoteSortField) ? sortKey as RemoteSortField : null
}, [sortKey])
// Reset sort when remote sort changes
if (prevRemoteSortValueRef.current !== remoteSortValue) {
prevRemoteSortValueRef.current = remoteSortValue
setSortField(null)
setSortOrder('desc')
}
const handleSort = useCallback((field: SortField) => {
if (!field)
if (field === null)
return
if (sortField === field) {
const nextSortOrder = sortOrder === 'desc' ? 'asc' : 'desc'
onRemoteSortChange(nextSortOrder === 'desc' ? `-${field}` : field)
return
setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc')
}
onRemoteSortChange(`-${field}`)
}, [onRemoteSortChange, sortField, sortOrder])
else {
setSortField(field)
setSortOrder('desc')
}
}, [sortField])
const sortedDocuments = useMemo(() => {
let filteredDocs = documents
if (statusFilterValue && statusFilterValue !== 'all') {
filteredDocs = filteredDocs.filter(doc =>
typeof doc.display_status === 'string'
&& normalizeStatusForQuery(doc.display_status) === statusFilterValue,
)
}
if (!sortField)
return filteredDocs
const sortedDocs = [...filteredDocs].sort((a, b) => {
let aValue: string | number
let bValue: string | number
switch (sortField) {
case 'name':
aValue = a.name?.toLowerCase() || ''
bValue = b.name?.toLowerCase() || ''
break
case 'word_count':
aValue = a.word_count || 0
bValue = b.word_count || 0
break
case 'hit_count':
aValue = a.hit_count || 0
bValue = b.hit_count || 0
break
case 'created_at':
aValue = a.created_at
bValue = b.created_at
break
default:
return 0
}
if (sortField === 'name') {
const result = (aValue as string).localeCompare(bValue as string)
return sortOrder === 'asc' ? result : -result
}
else {
const result = (aValue as number) - (bValue as number)
return sortOrder === 'asc' ? result : -result
}
})
return sortedDocs
}, [documents, sortField, sortOrder, statusFilterValue])
return {
sortField,
sortOrder,
handleSort,
sortedDocuments,
}
}

View File

@@ -14,7 +14,7 @@ import { useDatasetDetailContextWithSelector as useDatasetDetailContext } from '
import { ChunkingMode, DocumentActionType } from '@/models/datasets'
import BatchAction from '../detail/completed/common/batch-action'
import s from '../style.module.css'
import { DocumentTableRow, SortHeader } from './document-list/components'
import { DocumentTableRow, renderTdValue, SortHeader } from './document-list/components'
import { useDocumentActions, useDocumentSelection, useDocumentSort } from './document-list/hooks'
import RenameModal from './rename-modal'
@@ -29,8 +29,8 @@ type DocumentListProps = {
pagination: PaginationProps
onUpdate: () => void
onManageMetadata: () => void
statusFilterValue: string
remoteSortValue: string
onSortChange: (value: string) => void
}
/**
@@ -45,8 +45,8 @@ const DocumentList: FC<DocumentListProps> = ({
pagination,
onUpdate,
onManageMetadata,
statusFilterValue,
remoteSortValue,
onSortChange,
}) => {
const { t } = useTranslation()
const datasetConfig = useDatasetDetailContext(s => s.dataset)
@@ -55,9 +55,10 @@ const DocumentList: FC<DocumentListProps> = ({
const isQAMode = chunkingMode === ChunkingMode.qa
// Sorting
const { sortField, sortOrder, handleSort } = useDocumentSort({
const { sortField, sortOrder, handleSort, sortedDocuments } = useDocumentSort({
documents,
statusFilterValue,
remoteSortValue,
onRemoteSortChange: onSortChange,
})
// Selection
@@ -70,7 +71,7 @@ const DocumentList: FC<DocumentListProps> = ({
downloadableSelectedIds,
clearSelection,
} = useDocumentSelection({
documents,
documents: sortedDocuments,
selectedIds,
onSelectedIdChange,
})
@@ -134,10 +135,24 @@ const DocumentList: FC<DocumentListProps> = ({
</div>
</td>
<td>
{t('list.table.header.fileName', { ns: 'datasetDocuments' })}
<SortHeader
field="name"
label={t('list.table.header.fileName', { ns: 'datasetDocuments' })}
currentSortField={sortField}
sortOrder={sortOrder}
onSort={handleSort}
/>
</td>
<td className="w-[130px]">{t('list.table.header.chunkingMode', { ns: 'datasetDocuments' })}</td>
<td className="w-24">{t('list.table.header.words', { ns: 'datasetDocuments' })}</td>
<td className="w-24">
<SortHeader
field="word_count"
label={t('list.table.header.words', { ns: 'datasetDocuments' })}
currentSortField={sortField}
sortOrder={sortOrder}
onSort={handleSort}
/>
</td>
<td className="w-44">
<SortHeader
field="hit_count"
@@ -161,7 +176,7 @@ const DocumentList: FC<DocumentListProps> = ({
</tr>
</thead>
<tbody className="text-text-secondary">
{documents.map((doc, index) => (
{sortedDocuments.map((doc, index) => (
<DocumentTableRow
key={doc.id}
doc={doc}
@@ -233,3 +248,5 @@ const DocumentList: FC<DocumentListProps> = ({
}
export default DocumentList
export { renderTdValue }

View File

@@ -9,7 +9,6 @@ const mocks = vi.hoisted(() => {
documentError: null as Error | null,
documentMetadata: null as Record<string, unknown> | null,
media: 'desktop' as string,
searchParams: '' as string,
}
return {
state,
@@ -27,7 +26,6 @@ const mocks = vi.hoisted(() => {
// --- External mocks ---
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: mocks.push }),
useSearchParams: () => new URLSearchParams(mocks.state.searchParams),
}))
vi.mock('@/hooks/use-breakpoints', () => ({
@@ -195,7 +193,6 @@ describe('DocumentDetail', () => {
mocks.state.documentError = null
mocks.state.documentMetadata = null
mocks.state.media = 'desktop'
mocks.state.searchParams = ''
})
afterEach(() => {
@@ -289,23 +286,15 @@ describe('DocumentDetail', () => {
})
it('should toggle metadata panel when button clicked', () => {
render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />)
const { container } = render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />)
expect(screen.getByTestId('metadata')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('document-detail-metadata-toggle'))
const svgs = container.querySelectorAll('svg')
const toggleBtn = svgs[svgs.length - 1].closest('button')!
fireEvent.click(toggleBtn)
expect(screen.queryByTestId('metadata')).not.toBeInTheDocument()
})
it('should expose aria semantics for metadata toggle button', () => {
render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />)
const toggle = screen.getByTestId('document-detail-metadata-toggle')
expect(toggle).toHaveAttribute('aria-label')
expect(toggle).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(toggle)
expect(toggle).toHaveAttribute('aria-pressed', 'false')
})
it('should pass correct props to Metadata', () => {
render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />)
const metadata = screen.getByTestId('metadata')
@@ -316,21 +305,20 @@ describe('DocumentDetail', () => {
describe('Navigation', () => {
it('should navigate back when back button clicked', () => {
render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />)
fireEvent.click(screen.getByTestId('document-detail-back-button'))
const { container } = render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />)
const backBtn = container.querySelector('svg')!.parentElement!
fireEvent.click(backBtn)
expect(mocks.push).toHaveBeenCalledWith('/datasets/ds-1/documents')
})
it('should expose aria label for back button', () => {
render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />)
expect(screen.getByTestId('document-detail-back-button')).toHaveAttribute('aria-label')
})
it('should preserve query params when navigating back', () => {
mocks.state.searchParams = 'page=2&status=active'
render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />)
fireEvent.click(screen.getByTestId('document-detail-back-button'))
const origLocation = window.location
window.history.pushState({}, '', '?page=2&status=active')
const { container } = render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />)
const backBtn = container.querySelector('svg')!.parentElement!
fireEvent.click(backBtn)
expect(mocks.push).toHaveBeenCalledWith('/datasets/ds-1/documents?page=2&status=active')
window.history.pushState({}, '', origLocation.href)
})
})

View File

@@ -1,7 +1,8 @@
'use client'
import type { FC } from 'react'
import type { DataSourceInfo, FileItem, FullDocumentDetail, LegacyDataSourceInfo } from '@/models/datasets'
import { useRouter, useSearchParams } from 'next/navigation'
import { RiArrowLeftLine, RiLayoutLeft2Line, RiLayoutRight2Line } from '@remixicon/react'
import { useRouter } from 'next/navigation'
import * as React from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -34,7 +35,6 @@ type DocumentDetailProps = {
const DocumentDetail: FC<DocumentDetailProps> = ({ datasetId, documentId }) => {
const router = useRouter()
const searchParams = useSearchParams()
const { t } = useTranslation()
const media = useBreakpoints()
@@ -98,8 +98,11 @@ const DocumentDetail: FC<DocumentDetailProps> = ({ datasetId, documentId }) => {
})
const backToPrev = () => {
// Preserve pagination and filter states when navigating back
const searchParams = new URLSearchParams(window.location.search)
const queryString = searchParams.toString()
const backPath = `/datasets/${datasetId}/documents${queryString ? `?${queryString}` : ''}`
const separator = queryString ? '?' : ''
const backPath = `/datasets/${datasetId}/documents${separator}${queryString}`
router.push(backPath)
}
@@ -149,11 +152,6 @@ const DocumentDetail: FC<DocumentDetailProps> = ({ datasetId, documentId }) => {
return chunkMode === ChunkingMode.parentChild && parentMode === 'full-doc'
}, [documentDetail?.doc_form, parentMode])
const backButtonLabel = t('operation.back', { ns: 'common' })
const metadataToggleLabel = `${showMetadata
? t('operation.close', { ns: 'common' })
: t('operation.view', { ns: 'common' })} ${t('metadata.title', { ns: 'datasetDocuments' })}`
return (
<DocumentContext.Provider value={{
datasetId,
@@ -164,19 +162,9 @@ const DocumentDetail: FC<DocumentDetailProps> = ({ datasetId, documentId }) => {
>
<div className="flex h-full flex-col bg-background-default">
<div className="flex min-h-16 flex-wrap items-center justify-between border-b border-b-divider-subtle py-2.5 pl-3 pr-4">
<button
type="button"
data-testid="document-detail-back-button"
aria-label={backButtonLabel}
title={backButtonLabel}
onClick={backToPrev}
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full hover:bg-components-button-tertiary-bg"
>
<span
aria-hidden="true"
className="i-ri-arrow-left-line h-4 w-4 text-components-button-ghost-text hover:text-text-tertiary"
/>
</button>
<div onClick={backToPrev} className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full hover:bg-components-button-tertiary-bg">
<RiArrowLeftLine className="h-4 w-4 text-components-button-ghost-text hover:text-text-tertiary" />
</div>
<DocumentTitle
datasetId={datasetId}
extension={documentUploadFile?.extension}
@@ -228,17 +216,13 @@ const DocumentDetail: FC<DocumentDetailProps> = ({ datasetId, documentId }) => {
/>
<button
type="button"
data-testid="document-detail-metadata-toggle"
aria-label={metadataToggleLabel}
aria-pressed={showMetadata}
title={metadataToggleLabel}
className={style.layoutRightIcon}
onClick={() => setShowMetadata(!showMetadata)}
>
{
showMetadata
? <span aria-hidden="true" className="i-ri-layout-left-2-line h-4 w-4 text-components-button-secondary-text" />
: <span aria-hidden="true" className="i-ri-layout-right-2-line h-4 w-4 text-components-button-secondary-text" />
? <RiLayoutLeft2Line className="h-4 w-4 text-components-button-secondary-text" />
: <RiLayoutRight2Line className="h-4 w-4 text-components-button-secondary-text" />
}
</button>
</div>

View File

@@ -0,0 +1,439 @@
import type { DocumentListQuery } from '../use-document-list-query-state'
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import useDocumentListQueryState from '../use-document-list-query-state'
const mockPush = vi.fn()
const mockSearchParams = new URLSearchParams()
vi.mock('@/models/datasets', () => ({
DisplayStatusList: [
'queuing',
'indexing',
'paused',
'error',
'available',
'enabled',
'disabled',
'archived',
],
}))
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
usePathname: () => '/datasets/test-id/documents',
useSearchParams: () => mockSearchParams,
}))
describe('useDocumentListQueryState', () => {
beforeEach(() => {
vi.clearAllMocks()
// Reset mock search params to empty
for (const key of [...mockSearchParams.keys()])
mockSearchParams.delete(key)
})
// Tests for parseParams (exposed via the query property)
describe('parseParams (via query)', () => {
it('should return default query when no search params present', () => {
const { result } = renderHook(() => useDocumentListQueryState())
expect(result.current.query).toEqual({
page: 1,
limit: 10,
keyword: '',
status: 'all',
sort: '-created_at',
})
})
it('should parse page from search params', () => {
mockSearchParams.set('page', '3')
const { result } = renderHook(() => useDocumentListQueryState())
expect(result.current.query.page).toBe(3)
})
it('should default page to 1 when page is zero', () => {
mockSearchParams.set('page', '0')
const { result } = renderHook(() => useDocumentListQueryState())
expect(result.current.query.page).toBe(1)
})
it('should default page to 1 when page is negative', () => {
mockSearchParams.set('page', '-5')
const { result } = renderHook(() => useDocumentListQueryState())
expect(result.current.query.page).toBe(1)
})
it('should default page to 1 when page is NaN', () => {
mockSearchParams.set('page', 'abc')
const { result } = renderHook(() => useDocumentListQueryState())
expect(result.current.query.page).toBe(1)
})
it('should parse limit from search params', () => {
mockSearchParams.set('limit', '50')
const { result } = renderHook(() => useDocumentListQueryState())
expect(result.current.query.limit).toBe(50)
})
it('should default limit to 10 when limit is zero', () => {
mockSearchParams.set('limit', '0')
const { result } = renderHook(() => useDocumentListQueryState())
expect(result.current.query.limit).toBe(10)
})
it('should default limit to 10 when limit exceeds 100', () => {
mockSearchParams.set('limit', '101')
const { result } = renderHook(() => useDocumentListQueryState())
expect(result.current.query.limit).toBe(10)
})
it('should default limit to 10 when limit is negative', () => {
mockSearchParams.set('limit', '-1')
const { result } = renderHook(() => useDocumentListQueryState())
expect(result.current.query.limit).toBe(10)
})
it('should accept limit at boundary 100', () => {
mockSearchParams.set('limit', '100')
const { result } = renderHook(() => useDocumentListQueryState())
expect(result.current.query.limit).toBe(100)
})
it('should accept limit at boundary 1', () => {
mockSearchParams.set('limit', '1')
const { result } = renderHook(() => useDocumentListQueryState())
expect(result.current.query.limit).toBe(1)
})
it('should parse and decode keyword from search params', () => {
mockSearchParams.set('keyword', encodeURIComponent('hello world'))
const { result } = renderHook(() => useDocumentListQueryState())
expect(result.current.query.keyword).toBe('hello world')
})
it('should return empty keyword when not present', () => {
const { result } = renderHook(() => useDocumentListQueryState())
expect(result.current.query.keyword).toBe('')
})
it('should sanitize status from search params', () => {
mockSearchParams.set('status', 'available')
const { result } = renderHook(() => useDocumentListQueryState())
expect(result.current.query.status).toBe('available')
})
it('should fallback status to all for unknown status', () => {
mockSearchParams.set('status', 'badvalue')
const { result } = renderHook(() => useDocumentListQueryState())
expect(result.current.query.status).toBe('all')
})
it('should resolve active status alias to available', () => {
mockSearchParams.set('status', 'active')
const { result } = renderHook(() => useDocumentListQueryState())
expect(result.current.query.status).toBe('available')
})
it('should parse valid sort value from search params', () => {
mockSearchParams.set('sort', 'hit_count')
const { result } = renderHook(() => useDocumentListQueryState())
expect(result.current.query.sort).toBe('hit_count')
})
it('should default sort to -created_at for invalid sort value', () => {
mockSearchParams.set('sort', 'invalid_sort')
const { result } = renderHook(() => useDocumentListQueryState())
expect(result.current.query.sort).toBe('-created_at')
})
it('should default sort to -created_at when not present', () => {
const { result } = renderHook(() => useDocumentListQueryState())
expect(result.current.query.sort).toBe('-created_at')
})
it.each([
'-created_at',
'created_at',
'-hit_count',
'hit_count',
] as const)('should accept valid sort value %s', (sortValue) => {
mockSearchParams.set('sort', sortValue)
const { result } = renderHook(() => useDocumentListQueryState())
expect(result.current.query.sort).toBe(sortValue)
})
})
// Tests for updateQuery
describe('updateQuery', () => {
it('should call router.push with updated params when page is changed', () => {
const { result } = renderHook(() => useDocumentListQueryState())
act(() => {
result.current.updateQuery({ page: 3 })
})
expect(mockPush).toHaveBeenCalledTimes(1)
const pushedUrl = mockPush.mock.calls[0][0] as string
expect(pushedUrl).toContain('page=3')
})
it('should call router.push with scroll false', () => {
const { result } = renderHook(() => useDocumentListQueryState())
act(() => {
result.current.updateQuery({ page: 2 })
})
expect(mockPush).toHaveBeenCalledWith(
expect.any(String),
{ scroll: false },
)
})
it('should set status in URL when status is not all', () => {
const { result } = renderHook(() => useDocumentListQueryState())
act(() => {
result.current.updateQuery({ status: 'error' })
})
const pushedUrl = mockPush.mock.calls[0][0] as string
expect(pushedUrl).toContain('status=error')
})
it('should not set status in URL when status is all', () => {
const { result } = renderHook(() => useDocumentListQueryState())
act(() => {
result.current.updateQuery({ status: 'all' })
})
const pushedUrl = mockPush.mock.calls[0][0] as string
expect(pushedUrl).not.toContain('status=')
})
it('should set sort in URL when sort is not the default -created_at', () => {
const { result } = renderHook(() => useDocumentListQueryState())
act(() => {
result.current.updateQuery({ sort: 'hit_count' })
})
const pushedUrl = mockPush.mock.calls[0][0] as string
expect(pushedUrl).toContain('sort=hit_count')
})
it('should not set sort in URL when sort is default -created_at', () => {
const { result } = renderHook(() => useDocumentListQueryState())
act(() => {
result.current.updateQuery({ sort: '-created_at' })
})
const pushedUrl = mockPush.mock.calls[0][0] as string
expect(pushedUrl).not.toContain('sort=')
})
it('should encode keyword in URL when keyword is provided', () => {
const { result } = renderHook(() => useDocumentListQueryState())
act(() => {
result.current.updateQuery({ keyword: 'test query' })
})
const pushedUrl = mockPush.mock.calls[0][0] as string
// Source code applies encodeURIComponent before setting in URLSearchParams
expect(pushedUrl).toContain('keyword=')
const params = new URLSearchParams(pushedUrl.split('?')[1])
// params.get decodes one layer, but the value was pre-encoded with encodeURIComponent
expect(decodeURIComponent(params.get('keyword')!)).toBe('test query')
})
it('should remove keyword from URL when keyword is empty', () => {
const { result } = renderHook(() => useDocumentListQueryState())
act(() => {
result.current.updateQuery({ keyword: '' })
})
const pushedUrl = mockPush.mock.calls[0][0] as string
expect(pushedUrl).not.toContain('keyword=')
})
it('should sanitize invalid status to all and not include in URL', () => {
const { result } = renderHook(() => useDocumentListQueryState())
act(() => {
result.current.updateQuery({ status: 'invalidstatus' })
})
const pushedUrl = mockPush.mock.calls[0][0] as string
expect(pushedUrl).not.toContain('status=')
})
it('should sanitize invalid sort to -created_at and not include in URL', () => {
const { result } = renderHook(() => useDocumentListQueryState())
act(() => {
result.current.updateQuery({ sort: 'invalidsort' as DocumentListQuery['sort'] })
})
const pushedUrl = mockPush.mock.calls[0][0] as string
expect(pushedUrl).not.toContain('sort=')
})
it('should omit page and limit when they are default and no keyword', () => {
const { result } = renderHook(() => useDocumentListQueryState())
act(() => {
result.current.updateQuery({ page: 1, limit: 10 })
})
const pushedUrl = mockPush.mock.calls[0][0] as string
expect(pushedUrl).not.toContain('page=')
expect(pushedUrl).not.toContain('limit=')
})
it('should include page and limit when page is greater than 1', () => {
const { result } = renderHook(() => useDocumentListQueryState())
act(() => {
result.current.updateQuery({ page: 2 })
})
const pushedUrl = mockPush.mock.calls[0][0] as string
expect(pushedUrl).toContain('page=2')
expect(pushedUrl).toContain('limit=10')
})
it('should include page and limit when limit is non-default', () => {
const { result } = renderHook(() => useDocumentListQueryState())
act(() => {
result.current.updateQuery({ limit: 25 })
})
const pushedUrl = mockPush.mock.calls[0][0] as string
expect(pushedUrl).toContain('page=1')
expect(pushedUrl).toContain('limit=25')
})
it('should include page and limit when keyword is provided', () => {
const { result } = renderHook(() => useDocumentListQueryState())
act(() => {
result.current.updateQuery({ keyword: 'search' })
})
const pushedUrl = mockPush.mock.calls[0][0] as string
expect(pushedUrl).toContain('page=1')
expect(pushedUrl).toContain('limit=10')
})
it('should use pathname prefix in pushed URL', () => {
const { result } = renderHook(() => useDocumentListQueryState())
act(() => {
result.current.updateQuery({ page: 2 })
})
const pushedUrl = mockPush.mock.calls[0][0] as string
expect(pushedUrl).toMatch(/^\/datasets\/test-id\/documents/)
})
it('should push path without query string when all values are defaults', () => {
const { result } = renderHook(() => useDocumentListQueryState())
act(() => {
result.current.updateQuery({})
})
const pushedUrl = mockPush.mock.calls[0][0] as string
expect(pushedUrl).toBe('/datasets/test-id/documents')
})
})
// Tests for resetQuery
describe('resetQuery', () => {
it('should push URL with default query params when called', () => {
mockSearchParams.set('page', '5')
mockSearchParams.set('status', 'error')
const { result } = renderHook(() => useDocumentListQueryState())
act(() => {
result.current.resetQuery()
})
expect(mockPush).toHaveBeenCalledTimes(1)
const pushedUrl = mockPush.mock.calls[0][0] as string
// Default query has all defaults, so no params should be in the URL
expect(pushedUrl).toBe('/datasets/test-id/documents')
})
it('should call router.push with scroll false when resetting', () => {
const { result } = renderHook(() => useDocumentListQueryState())
act(() => {
result.current.resetQuery()
})
expect(mockPush).toHaveBeenCalledWith(
expect.any(String),
{ scroll: false },
)
})
})
// Tests for return value stability
describe('return value', () => {
it('should return query, updateQuery, and resetQuery', () => {
const { result } = renderHook(() => useDocumentListQueryState())
expect(result.current).toHaveProperty('query')
expect(result.current).toHaveProperty('updateQuery')
expect(result.current).toHaveProperty('resetQuery')
expect(typeof result.current.updateQuery).toBe('function')
expect(typeof result.current.resetQuery).toBe('function')
})
})
})

View File

@@ -1,426 +0,0 @@
import type { DocumentListQuery } from '../use-document-list-query-state'
import { act, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { renderHookWithNuqs } from '@/test/nuqs-testing'
import { useDocumentListQueryState } from '../use-document-list-query-state'
vi.mock('@/models/datasets', () => ({
DisplayStatusList: [
'queuing',
'indexing',
'paused',
'error',
'available',
'enabled',
'disabled',
'archived',
],
}))
const renderWithAdapter = (searchParams = '') => {
return renderHookWithNuqs(() => useDocumentListQueryState(), { searchParams })
}
describe('useDocumentListQueryState', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('query parsing', () => {
it('should return default query when no search params present', () => {
const { result } = renderWithAdapter()
expect(result.current.query).toEqual({
page: 1,
limit: 10,
keyword: '',
status: 'all',
sort: '-created_at',
})
})
it('should parse page from search params', () => {
const { result } = renderWithAdapter('?page=3')
expect(result.current.query.page).toBe(3)
})
it('should default page to 1 when page is zero', () => {
const { result } = renderWithAdapter('?page=0')
expect(result.current.query.page).toBe(1)
})
it('should default page to 1 when page is negative', () => {
const { result } = renderWithAdapter('?page=-5')
expect(result.current.query.page).toBe(1)
})
it('should default page to 1 when page is NaN', () => {
const { result } = renderWithAdapter('?page=abc')
expect(result.current.query.page).toBe(1)
})
it('should parse limit from search params', () => {
const { result } = renderWithAdapter('?limit=50')
expect(result.current.query.limit).toBe(50)
})
it('should default limit to 10 when limit is zero', () => {
const { result } = renderWithAdapter('?limit=0')
expect(result.current.query.limit).toBe(10)
})
it('should default limit to 10 when limit exceeds 100', () => {
const { result } = renderWithAdapter('?limit=101')
expect(result.current.query.limit).toBe(10)
})
it('should default limit to 10 when limit is negative', () => {
const { result } = renderWithAdapter('?limit=-1')
expect(result.current.query.limit).toBe(10)
})
it('should accept limit at boundary 100', () => {
const { result } = renderWithAdapter('?limit=100')
expect(result.current.query.limit).toBe(100)
})
it('should accept limit at boundary 1', () => {
const { result } = renderWithAdapter('?limit=1')
expect(result.current.query.limit).toBe(1)
})
it('should parse keyword from search params', () => {
const { result } = renderWithAdapter('?keyword=hello+world')
expect(result.current.query.keyword).toBe('hello world')
})
it('should preserve legacy double-encoded keyword text after URL decoding', () => {
const { result } = renderWithAdapter('?keyword=test%2520query')
expect(result.current.query.keyword).toBe('test%20query')
})
it('should return empty keyword when not present', () => {
const { result } = renderWithAdapter()
expect(result.current.query.keyword).toBe('')
})
it('should sanitize status from search params', () => {
const { result } = renderWithAdapter('?status=available')
expect(result.current.query.status).toBe('available')
})
it('should fallback status to all for unknown status', () => {
const { result } = renderWithAdapter('?status=badvalue')
expect(result.current.query.status).toBe('all')
})
it('should resolve active status alias to available', () => {
const { result } = renderWithAdapter('?status=active')
expect(result.current.query.status).toBe('available')
})
it('should parse valid sort value from search params', () => {
const { result } = renderWithAdapter('?sort=hit_count')
expect(result.current.query.sort).toBe('hit_count')
})
it('should default sort to -created_at for invalid sort value', () => {
const { result } = renderWithAdapter('?sort=invalid_sort')
expect(result.current.query.sort).toBe('-created_at')
})
it('should default sort to -created_at when not present', () => {
const { result } = renderWithAdapter()
expect(result.current.query.sort).toBe('-created_at')
})
it.each([
'-created_at',
'created_at',
'-hit_count',
'hit_count',
] as const)('should accept valid sort value %s', (sortValue) => {
const { result } = renderWithAdapter(`?sort=${sortValue}`)
expect(result.current.query.sort).toBe(sortValue)
})
})
describe('updateQuery', () => {
it('should update page in state when page is changed', () => {
const { result } = renderWithAdapter()
act(() => {
result.current.updateQuery({ page: 3 })
})
expect(result.current.query.page).toBe(3)
})
it('should sync page to URL with push history', async () => {
const { result, onUrlUpdate } = renderWithAdapter()
act(() => {
result.current.updateQuery({ page: 2 })
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.searchParams.get('page')).toBe('2')
expect(update.options.history).toBe('push')
})
it('should set status in URL when status is not all', async () => {
const { result, onUrlUpdate } = renderWithAdapter()
act(() => {
result.current.updateQuery({ status: 'error' })
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.searchParams.get('status')).toBe('error')
})
it('should not set status in URL when status is all', async () => {
const { result, onUrlUpdate } = renderWithAdapter()
act(() => {
result.current.updateQuery({ status: 'all' })
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.searchParams.has('status')).toBe(false)
})
it('should set sort in URL when sort is not the default -created_at', async () => {
const { result, onUrlUpdate } = renderWithAdapter()
act(() => {
result.current.updateQuery({ sort: 'hit_count' })
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.searchParams.get('sort')).toBe('hit_count')
})
it('should not set sort in URL when sort is default -created_at', async () => {
const { result, onUrlUpdate } = renderWithAdapter()
act(() => {
result.current.updateQuery({ sort: '-created_at' })
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.searchParams.has('sort')).toBe(false)
})
it('should set keyword in URL when keyword is provided', async () => {
const { result, onUrlUpdate } = renderWithAdapter()
act(() => {
result.current.updateQuery({ keyword: 'test query' })
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.searchParams.get('keyword')).toBe('test query')
expect(update.options.history).toBe('replace')
})
it('should use replace history when keyword update also resets page', async () => {
const { result, onUrlUpdate } = renderWithAdapter('?page=3')
act(() => {
result.current.updateQuery({ keyword: 'hello', page: 1 })
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.searchParams.get('keyword')).toBe('hello')
expect(update.searchParams.has('page')).toBe(false)
expect(update.options.history).toBe('replace')
})
it('should remove keyword from URL when keyword is empty', async () => {
const { result, onUrlUpdate } = renderWithAdapter('?keyword=existing')
act(() => {
result.current.updateQuery({ keyword: '' })
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.searchParams.has('keyword')).toBe(false)
expect(update.options.history).toBe('replace')
})
it('should remove keyword from URL when keyword contains only whitespace', async () => {
const { result, onUrlUpdate } = renderWithAdapter('?keyword=existing')
act(() => {
result.current.updateQuery({ keyword: ' ' })
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.searchParams.has('keyword')).toBe(false)
expect(result.current.query.keyword).toBe('')
})
it('should preserve literal percent-encoded-like keyword values', async () => {
const { result, onUrlUpdate } = renderWithAdapter()
act(() => {
result.current.updateQuery({ keyword: '%2F' })
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.searchParams.get('keyword')).toBe('%2F')
expect(result.current.query.keyword).toBe('%2F')
})
it('should keep keyword text unchanged when updating query from legacy URL', async () => {
const { result, onUrlUpdate } = renderWithAdapter('?keyword=test%2520query')
act(() => {
result.current.updateQuery({ page: 2 })
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
expect(result.current.query.keyword).toBe('test%20query')
})
it('should sanitize invalid status to all and not include in URL', async () => {
const { result, onUrlUpdate } = renderWithAdapter()
act(() => {
result.current.updateQuery({ status: 'invalidstatus' })
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.searchParams.has('status')).toBe(false)
})
it('should sanitize invalid sort to -created_at and not include in URL', async () => {
const { result, onUrlUpdate } = renderWithAdapter()
act(() => {
result.current.updateQuery({ sort: 'invalidsort' as DocumentListQuery['sort'] })
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.searchParams.has('sort')).toBe(false)
})
it('should not include page in URL when page is default', async () => {
const { result, onUrlUpdate } = renderWithAdapter()
act(() => {
result.current.updateQuery({ page: 1 })
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.searchParams.has('page')).toBe(false)
})
it('should include page in URL when page is greater than 1', async () => {
const { result, onUrlUpdate } = renderWithAdapter()
act(() => {
result.current.updateQuery({ page: 2 })
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.searchParams.get('page')).toBe('2')
})
it('should include limit in URL when limit is non-default', async () => {
const { result, onUrlUpdate } = renderWithAdapter()
act(() => {
result.current.updateQuery({ limit: 25 })
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.searchParams.get('limit')).toBe('25')
})
it('should sanitize invalid page to default and omit page from URL', async () => {
const { result, onUrlUpdate } = renderWithAdapter()
act(() => {
result.current.updateQuery({ page: -1 })
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.searchParams.has('page')).toBe(false)
expect(result.current.query.page).toBe(1)
})
it('should sanitize invalid limit to default and omit limit from URL', async () => {
const { result, onUrlUpdate } = renderWithAdapter()
act(() => {
result.current.updateQuery({ limit: 999 })
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.searchParams.has('limit')).toBe(false)
expect(result.current.query.limit).toBe(10)
})
})
describe('resetQuery', () => {
it('should reset all values to defaults', () => {
const { result } = renderWithAdapter('?page=5&status=error&sort=hit_count')
act(() => {
result.current.resetQuery()
})
expect(result.current.query).toEqual({
page: 1,
limit: 10,
keyword: '',
status: 'all',
sort: '-created_at',
})
})
it('should clear all params from URL when called', async () => {
const { result, onUrlUpdate } = renderWithAdapter('?page=5&status=error')
act(() => {
result.current.resetQuery()
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.searchParams.has('page')).toBe(false)
expect(update.searchParams.has('status')).toBe(false)
})
})
describe('return value', () => {
it('should return query, updateQuery, and resetQuery', () => {
const { result } = renderWithAdapter()
expect(result.current).toHaveProperty('query')
expect(result.current).toHaveProperty('updateQuery')
expect(result.current).toHaveProperty('resetQuery')
expect(typeof result.current.updateQuery).toBe('function')
expect(typeof result.current.resetQuery).toBe('function')
})
})
})

View File

@@ -1,10 +1,12 @@
import type { DocumentListQuery } from '../use-document-list-query-state'
import type { DocumentListResponse } from '@/models/datasets'
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useDocumentsPageState } from '../use-documents-page-state'
const mockUpdateQuery = vi.fn()
const mockResetQuery = vi.fn()
let mockQuery: DocumentListQuery = { page: 1, limit: 10, keyword: '', status: 'all', sort: '-created_at' }
vi.mock('@/models/datasets', () => ({
@@ -20,70 +22,151 @@ vi.mock('@/models/datasets', () => ({
],
}))
vi.mock('ahooks', () => ({
useDebounce: (value: unknown, _options?: { wait?: number }) => value,
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: vi.fn() }),
usePathname: () => '/datasets/test-id/documents',
useSearchParams: () => new URLSearchParams(),
}))
vi.mock('../use-document-list-query-state', async () => {
const React = await import('react')
// Mock ahooks debounce utilities: required because tests capture the debounce
// callback reference to invoke it synchronously, bypassing real timer delays.
let capturedDebounceFnCallback: (() => void) | null = null
vi.mock('ahooks', () => ({
useDebounce: (value: unknown, _options?: { wait?: number }) => value,
useDebounceFn: (fn: () => void, _options?: { wait?: number }) => {
capturedDebounceFnCallback = fn
return { run: fn, cancel: vi.fn(), flush: vi.fn() }
},
}))
// Mock the dependent hook
vi.mock('../use-document-list-query-state', () => ({
default: () => ({
query: mockQuery,
updateQuery: mockUpdateQuery,
resetQuery: mockResetQuery,
}),
}))
// Factory for creating DocumentListResponse test data
function createDocumentListResponse(overrides: Partial<DocumentListResponse> = {}): DocumentListResponse {
return {
useDocumentListQueryState: () => {
const [query, setQuery] = React.useState<DocumentListQuery>(mockQuery)
return {
query,
updateQuery: (updates: Partial<DocumentListQuery>) => {
mockUpdateQuery(updates)
setQuery(prev => ({ ...prev, ...updates }))
},
}
},
data: [],
has_more: false,
total: 0,
page: 1,
limit: 10,
...overrides,
}
})
}
// Factory for creating a minimal document item
function createDocumentItem(overrides: Record<string, unknown> = {}) {
return {
id: `doc-${Math.random().toString(36).slice(2, 8)}`,
name: 'test-doc.txt',
indexing_status: 'completed' as string,
display_status: 'available' as string,
enabled: true,
archived: false,
word_count: 100,
created_at: Date.now(),
updated_at: Date.now(),
created_from: 'web' as const,
created_by: 'user-1',
dataset_process_rule_id: 'rule-1',
doc_form: 'text_model' as const,
doc_language: 'en',
position: 1,
data_source_type: 'upload_file',
...overrides,
}
}
describe('useDocumentsPageState', () => {
beforeEach(() => {
vi.clearAllMocks()
capturedDebounceFnCallback = null
mockQuery = { page: 1, limit: 10, keyword: '', status: 'all', sort: '-created_at' }
})
// Initial state verification
describe('initial state', () => {
it('should return correct initial query-derived state', () => {
it('should return correct initial search state', () => {
const { result } = renderHook(() => useDocumentsPageState())
expect(result.current.inputValue).toBe('')
expect(result.current.searchValue).toBe('')
expect(result.current.debouncedSearchValue).toBe('')
})
it('should return correct initial filter and sort state', () => {
const { result } = renderHook(() => useDocumentsPageState())
expect(result.current.statusFilterValue).toBe('all')
expect(result.current.sortValue).toBe('-created_at')
expect(result.current.normalizedStatusFilterValue).toBe('all')
})
it('should return correct initial pagination state', () => {
const { result } = renderHook(() => useDocumentsPageState())
// page is query.page - 1 = 0
expect(result.current.currPage).toBe(0)
expect(result.current.limit).toBe(10)
})
it('should return correct initial selection state', () => {
const { result } = renderHook(() => useDocumentsPageState())
expect(result.current.selectedIds).toEqual([])
})
it('should initialize from non-default query values', () => {
mockQuery = {
page: 3,
limit: 25,
keyword: 'initial',
status: 'enabled',
sort: 'hit_count',
}
it('should return correct initial polling state', () => {
const { result } = renderHook(() => useDocumentsPageState())
expect(result.current.timerCanRun).toBe(true)
})
it('should initialize from query when query has keyword', () => {
mockQuery = { ...mockQuery, keyword: 'initial search' }
const { result } = renderHook(() => useDocumentsPageState())
expect(result.current.inputValue).toBe('initial')
expect(result.current.currPage).toBe(2)
expect(result.current.inputValue).toBe('initial search')
expect(result.current.searchValue).toBe('initial search')
})
it('should initialize pagination from query with non-default page', () => {
mockQuery = { ...mockQuery, page: 3, limit: 25 }
const { result } = renderHook(() => useDocumentsPageState())
expect(result.current.currPage).toBe(2) // page - 1
expect(result.current.limit).toBe(25)
expect(result.current.statusFilterValue).toBe('enabled')
expect(result.current.normalizedStatusFilterValue).toBe('available')
})
it('should initialize status filter from query', () => {
mockQuery = { ...mockQuery, status: 'error' }
const { result } = renderHook(() => useDocumentsPageState())
expect(result.current.statusFilterValue).toBe('error')
})
it('should initialize sort from query', () => {
mockQuery = { ...mockQuery, sort: 'hit_count' }
const { result } = renderHook(() => useDocumentsPageState())
expect(result.current.sortValue).toBe('hit_count')
})
})
// Handler behaviors
describe('handleInputChange', () => {
it('should update keyword and reset page', () => {
it('should update input value when called', () => {
const { result } = renderHook(() => useDocumentsPageState())
act(() => {
@@ -91,59 +174,30 @@ describe('useDocumentsPageState', () => {
})
expect(result.current.inputValue).toBe('new value')
expect(mockUpdateQuery).toHaveBeenCalledWith({ keyword: 'new value', page: 1 })
})
it('should clear selected ids when keyword changes', () => {
it('should trigger debounced search callback when called', () => {
const { result } = renderHook(() => useDocumentsPageState())
// First call sets inputValue and triggers the debounced fn
act(() => {
result.current.setSelectedIds(['doc-1'])
})
expect(result.current.selectedIds).toEqual(['doc-1'])
act(() => {
result.current.handleInputChange('keyword')
result.current.handleInputChange('search term')
})
expect(result.current.selectedIds).toEqual([])
})
it('should keep selected ids when keyword is unchanged', () => {
mockQuery = { ...mockQuery, keyword: 'same' }
const { result } = renderHook(() => useDocumentsPageState())
// The debounced fn captures inputValue from its render closure.
// After re-render with new inputValue, calling the captured callback again
// should reflect the updated state.
act(() => {
result.current.setSelectedIds(['doc-1'])
if (capturedDebounceFnCallback)
capturedDebounceFnCallback()
})
act(() => {
result.current.handleInputChange('same')
})
expect(result.current.selectedIds).toEqual(['doc-1'])
expect(mockUpdateQuery).toHaveBeenCalledWith({ keyword: 'same', page: 1 })
expect(result.current.searchValue).toBe('search term')
})
})
describe('handleStatusFilterChange', () => {
it('should sanitize status, reset page, and clear selection', () => {
const { result } = renderHook(() => useDocumentsPageState())
act(() => {
result.current.setSelectedIds(['doc-1'])
})
act(() => {
result.current.handleStatusFilterChange('invalid')
})
expect(result.current.statusFilterValue).toBe('all')
expect(result.current.selectedIds).toEqual([])
expect(mockUpdateQuery).toHaveBeenCalledWith({ status: 'all', page: 1 })
})
it('should update to valid status value', () => {
it('should update status filter value when called with valid status', () => {
const { result } = renderHook(() => useDocumentsPageState())
act(() => {
@@ -151,23 +205,61 @@ describe('useDocumentsPageState', () => {
})
expect(result.current.statusFilterValue).toBe('error')
})
it('should reset page to 0 when status filter changes', () => {
mockQuery = { ...mockQuery, page: 3 }
const { result } = renderHook(() => useDocumentsPageState())
act(() => {
result.current.handleStatusFilterChange('error')
})
expect(result.current.currPage).toBe(0)
})
it('should call updateQuery with sanitized status and page 1', () => {
const { result } = renderHook(() => useDocumentsPageState())
act(() => {
result.current.handleStatusFilterChange('error')
})
expect(mockUpdateQuery).toHaveBeenCalledWith({ status: 'error', page: 1 })
})
it('should sanitize invalid status to all', () => {
const { result } = renderHook(() => useDocumentsPageState())
act(() => {
result.current.handleStatusFilterChange('invalid')
})
expect(result.current.statusFilterValue).toBe('all')
expect(mockUpdateQuery).toHaveBeenCalledWith({ status: 'all', page: 1 })
})
})
describe('handleStatusFilterClear', () => {
it('should reset status to all when status is not all', () => {
mockQuery = { ...mockQuery, status: 'error' }
it('should set status to all and reset page when status is not all', () => {
const { result } = renderHook(() => useDocumentsPageState())
// First set a non-all status
act(() => {
result.current.handleStatusFilterChange('error')
})
vi.clearAllMocks()
// Then clear
act(() => {
result.current.handleStatusFilterClear()
})
expect(result.current.statusFilterValue).toBe('all')
expect(mockUpdateQuery).toHaveBeenCalledWith({ status: 'all', page: 1 })
})
it('should do nothing when status is already all', () => {
it('should not call updateQuery when status is already all', () => {
const { result } = renderHook(() => useDocumentsPageState())
act(() => {
@@ -179,7 +271,7 @@ describe('useDocumentsPageState', () => {
})
describe('handleSortChange', () => {
it('should update sort and reset page when sort changes', () => {
it('should update sort value and call updateQuery when value changes', () => {
const { result } = renderHook(() => useDocumentsPageState())
act(() => {
@@ -190,7 +282,18 @@ describe('useDocumentsPageState', () => {
expect(mockUpdateQuery).toHaveBeenCalledWith({ sort: 'hit_count', page: 1 })
})
it('should ignore sort update when value is unchanged', () => {
it('should reset page to 0 when sort changes', () => {
mockQuery = { ...mockQuery, page: 5 }
const { result } = renderHook(() => useDocumentsPageState())
act(() => {
result.current.handleSortChange('hit_count')
})
expect(result.current.currPage).toBe(0)
})
it('should not call updateQuery when sort value is same as current', () => {
const { result } = renderHook(() => useDocumentsPageState())
act(() => {
@@ -201,8 +304,8 @@ describe('useDocumentsPageState', () => {
})
})
describe('pagination handlers', () => {
it('should update page with one-based value', () => {
describe('handlePageChange', () => {
it('should update current page and call updateQuery', () => {
const { result } = renderHook(() => useDocumentsPageState())
act(() => {
@@ -210,10 +313,23 @@ describe('useDocumentsPageState', () => {
})
expect(result.current.currPage).toBe(2)
expect(mockUpdateQuery).toHaveBeenCalledWith({ page: 3 })
expect(mockUpdateQuery).toHaveBeenCalledWith({ page: 3 }) // newPage + 1
})
it('should update limit and reset page', () => {
it('should handle page 0 (first page)', () => {
const { result } = renderHook(() => useDocumentsPageState())
act(() => {
result.current.handlePageChange(0)
})
expect(result.current.currPage).toBe(0)
expect(mockUpdateQuery).toHaveBeenCalledWith({ page: 1 })
})
})
describe('handleLimitChange', () => {
it('should update limit, reset page to 0, and call updateQuery', () => {
const { result } = renderHook(() => useDocumentsPageState())
act(() => {
@@ -226,29 +342,359 @@ describe('useDocumentsPageState', () => {
})
})
// Selection state
describe('selection state', () => {
it('should update selectedIds via setSelectedIds', () => {
const { result } = renderHook(() => useDocumentsPageState())
act(() => {
result.current.setSelectedIds(['doc-1', 'doc-2'])
})
expect(result.current.selectedIds).toEqual(['doc-1', 'doc-2'])
})
})
// Polling state management
describe('updatePollingState', () => {
it('should not update timer when documentsRes is undefined', () => {
const { result } = renderHook(() => useDocumentsPageState())
act(() => {
result.current.updatePollingState(undefined)
})
// timerCanRun remains true (initial value)
expect(result.current.timerCanRun).toBe(true)
})
it('should not update timer when documentsRes.data is undefined', () => {
const { result } = renderHook(() => useDocumentsPageState())
act(() => {
result.current.updatePollingState({ data: undefined } as unknown as DocumentListResponse)
})
expect(result.current.timerCanRun).toBe(true)
})
it('should set timerCanRun to false when all documents are completed and status filter is all', () => {
const response = createDocumentListResponse({
data: [
createDocumentItem({ indexing_status: 'completed' }),
createDocumentItem({ indexing_status: 'completed' }),
] as DocumentListResponse['data'],
total: 2,
})
const { result } = renderHook(() => useDocumentsPageState())
act(() => {
result.current.updatePollingState(response)
})
expect(result.current.timerCanRun).toBe(false)
})
it('should set timerCanRun to true when some documents are not completed', () => {
const response = createDocumentListResponse({
data: [
createDocumentItem({ indexing_status: 'completed' }),
createDocumentItem({ indexing_status: 'indexing' }),
] as DocumentListResponse['data'],
total: 2,
})
const { result } = renderHook(() => useDocumentsPageState())
act(() => {
result.current.updatePollingState(response)
})
expect(result.current.timerCanRun).toBe(true)
})
it('should count paused documents as completed for polling purposes', () => {
const response = createDocumentListResponse({
data: [
createDocumentItem({ indexing_status: 'paused' }),
createDocumentItem({ indexing_status: 'completed' }),
] as DocumentListResponse['data'],
total: 2,
})
const { result } = renderHook(() => useDocumentsPageState())
act(() => {
result.current.updatePollingState(response)
})
// All docs are "embedded" (completed, paused, error), so hasIncomplete = false
// statusFilter is 'all', so shouldForcePolling = false
expect(result.current.timerCanRun).toBe(false)
})
it('should count error documents as completed for polling purposes', () => {
const response = createDocumentListResponse({
data: [
createDocumentItem({ indexing_status: 'error' }),
createDocumentItem({ indexing_status: 'completed' }),
] as DocumentListResponse['data'],
total: 2,
})
const { result } = renderHook(() => useDocumentsPageState())
act(() => {
result.current.updatePollingState(response)
})
expect(result.current.timerCanRun).toBe(false)
})
it('should force polling when status filter is a transient status (queuing)', () => {
const { result } = renderHook(() => useDocumentsPageState())
// Set status filter to queuing
act(() => {
result.current.handleStatusFilterChange('queuing')
})
const response = createDocumentListResponse({
data: [
createDocumentItem({ indexing_status: 'completed' }),
] as DocumentListResponse['data'],
total: 1,
})
act(() => {
result.current.updatePollingState(response)
})
// shouldForcePolling = true (queuing is transient), hasIncomplete = false
// timerCanRun = true || false = true
expect(result.current.timerCanRun).toBe(true)
})
it('should force polling when status filter is indexing', () => {
const { result } = renderHook(() => useDocumentsPageState())
act(() => {
result.current.handleStatusFilterChange('indexing')
})
const response = createDocumentListResponse({
data: [
createDocumentItem({ indexing_status: 'completed' }),
] as DocumentListResponse['data'],
total: 1,
})
act(() => {
result.current.updatePollingState(response)
})
expect(result.current.timerCanRun).toBe(true)
})
it('should force polling when status filter is paused', () => {
const { result } = renderHook(() => useDocumentsPageState())
act(() => {
result.current.handleStatusFilterChange('paused')
})
const response = createDocumentListResponse({
data: [
createDocumentItem({ indexing_status: 'paused' }),
] as DocumentListResponse['data'],
total: 1,
})
act(() => {
result.current.updatePollingState(response)
})
expect(result.current.timerCanRun).toBe(true)
})
it('should not force polling when status filter is a non-transient status (error)', () => {
const { result } = renderHook(() => useDocumentsPageState())
act(() => {
result.current.handleStatusFilterChange('error')
})
const response = createDocumentListResponse({
data: [
createDocumentItem({ indexing_status: 'error' }),
] as DocumentListResponse['data'],
total: 1,
})
act(() => {
result.current.updatePollingState(response)
})
// shouldForcePolling = false (error is not transient), hasIncomplete = false (error is embedded)
expect(result.current.timerCanRun).toBe(false)
})
it('should set timerCanRun to true when data is empty and filter is transient', () => {
const { result } = renderHook(() => useDocumentsPageState())
act(() => {
result.current.handleStatusFilterChange('indexing')
})
const response = createDocumentListResponse({ data: [] as DocumentListResponse['data'], total: 0 })
act(() => {
result.current.updatePollingState(response)
})
// shouldForcePolling = true (indexing is transient), hasIncomplete = false (0 !== 0 is false)
expect(result.current.timerCanRun).toBe(true)
})
})
// Page adjustment
describe('adjustPageForTotal', () => {
it('should not adjust page when documentsRes is undefined', () => {
const { result } = renderHook(() => useDocumentsPageState())
act(() => {
result.current.adjustPageForTotal(undefined)
})
expect(result.current.currPage).toBe(0)
})
it('should not adjust page when currPage is within total pages', () => {
const { result } = renderHook(() => useDocumentsPageState())
const response = createDocumentListResponse({ total: 20 })
act(() => {
result.current.adjustPageForTotal(response)
})
// currPage is 0, totalPages is 2, so no adjustment needed
expect(result.current.currPage).toBe(0)
})
it('should adjust page to last page when currPage exceeds total pages', () => {
mockQuery = { ...mockQuery, page: 6 }
const { result } = renderHook(() => useDocumentsPageState())
// currPage should be 5 (page - 1)
expect(result.current.currPage).toBe(5)
const response = createDocumentListResponse({ total: 30 }) // 30/10 = 3 pages
act(() => {
result.current.adjustPageForTotal(response)
})
// currPage (5) + 1 > totalPages (3), so adjust to totalPages - 1 = 2
expect(result.current.currPage).toBe(2)
expect(mockUpdateQuery).toHaveBeenCalledWith({ page: 3 }) // handlePageChange passes newPage + 1
})
it('should adjust page to 0 when total is 0 and currPage > 0', () => {
mockQuery = { ...mockQuery, page: 3 }
const { result } = renderHook(() => useDocumentsPageState())
const response = createDocumentListResponse({ total: 0 })
act(() => {
result.current.adjustPageForTotal(response)
})
// totalPages = 0, so adjust to max(0 - 1, 0) = 0
expect(result.current.currPage).toBe(0)
expect(mockUpdateQuery).toHaveBeenCalledWith({ page: 1 })
})
it('should not adjust page when currPage is 0 even if total is 0', () => {
const { result } = renderHook(() => useDocumentsPageState())
const response = createDocumentListResponse({ total: 0 })
act(() => {
result.current.adjustPageForTotal(response)
})
// currPage is 0, condition is currPage > 0 so no adjustment
expect(mockUpdateQuery).not.toHaveBeenCalled()
})
})
// Normalized status filter value
describe('normalizedStatusFilterValue', () => {
it('should return all for default status', () => {
const { result } = renderHook(() => useDocumentsPageState())
expect(result.current.normalizedStatusFilterValue).toBe('all')
})
it('should normalize enabled to available', () => {
const { result } = renderHook(() => useDocumentsPageState())
act(() => {
result.current.handleStatusFilterChange('enabled')
})
expect(result.current.normalizedStatusFilterValue).toBe('available')
})
it('should return non-aliased status as-is', () => {
const { result } = renderHook(() => useDocumentsPageState())
act(() => {
result.current.handleStatusFilterChange('error')
})
expect(result.current.normalizedStatusFilterValue).toBe('error')
})
})
// Return value shape
describe('return value', () => {
it('should return all expected properties', () => {
const { result } = renderHook(() => useDocumentsPageState())
// Search state
expect(result.current).toHaveProperty('inputValue')
expect(result.current).toHaveProperty('searchValue')
expect(result.current).toHaveProperty('debouncedSearchValue')
expect(result.current).toHaveProperty('handleInputChange')
// Filter & sort state
expect(result.current).toHaveProperty('statusFilterValue')
expect(result.current).toHaveProperty('sortValue')
expect(result.current).toHaveProperty('normalizedStatusFilterValue')
expect(result.current).toHaveProperty('handleStatusFilterChange')
expect(result.current).toHaveProperty('handleStatusFilterClear')
expect(result.current).toHaveProperty('handleSortChange')
// Pagination state
expect(result.current).toHaveProperty('currPage')
expect(result.current).toHaveProperty('limit')
expect(result.current).toHaveProperty('handlePageChange')
expect(result.current).toHaveProperty('handleLimitChange')
// Selection state
expect(result.current).toHaveProperty('selectedIds')
expect(result.current).toHaveProperty('setSelectedIds')
// Polling state
expect(result.current).toHaveProperty('timerCanRun')
expect(result.current).toHaveProperty('updatePollingState')
expect(result.current).toHaveProperty('adjustPageForTotal')
})
it('should expose function handlers', () => {
it('should have function types for all handlers', () => {
const { result } = renderHook(() => useDocumentsPageState())
expect(typeof result.current.handleInputChange).toBe('function')
@@ -258,6 +704,8 @@ describe('useDocumentsPageState', () => {
expect(typeof result.current.handlePageChange).toBe('function')
expect(typeof result.current.handleLimitChange).toBe('function')
expect(typeof result.current.setSelectedIds).toBe('function')
expect(typeof result.current.updatePollingState).toBe('function')
expect(typeof result.current.adjustPageForTotal).toBe('function')
})
})
})

View File

@@ -1,6 +1,6 @@
import type { inferParserType } from 'nuqs'
import type { ReadonlyURLSearchParams } from 'next/navigation'
import type { SortType } from '@/service/datasets'
import { createParser, parseAsString, throttle, useQueryStates } from 'nuqs'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { useCallback, useMemo } from 'react'
import { sanitizeStatusValue } from '../status-filter'
@@ -13,87 +13,99 @@ const sanitizeSortValue = (value?: string | null): SortType => {
return (ALLOWED_SORT_VALUES.includes(value as SortType) ? value : '-created_at') as SortType
}
const sanitizePageValue = (value: number): number => {
return Number.isInteger(value) && value > 0 ? value : 1
export type DocumentListQuery = {
page: number
limit: number
keyword: string
status: string
sort: SortType
}
const sanitizeLimitValue = (value: number): number => {
return Number.isInteger(value) && value > 0 && value <= 100 ? value : 10
const DEFAULT_QUERY: DocumentListQuery = {
page: 1,
limit: 10,
keyword: '',
status: 'all',
sort: '-created_at',
}
const parseAsPage = createParser<number>({
parse: (value) => {
const n = Number.parseInt(value, 10)
return Number.isNaN(n) || n <= 0 ? null : n
},
serialize: value => value.toString(),
}).withDefault(1)
// Parse the query parameters from the URL search string.
function parseParams(params: ReadonlyURLSearchParams): DocumentListQuery {
const page = Number.parseInt(params.get('page') || '1', 10)
const limit = Number.parseInt(params.get('limit') || '10', 10)
const keyword = params.get('keyword') || ''
const status = sanitizeStatusValue(params.get('status'))
const sort = sanitizeSortValue(params.get('sort'))
const parseAsLimit = createParser<number>({
parse: (value) => {
const n = Number.parseInt(value, 10)
return Number.isNaN(n) || n <= 0 || n > 100 ? null : n
},
serialize: value => value.toString(),
}).withDefault(10)
const parseAsDocStatus = createParser<string>({
parse: value => sanitizeStatusValue(value),
serialize: value => value,
}).withDefault('all')
const parseAsDocSort = createParser<SortType>({
parse: value => sanitizeSortValue(value),
serialize: value => value,
}).withDefault('-created_at' as SortType)
const parseAsKeyword = parseAsString.withDefault('')
export const documentListParsers = {
page: parseAsPage,
limit: parseAsLimit,
keyword: parseAsKeyword,
status: parseAsDocStatus,
sort: parseAsDocSort,
return {
page: page > 0 ? page : 1,
limit: (limit > 0 && limit <= 100) ? limit : 10,
keyword: keyword ? decodeURIComponent(keyword) : '',
status,
sort,
}
}
export type DocumentListQuery = inferParserType<typeof documentListParsers>
// Update the URL search string with the given query parameters.
function updateSearchParams(query: DocumentListQuery, searchParams: URLSearchParams) {
const { page, limit, keyword, status, sort } = query || {}
// Search input updates can be frequent; throttle URL writes to reduce history/api churn.
const KEYWORD_URL_UPDATE_THROTTLE = throttle(300)
const hasNonDefaultParams = (page && page > 1) || (limit && limit !== 10) || (keyword && keyword.trim())
export function useDocumentListQueryState() {
const [query, setQuery] = useQueryStates(documentListParsers)
if (hasNonDefaultParams) {
searchParams.set('page', (page || 1).toString())
searchParams.set('limit', (limit || 10).toString())
}
else {
searchParams.delete('page')
searchParams.delete('limit')
}
if (keyword && keyword.trim())
searchParams.set('keyword', encodeURIComponent(keyword))
else
searchParams.delete('keyword')
const sanitizedStatus = sanitizeStatusValue(status)
if (sanitizedStatus && sanitizedStatus !== 'all')
searchParams.set('status', sanitizedStatus)
else
searchParams.delete('status')
const sanitizedSort = sanitizeSortValue(sort)
if (sanitizedSort !== '-created_at')
searchParams.set('sort', sanitizedSort)
else
searchParams.delete('sort')
}
function useDocumentListQueryState() {
const searchParams = useSearchParams()
const query = useMemo(() => parseParams(searchParams), [searchParams])
const router = useRouter()
const pathname = usePathname()
// Helper function to update specific query parameters
const updateQuery = useCallback((updates: Partial<DocumentListQuery>) => {
const patch = { ...updates }
if ('page' in patch && patch.page !== undefined)
patch.page = sanitizePageValue(patch.page)
if ('limit' in patch && patch.limit !== undefined)
patch.limit = sanitizeLimitValue(patch.limit)
if ('status' in patch)
patch.status = sanitizeStatusValue(patch.status)
if ('sort' in patch)
patch.sort = sanitizeSortValue(patch.sort)
if ('keyword' in patch && typeof patch.keyword === 'string' && patch.keyword.trim() === '')
patch.keyword = ''
// If keyword is part of this patch (even with page reset), treat it as a search update:
// use replace to avoid creating a history entry per input-driven change.
if ('keyword' in patch) {
setQuery(patch, {
history: 'replace',
limitUrlUpdates: patch.keyword === '' ? undefined : KEYWORD_URL_UPDATE_THROTTLE,
})
return
}
setQuery(patch, { history: 'push' })
}, [setQuery])
const newQuery = { ...query, ...updates }
newQuery.status = sanitizeStatusValue(newQuery.status)
newQuery.sort = sanitizeSortValue(newQuery.sort)
const params = new URLSearchParams()
updateSearchParams(newQuery, params)
const search = params.toString()
const queryString = search ? `?${search}` : ''
router.push(`${pathname}${queryString}`, { scroll: false })
}, [query, router, pathname])
// Helper function to reset query to defaults
const resetQuery = useCallback(() => {
setQuery(null, { history: 'replace' })
}, [setQuery])
const params = new URLSearchParams()
updateSearchParams(DEFAULT_QUERY, params)
const search = params.toString()
const queryString = search ? `?${search}` : ''
router.push(`${pathname}${queryString}`, { scroll: false })
}, [router, pathname])
return useMemo(() => ({
query,
@@ -101,3 +113,5 @@ export function useDocumentListQueryState() {
resetQuery,
}), [query, updateQuery, resetQuery])
}
export default useDocumentListQueryState

View File

@@ -1,63 +1,175 @@
import type { DocumentListResponse } from '@/models/datasets'
import type { SortType } from '@/service/datasets'
import { useDebounce } from 'ahooks'
import { useCallback, useState } from 'react'
import { useDebounce, useDebounceFn } from 'ahooks'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { normalizeStatusForQuery, sanitizeStatusValue } from '../status-filter'
import { useDocumentListQueryState } from './use-document-list-query-state'
import useDocumentListQueryState from './use-document-list-query-state'
/**
* Custom hook to manage documents page state including:
* - Search state (input value, debounced search value)
* - Filter state (status filter, sort value)
* - Pagination state (current page, limit)
* - Selection state (selected document ids)
* - Polling state (timer control for auto-refresh)
*/
export function useDocumentsPageState() {
const { query, updateQuery } = useDocumentListQueryState()
const inputValue = query.keyword
const debouncedSearchValue = useDebounce(query.keyword, { wait: 500 })
// Search state
const [inputValue, setInputValue] = useState<string>('')
const [searchValue, setSearchValue] = useState<string>('')
const debouncedSearchValue = useDebounce(searchValue, { wait: 500 })
const statusFilterValue = sanitizeStatusValue(query.status)
const sortValue = query.sort
const normalizedStatusFilterValue = normalizeStatusForQuery(statusFilterValue)
// Filter & sort state
const [statusFilterValue, setStatusFilterValue] = useState<string>(() => sanitizeStatusValue(query.status))
const [sortValue, setSortValue] = useState<SortType>(query.sort)
const normalizedStatusFilterValue = useMemo(
() => normalizeStatusForQuery(statusFilterValue),
[statusFilterValue],
)
const currPage = query.page - 1
const limit = query.limit
// Pagination state
const [currPage, setCurrPage] = useState<number>(query.page - 1)
const [limit, setLimit] = useState<number>(query.limit)
// Selection state
const [selectedIds, setSelectedIds] = useState<string[]>([])
// Polling state
const [timerCanRun, setTimerCanRun] = useState(true)
// Initialize search value from URL on mount
useEffect(() => {
if (query.keyword) {
setInputValue(query.keyword)
setSearchValue(query.keyword)
}
}, []) // Only run on mount
// Sync local state with URL query changes
useEffect(() => {
setCurrPage(query.page - 1)
setLimit(query.limit)
if (query.keyword !== searchValue) {
setInputValue(query.keyword)
setSearchValue(query.keyword)
}
setStatusFilterValue((prev) => {
const nextValue = sanitizeStatusValue(query.status)
return prev === nextValue ? prev : nextValue
})
setSortValue(query.sort)
}, [query])
// Update URL when search changes
useEffect(() => {
if (debouncedSearchValue !== query.keyword) {
setCurrPage(0)
updateQuery({ keyword: debouncedSearchValue, page: 1 })
}
}, [debouncedSearchValue, query.keyword, updateQuery])
// Clear selection when search changes
useEffect(() => {
if (searchValue !== query.keyword)
setSelectedIds([])
}, [searchValue, query.keyword])
// Clear selection when status filter changes
useEffect(() => {
setSelectedIds([])
}, [normalizedStatusFilterValue])
// Page change handler
const handlePageChange = useCallback((newPage: number) => {
setCurrPage(newPage)
updateQuery({ page: newPage + 1 })
}, [updateQuery])
// Limit change handler
const handleLimitChange = useCallback((newLimit: number) => {
setLimit(newLimit)
setCurrPage(0)
updateQuery({ limit: newLimit, page: 1 })
}, [updateQuery])
const handleInputChange = useCallback((value: string) => {
if (value !== query.keyword)
setSelectedIds([])
updateQuery({ keyword: value, page: 1 })
}, [query.keyword, updateQuery])
// Debounced search handler
const { run: handleSearch } = useDebounceFn(() => {
setSearchValue(inputValue)
}, { wait: 500 })
// Input change handler
const handleInputChange = useCallback((value: string) => {
setInputValue(value)
handleSearch()
}, [handleSearch])
// Status filter change handler
const handleStatusFilterChange = useCallback((value: string) => {
const selectedValue = sanitizeStatusValue(value)
setSelectedIds([])
setStatusFilterValue(selectedValue)
setCurrPage(0)
updateQuery({ status: selectedValue, page: 1 })
}, [updateQuery])
// Status filter clear handler
const handleStatusFilterClear = useCallback(() => {
if (statusFilterValue === 'all')
return
setSelectedIds([])
setStatusFilterValue('all')
setCurrPage(0)
updateQuery({ status: 'all', page: 1 })
}, [statusFilterValue, updateQuery])
// Sort change handler
const handleSortChange = useCallback((value: string) => {
const next = value as SortType
if (next === sortValue)
return
setSortValue(next)
setCurrPage(0)
updateQuery({ sort: next, page: 1 })
}, [sortValue, updateQuery])
// Update polling state based on documents response
const updatePollingState = useCallback((documentsRes: DocumentListResponse | undefined) => {
if (!documentsRes?.data)
return
let completedNum = 0
documentsRes.data.forEach((documentItem) => {
const { indexing_status } = documentItem
const isEmbedded = indexing_status === 'completed' || indexing_status === 'paused' || indexing_status === 'error'
if (isEmbedded)
completedNum++
})
const hasIncompleteDocuments = completedNum !== documentsRes.data.length
const transientStatuses = ['queuing', 'indexing', 'paused']
const shouldForcePolling = normalizedStatusFilterValue === 'all'
? false
: transientStatuses.includes(normalizedStatusFilterValue)
setTimerCanRun(shouldForcePolling || hasIncompleteDocuments)
}, [normalizedStatusFilterValue])
// Adjust page when total pages change
const adjustPageForTotal = useCallback((documentsRes: DocumentListResponse | undefined) => {
if (!documentsRes)
return
const totalPages = Math.ceil(documentsRes.total / limit)
if (currPage > 0 && currPage + 1 > totalPages)
handlePageChange(totalPages > 0 ? totalPages - 1 : 0)
}, [limit, currPage, handlePageChange])
return {
// Search state
inputValue,
searchValue,
debouncedSearchValue,
handleInputChange,
// Filter & sort state
statusFilterValue,
sortValue,
normalizedStatusFilterValue,
@@ -65,12 +177,21 @@ export function useDocumentsPageState() {
handleStatusFilterClear,
handleSortChange,
// Pagination state
currPage,
limit,
handlePageChange,
handleLimitChange,
// Selection state
selectedIds,
setSelectedIds,
// Polling state
timerCanRun,
updatePollingState,
adjustPageForTotal,
}
}
export default useDocumentsPageState

View File

@@ -1,7 +1,7 @@
'use client'
import type { FC } from 'react'
import { useRouter } from 'next/navigation'
import { useCallback } from 'react'
import { useCallback, useEffect } from 'react'
import Loading from '@/app/components/base/loading'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useProviderContext } from '@/context/provider-context'
@@ -13,16 +13,12 @@ import useEditDocumentMetadata from '../metadata/hooks/use-edit-dataset-metadata
import DocumentsHeader from './components/documents-header'
import EmptyElement from './components/empty-element'
import List from './components/list'
import { useDocumentsPageState } from './hooks/use-documents-page-state'
import useDocumentsPageState from './hooks/use-documents-page-state'
type IDocumentsProps = {
datasetId: string
}
const POLLING_INTERVAL = 2500
const TERMINAL_INDEXING_STATUSES = new Set(['completed', 'paused', 'error'])
const FORCED_POLLING_STATUSES = new Set(['queuing', 'indexing', 'paused'])
const Documents: FC<IDocumentsProps> = ({ datasetId }) => {
const router = useRouter()
const { plan } = useProviderContext()
@@ -48,6 +44,9 @@ const Documents: FC<IDocumentsProps> = ({ datasetId }) => {
handleLimitChange,
selectedIds,
setSelectedIds,
timerCanRun,
updatePollingState,
adjustPageForTotal,
} = useDocumentsPageState()
// Fetch document list
@@ -60,17 +59,19 @@ const Documents: FC<IDocumentsProps> = ({ datasetId }) => {
status: normalizedStatusFilterValue,
sort: sortValue,
},
refetchInterval: (query) => {
const shouldForcePolling = normalizedStatusFilterValue !== 'all'
&& FORCED_POLLING_STATUSES.has(normalizedStatusFilterValue)
const documents = query.state.data?.data
if (!documents)
return POLLING_INTERVAL
const hasIncompleteDocuments = documents.some(({ indexing_status }) => !TERMINAL_INDEXING_STATUSES.has(indexing_status))
return shouldForcePolling || hasIncompleteDocuments ? POLLING_INTERVAL : false
},
refetchInterval: timerCanRun ? 2500 : 0,
})
// Update polling state when documents change
useEffect(() => {
updatePollingState(documentsRes)
}, [documentsRes, updatePollingState])
// Adjust page when total changes
useEffect(() => {
adjustPageForTotal(documentsRes)
}, [documentsRes, adjustPageForTotal])
// Invalidation hooks
const invalidDocumentList = useInvalidDocumentList(datasetId)
const invalidDocumentDetail = useInvalidDocumentDetail()
@@ -118,7 +119,7 @@ const Documents: FC<IDocumentsProps> = ({ datasetId }) => {
// Render content based on loading and data state
const renderContent = () => {
if (isListLoading && !documentsRes)
if (isListLoading)
return <Loading type="app" />
if (total > 0) {
@@ -130,8 +131,8 @@ const Documents: FC<IDocumentsProps> = ({ datasetId }) => {
onUpdate={handleUpdate}
selectedIds={selectedIds}
onSelectedIdChange={setSelectedIds}
statusFilterValue={normalizedStatusFilterValue}
remoteSortValue={sortValue}
onSortChange={handleSortChange}
pagination={{
total,
limit,

View File

@@ -1,12 +1,12 @@
import type { Mock } from 'vitest'
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import type { App } from '@/models/explore'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { fetchAppDetail } from '@/service/explore'
import { useMembers } from '@/service/use-common'
import { renderWithNuqs } from '@/test/nuqs-testing'
import { AppModeEnum } from '@/types/app'
import AppList from '../index'
@@ -132,9 +132,10 @@ const mockMemberRole = (hasEditPermission: boolean) => {
const renderAppList = (hasEditPermission = false, onSuccess?: () => void, searchParams?: Record<string, string>) => {
mockMemberRole(hasEditPermission)
return renderWithNuqs(
<AppList onSuccess={onSuccess} />,
{ searchParams },
return render(
<NuqsTestingAdapter searchParams={searchParams}>
<AppList onSuccess={onSuccess} />
</NuqsTestingAdapter>,
)
}

View File

@@ -14,17 +14,13 @@ import { slashCommandRegistry } from './registry'
import { themeCommand } from './theme'
import { zenCommand } from './zen'
const i18n = getI18n()
export const slashAction: ActionItem = {
key: '/',
shortcut: '/',
get title() {
const i18n = getI18n()
return i18n.t('gotoAnything.actions.slashTitle', { ns: 'app' })
},
get description() {
const i18n = getI18n()
return i18n.t('gotoAnything.actions.slashDesc', { ns: 'app' })
},
title: i18n.t('gotoAnything.actions.slashTitle', { ns: 'app' }),
description: i18n.t('gotoAnything.actions.slashDesc', { ns: 'app' }),
action: (result) => {
if (result.type !== 'command')
return
@@ -32,7 +28,6 @@ export const slashAction: ActionItem = {
executeCommand(command, args)
},
search: async (query, _searchTerm = '') => {
const i18n = getI18n()
// Delegate all search logic to the command registry system
return slashCommandRegistry.search(query, i18n.language)
},

View File

@@ -1,7 +1,6 @@
import type { ModalContextState } from '@/context/modal-context'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/app/components/base/ui/dropdown-menu'
import { Plan } from '@/app/components/billing/type'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useModalContext } from '@/context/modal-context'
@@ -71,26 +70,16 @@ describe('Compliance', () => {
)
}
const renderCompliance = () => {
return renderWithQueryClient(
<DropdownMenu open={true} onOpenChange={() => {}}>
<DropdownMenuTrigger>open</DropdownMenuTrigger>
<DropdownMenuContent>
<Compliance />
</DropdownMenuContent>
</DropdownMenu>,
)
}
// Wrapper for tests that need the menu open
const openMenuAndRender = () => {
renderCompliance()
fireEvent.click(screen.getByText('common.userProfile.compliance'))
renderWithQueryClient(<Compliance />)
fireEvent.click(screen.getByRole('button'))
}
describe('Rendering', () => {
it('should render compliance menu trigger', () => {
// Act
renderCompliance()
renderWithQueryClient(<Compliance />)
// Assert
expect(screen.getByText('common.userProfile.compliance')).toBeInTheDocument()

View File

@@ -1,9 +1,9 @@
import type { ReactNode } from 'react'
import type { FC, MouseEvent } from 'react'
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
import { RiArrowDownCircleLine, RiArrowRightSLine, RiVerifiedBadgeLine } from '@remixicon/react'
import { useMutation } from '@tanstack/react-query'
import { useCallback } from 'react'
import { Fragment, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger } from '@/app/components/base/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import { Plan } from '@/app/components/billing/type'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useModalContext } from '@/context/modal-context'
@@ -11,14 +11,14 @@ import { useProviderContext } from '@/context/provider-context'
import { getDocDownloadUrl } from '@/service/common'
import { cn } from '@/utils/classnames'
import { downloadUrl } from '@/utils/download'
import Button from '../../base/button'
import Gdpr from '../../base/icons/src/public/common/Gdpr'
import Iso from '../../base/icons/src/public/common/Iso'
import Soc2 from '../../base/icons/src/public/common/Soc2'
import SparklesSoft from '../../base/icons/src/public/common/SparklesSoft'
import PremiumBadge from '../../base/premium-badge'
import Spinner from '../../base/spinner'
import Toast from '../../base/toast'
import { MenuItemContent } from './menu-item-content'
import Tooltip from '../../base/tooltip'
enum DocName {
SOC2_Type_I = 'SOC2_Type_I',
@@ -27,83 +27,27 @@ enum DocName {
GDPR = 'GDPR',
}
type ComplianceDocActionVisualProps = {
isCurrentPlanCanDownload: boolean
isPending: boolean
tooltipText: string
downloadText: string
upgradeText: string
type UpgradeOrDownloadProps = {
doc_name: DocName
}
function ComplianceDocActionVisual({
isCurrentPlanCanDownload,
isPending,
tooltipText,
downloadText,
upgradeText,
}: ComplianceDocActionVisualProps) {
if (isCurrentPlanCanDownload) {
return (
<div
aria-hidden
className={cn(
'btn btn-small btn-secondary pointer-events-none flex items-center gap-[1px]',
isPending && 'btn-disabled',
)}
>
<span className="i-ri-arrow-down-circle-line size-[14px] text-components-button-secondary-text-disabled" />
<span className="px-[3px] text-components-button-secondary-text system-xs-medium">{downloadText}</span>
{isPending && <Spinner loading={true} className="!ml-1 !h-3 !w-3 !border-2 !text-text-tertiary" />}
</div>
)
}
const canShowUpgradeTooltip = tooltipText.length > 0
return (
<Tooltip>
<TooltipTrigger
delay={0}
disabled={!canShowUpgradeTooltip}
render={(
<PremiumBadge color="blue" allowHover={true}>
<SparklesSoft className="flex h-3.5 w-3.5 items-center py-[1px] pl-[3px] text-components-premium-badge-indigo-text-stop-0" />
<div className="px-1 system-xs-medium">
{upgradeText}
</div>
</PremiumBadge>
)}
/>
{canShowUpgradeTooltip && (
<TooltipContent>
{tooltipText}
</TooltipContent>
)}
</Tooltip>
)
}
type ComplianceDocRowItemProps = {
icon: ReactNode
label: ReactNode
docName: DocName
}
function ComplianceDocRowItem({
icon,
label,
docName,
}: ComplianceDocRowItemProps) {
const UpgradeOrDownload: FC<UpgradeOrDownloadProps> = ({ doc_name }) => {
const { t } = useTranslation()
const { plan } = useProviderContext()
const { setShowPricingModal, setShowAccountSettingModal } = useModalContext()
const isFreePlan = plan.type === Plan.sandbox
const handlePlanClick = useCallback(() => {
if (isFreePlan)
setShowPricingModal()
else
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING })
}, [isFreePlan, setShowAccountSettingModal, setShowPricingModal])
const { isPending, mutate: downloadCompliance } = useMutation({
mutationKey: ['downloadCompliance', docName],
mutationKey: ['downloadCompliance', doc_name],
mutationFn: async () => {
try {
const ret = await getDocDownloadUrl(docName)
const ret = await getDocDownloadUrl(doc_name)
downloadUrl({ url: ret.url })
Toast.notify({
type: 'success',
@@ -119,7 +63,6 @@ function ComplianceDocRowItem({
}
},
})
const whichPlanCanDownloadCompliance = {
[DocName.SOC2_Type_I]: [Plan.professional, Plan.team],
[DocName.SOC2_Type_II]: [Plan.team],
@@ -127,85 +70,118 @@ function ComplianceDocRowItem({
[DocName.GDPR]: [Plan.team, Plan.professional, Plan.sandbox],
}
const isCurrentPlanCanDownload = whichPlanCanDownloadCompliance[docName].includes(plan.type)
const handleSelect = useCallback(() => {
if (isCurrentPlanCanDownload) {
if (!isPending)
downloadCompliance()
return
}
if (isFreePlan)
setShowPricingModal()
else
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING })
}, [downloadCompliance, isCurrentPlanCanDownload, isFreePlan, isPending, setShowAccountSettingModal, setShowPricingModal])
const isCurrentPlanCanDownload = whichPlanCanDownloadCompliance[doc_name].includes(plan.type)
const handleDownloadClick = useCallback((e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
downloadCompliance()
}, [downloadCompliance])
if (isCurrentPlanCanDownload) {
return (
<Button loading={isPending} disabled={isPending} size="small" variant="secondary" className="flex items-center gap-[1px]" onClick={handleDownloadClick}>
<RiArrowDownCircleLine className="size-[14px] text-components-button-secondary-text-disabled" />
<span className="system-xs-medium px-[3px] text-components-button-secondary-text">{t('operation.download', { ns: 'common' })}</span>
</Button>
)
}
const upgradeTooltip: Record<Plan, string> = {
[Plan.sandbox]: t('compliance.sandboxUpgradeTooltip', { ns: 'common' }),
[Plan.professional]: t('compliance.professionalUpgradeTooltip', { ns: 'common' }),
[Plan.team]: '',
[Plan.enterprise]: '',
}
return (
<DropdownMenuItem
className="h-10 justify-between py-1 pl-1 pr-2"
closeOnClick={!isCurrentPlanCanDownload}
onClick={handleSelect}
>
{icon}
<div className="grow truncate px-1 text-text-secondary system-md-regular">{label}</div>
<ComplianceDocActionVisual
isCurrentPlanCanDownload={isCurrentPlanCanDownload}
isPending={isPending}
tooltipText={upgradeTooltip[plan.type]}
downloadText={t('operation.download', { ns: 'common' })}
upgradeText={t('upgradeBtn.encourageShort', { ns: 'billing' })}
/>
</DropdownMenuItem>
<Tooltip asChild={false} popupContent={upgradeTooltip[plan.type]}>
<PremiumBadge color="blue" allowHover={true} onClick={handlePlanClick}>
<SparklesSoft className="flex h-3.5 w-3.5 items-center py-[1px] pl-[3px] text-components-premium-badge-indigo-text-stop-0" />
<div className="system-xs-medium">
<span className="p-1">
{t('upgradeBtn.encourageShort', { ns: 'billing' })}
</span>
</div>
</PremiumBadge>
</Tooltip>
)
}
// Submenu-only: this component must be rendered within an existing DropdownMenu root.
export default function Compliance() {
const itemClassName = `
flex items-center w-full h-10 pl-1 pr-2 py-1 text-text-secondary system-md-regular
rounded-lg hover:bg-state-base-hover gap-1
`
const { t } = useTranslation()
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<MenuItemContent
iconClassName="i-ri-verified-badge-line"
label={t('userProfile.compliance', { ns: 'common' })}
/>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
popupClassName="w-[337px] divide-y divide-divider-subtle !bg-components-panel-bg-blur !py-0 backdrop-blur-sm"
>
<DropdownMenuGroup className="p-1">
<ComplianceDocRowItem
icon={<Soc2 aria-hidden className="size-7 shrink-0" />}
label={t('compliance.soc2Type1', { ns: 'common' })}
docName={DocName.SOC2_Type_I}
/>
<ComplianceDocRowItem
icon={<Soc2 aria-hidden className="size-7 shrink-0" />}
label={t('compliance.soc2Type2', { ns: 'common' })}
docName={DocName.SOC2_Type_II}
/>
<ComplianceDocRowItem
icon={<Iso aria-hidden className="size-7 shrink-0" />}
label={t('compliance.iso27001', { ns: 'common' })}
docName={DocName.ISO_27001}
/>
<ComplianceDocRowItem
icon={<Gdpr aria-hidden className="size-7 shrink-0" />}
label={t('compliance.gdpr', { ns: 'common' })}
docName={DocName.GDPR}
/>
</DropdownMenuGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
<Menu as="div" className="relative h-full w-full">
{
({ open }) => (
<>
<MenuButton className={
cn('group flex h-9 w-full items-center gap-1 rounded-lg py-2 pl-3 pr-2 hover:bg-state-base-hover', open && 'bg-state-base-hover')
}
>
<RiVerifiedBadgeLine className="size-4 shrink-0 text-text-tertiary" />
<div className="system-md-regular grow px-1 text-left text-text-secondary">{t('userProfile.compliance', { ns: 'common' })}</div>
<RiArrowRightSLine className="size-[14px] shrink-0 text-text-tertiary" />
</MenuButton>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<MenuItems
className={cn(
`absolute top-[1px] z-10 max-h-[70vh] w-[337px] origin-top-right -translate-x-full divide-y divide-divider-subtle overflow-y-scroll
rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px] focus:outline-none
`,
)}
>
<div className="px-1 py-1">
<MenuItem>
<div
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
>
<Soc2 className="size-7 shrink-0" />
<div className="system-md-regular grow truncate px-1 text-text-secondary">{t('compliance.soc2Type1', { ns: 'common' })}</div>
<UpgradeOrDownload doc_name={DocName.SOC2_Type_I} />
</div>
</MenuItem>
<MenuItem>
<div
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
>
<Soc2 className="size-7 shrink-0" />
<div className="system-md-regular grow truncate px-1 text-text-secondary">{t('compliance.soc2Type2', { ns: 'common' })}</div>
<UpgradeOrDownload doc_name={DocName.SOC2_Type_II} />
</div>
</MenuItem>
<MenuItem>
<div
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
>
<Iso className="size-7 shrink-0" />
<div className="system-md-regular grow truncate px-1 text-text-secondary">{t('compliance.iso27001', { ns: 'common' })}</div>
<UpgradeOrDownload doc_name={DocName.ISO_27001} />
</div>
</MenuItem>
<MenuItem>
<div
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
>
<Gdpr className="size-7 shrink-0" />
<div className="system-md-regular grow truncate px-1 text-text-secondary">{t('compliance.gdpr', { ns: 'common' })}</div>
<UpgradeOrDownload doc_name={DocName.GDPR} />
</div>
</MenuItem>
</div>
</MenuItems>
</Transition>
</>
)
}
</Menu>
)
}

View File

@@ -29,10 +29,6 @@ vi.mock('@/app/components/header/github-star', () => ({
default: () => <div data-testid="github-star">GithubStar</div>,
}))
vi.mock('@/app/components/base/theme-switcher', () => ({
default: () => <button type="button" data-testid="theme-switcher-button">Theme switcher</button>,
}))
vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
@@ -69,7 +65,6 @@ vi.mock('@/context/i18n', () => ({
const { mockConfig, mockEnv } = vi.hoisted(() => ({
mockConfig: {
IS_CLOUD_EDITION: false,
ZENDESK_WIDGET_KEY: '',
},
mockEnv: {
env: {
@@ -79,7 +74,6 @@ const { mockConfig, mockEnv } = vi.hoisted(() => ({
}))
vi.mock('@/config', () => ({
get IS_CLOUD_EDITION() { return mockConfig.IS_CLOUD_EDITION },
get ZENDESK_WIDGET_KEY() { return mockConfig.ZENDESK_WIDGET_KEY },
IS_DEV: false,
IS_CE_EDITION: false,
}))
@@ -193,14 +187,6 @@ describe('AccountDropdown', () => {
expect(screen.getByText('test@example.com')).toBeInTheDocument()
})
it('should set an accessible label on avatar trigger when menu trigger is rendered', () => {
// Act
renderWithRouter(<AppSelector />)
// Assert
expect(screen.getByRole('button', { name: 'common.account.account' })).toBeInTheDocument()
})
it('should show EDU badge for education accounts', () => {
// Arrange
vi.mocked(useProviderContext).mockReturnValue({
@@ -280,16 +266,6 @@ describe('AccountDropdown', () => {
// Assert
expect(screen.queryByTestId('account-about')).not.toBeInTheDocument()
})
it('should keep account dropdown open when clicking the theme switcher', () => {
// Act
renderWithRouter(<AppSelector />)
fireEvent.click(screen.getByRole('button', { name: 'common.account.account' }))
fireEvent.click(screen.getByTestId('theme-switcher-button'))
// Assert
expect(screen.getByText('common.userProfile.logout')).toBeInTheDocument()
})
})
describe('Branding and Environment', () => {

View File

@@ -1,15 +1,26 @@
'use client'
import type { MouseEventHandler, ReactNode } from 'react'
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
import {
RiAccountCircleLine,
RiArrowRightUpLine,
RiBookOpenLine,
RiGithubLine,
RiGraduationCapFill,
RiInformation2Line,
RiLogoutBoxRLine,
RiMap2Line,
RiSettings3Line,
RiStarLine,
RiTShirt2Line,
} from '@remixicon/react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import { Fragment, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { resetUser } from '@/app/components/base/amplitude/utils'
import Avatar from '@/app/components/base/avatar'
import PremiumBadge from '@/app/components/base/premium-badge'
import ThemeSwitcher from '@/app/components/base/theme-switcher'
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/app/components/base/ui/dropdown-menu'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { IS_CLOUD_EDITION } from '@/config'
import { useAppContext } from '@/context/app-context'
@@ -24,90 +35,15 @@ import AccountAbout from '../account-about'
import GithubStar from '../github-star'
import Indicator from '../indicator'
import Compliance from './compliance'
import { ExternalLinkIndicator, MenuItemContent } from './menu-item-content'
import Support from './support'
type AccountMenuRouteItemProps = {
href: string
iconClassName: string
label: ReactNode
trailing?: ReactNode
}
function AccountMenuRouteItem({
href,
iconClassName,
label,
trailing,
}: AccountMenuRouteItemProps) {
return (
<DropdownMenuItem
className="justify-between"
render={<Link href={href} />}
>
<MenuItemContent iconClassName={iconClassName} label={label} trailing={trailing} />
</DropdownMenuItem>
)
}
type AccountMenuExternalItemProps = {
href: string
iconClassName: string
label: ReactNode
trailing?: ReactNode
}
function AccountMenuExternalItem({
href,
iconClassName,
label,
trailing,
}: AccountMenuExternalItemProps) {
return (
<DropdownMenuItem
className="justify-between"
render={<a href={href} rel="noopener noreferrer" target="_blank" />}
>
<MenuItemContent iconClassName={iconClassName} label={label} trailing={trailing} />
</DropdownMenuItem>
)
}
type AccountMenuActionItemProps = {
iconClassName: string
label: ReactNode
onClick?: MouseEventHandler<HTMLElement>
trailing?: ReactNode
}
function AccountMenuActionItem({
iconClassName,
label,
onClick,
trailing,
}: AccountMenuActionItemProps) {
return (
<DropdownMenuItem
className="justify-between"
onClick={onClick}
>
<MenuItemContent iconClassName={iconClassName} label={label} trailing={trailing} />
</DropdownMenuItem>
)
}
type AccountMenuSectionProps = {
children: ReactNode
}
function AccountMenuSection({ children }: AccountMenuSectionProps) {
return <DropdownMenuGroup className="p-1">{children}</DropdownMenuGroup>
}
export default function AppSelector() {
const itemClassName = `
flex items-center w-full h-8 pl-3 pr-2 text-text-secondary system-md-regular
rounded-lg hover:bg-state-base-hover cursor-pointer gap-1
`
const router = useRouter()
const [aboutVisible, setAboutVisible] = useState(false)
const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false)
const { systemFeatures } = useGlobalPublicStore()
const { t } = useTranslation()
@@ -132,124 +68,161 @@ export default function AppSelector() {
}
return (
<div>
<DropdownMenu open={isAccountMenuOpen} onOpenChange={setIsAccountMenuOpen}>
<DropdownMenuTrigger
aria-label={t('account.account', { ns: 'common' })}
className={cn('inline-flex items-center rounded-[20px] p-0.5 hover:bg-background-default-dodge', isAccountMenuOpen && 'bg-background-default-dodge')}
>
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={36} />
</DropdownMenuTrigger>
<DropdownMenuContent
sideOffset={6}
popupClassName="w-60 max-w-80 !bg-components-panel-bg-blur !py-0 backdrop-blur-sm"
>
<DropdownMenuGroup className="px-1 py-1">
<div className="flex flex-nowrap items-center py-2 pl-3 pr-2">
<div className="grow">
<div className="break-all text-text-primary system-md-medium">
{userProfile.name}
{isEducationAccount && (
<PremiumBadge size="s" color="blue" className="ml-1 !px-2">
<span aria-hidden className="i-ri-graduation-cap-fill mr-1 h-3 w-3" />
<span className="system-2xs-medium">EDU</span>
</PremiumBadge>
)}
</div>
<div className="break-all text-text-tertiary system-xs-regular">{userProfile.email}</div>
</div>
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={36} />
</div>
<AccountMenuRouteItem
href="/account"
iconClassName="i-ri-account-circle-line"
label={t('account.account', { ns: 'common' })}
trailing={<ExternalLinkIndicator />}
/>
<AccountMenuActionItem
iconClassName="i-ri-settings-3-line"
label={t('userProfile.settings', { ns: 'common' })}
onClick={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.MEMBERS })}
/>
</DropdownMenuGroup>
<DropdownMenuSeparator className="!my-0 bg-divider-subtle" />
{!systemFeatures.branding.enabled && (
<div className="">
<Menu as="div" className="relative inline-block text-left">
{
({ open, close }) => (
<>
<AccountMenuSection>
<AccountMenuExternalItem
href={docLink('/use-dify/getting-started/introduction')}
iconClassName="i-ri-book-open-line"
label={t('userProfile.helpCenter', { ns: 'common' })}
trailing={<ExternalLinkIndicator />}
/>
<Support closeAccountDropdown={() => setIsAccountMenuOpen(false)} />
{IS_CLOUD_EDITION && isCurrentWorkspaceOwner && <Compliance />}
</AccountMenuSection>
<DropdownMenuSeparator className="!my-0 bg-divider-subtle" />
<AccountMenuSection>
<AccountMenuExternalItem
href="https://roadmap.dify.ai"
iconClassName="i-ri-map-2-line"
label={t('userProfile.roadmap', { ns: 'common' })}
trailing={<ExternalLinkIndicator />}
/>
<AccountMenuExternalItem
href="https://github.com/langgenius/dify"
iconClassName="i-ri-github-line"
label={t('userProfile.github', { ns: 'common' })}
trailing={(
<div className="flex items-center gap-0.5 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-[5px] py-[3px]">
<span aria-hidden className="i-ri-star-line size-3 shrink-0 text-text-tertiary" />
<GithubStar className="text-text-tertiary system-2xs-medium-uppercase" />
</div>
)}
/>
{
env.NEXT_PUBLIC_SITE_ABOUT !== 'hide' && (
<AccountMenuActionItem
iconClassName="i-ri-information-2-line"
label={t('userProfile.about', { ns: 'common' })}
onClick={() => {
setAboutVisible(true)
setIsAccountMenuOpen(false)
}}
trailing={(
<div className="flex shrink-0 items-center">
<div className="mr-2 text-text-tertiary system-xs-regular">{langGeniusVersionInfo.current_version}</div>
<Indicator color={langGeniusVersionInfo.current_version === langGeniusVersionInfo.latest_version ? 'green' : 'orange'} />
<MenuButton className={cn('inline-flex items-center rounded-[20px] p-0.5 hover:bg-background-default-dodge', open && 'bg-background-default-dodge')}>
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={36} />
</MenuButton>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<MenuItems
className="
absolute right-0 mt-1.5 w-60 max-w-80
origin-top-right divide-y divide-divider-subtle rounded-xl bg-components-panel-bg-blur shadow-lg
backdrop-blur-sm focus:outline-none
"
>
<div className="px-1 py-1">
<MenuItem disabled>
<div className="flex flex-nowrap items-center py-2 pl-3 pr-2">
<div className="grow">
<div className="system-md-medium break-all text-text-primary">
{userProfile.name}
{isEducationAccount && (
<PremiumBadge size="s" color="blue" className="ml-1 !px-2">
<RiGraduationCapFill className="mr-1 h-3 w-3" />
<span className="system-2xs-medium">EDU</span>
</PremiumBadge>
)}
</div>
<div className="system-xs-regular break-all text-text-tertiary">{userProfile.email}</div>
</div>
)}
/>
)
}
</AccountMenuSection>
<DropdownMenuSeparator className="!my-0 bg-divider-subtle" />
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={36} />
</div>
</MenuItem>
<MenuItem>
<Link
className={cn(itemClassName, 'group', 'data-[active]:bg-state-base-hover')}
href="/account"
target="_self"
rel="noopener noreferrer"
>
<RiAccountCircleLine className="size-4 shrink-0 text-text-tertiary" />
<div className="system-md-regular grow px-1 text-text-secondary">{t('account.account', { ns: 'common' })}</div>
<RiArrowRightUpLine className="size-[14px] shrink-0 text-text-tertiary" />
</Link>
</MenuItem>
<MenuItem>
<div
className={cn(itemClassName, 'data-[active]:bg-state-base-hover')}
onClick={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.MEMBERS })}
>
<RiSettings3Line className="size-4 shrink-0 text-text-tertiary" />
<div className="system-md-regular grow px-1 text-text-secondary">{t('userProfile.settings', { ns: 'common' })}</div>
</div>
</MenuItem>
</div>
{!systemFeatures.branding.enabled && (
<>
<div className="p-1">
<MenuItem>
<Link
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
href={docLink('/use-dify/getting-started/introduction')}
target="_blank"
rel="noopener noreferrer"
>
<RiBookOpenLine className="size-4 shrink-0 text-text-tertiary" />
<div className="system-md-regular grow px-1 text-text-secondary">{t('userProfile.helpCenter', { ns: 'common' })}</div>
<RiArrowRightUpLine className="size-[14px] shrink-0 text-text-tertiary" />
</Link>
</MenuItem>
<Support closeAccountDropdown={close} />
{IS_CLOUD_EDITION && isCurrentWorkspaceOwner && <Compliance />}
</div>
<div className="p-1">
<MenuItem>
<Link
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
href="https://roadmap.dify.ai"
target="_blank"
rel="noopener noreferrer"
>
<RiMap2Line className="size-4 shrink-0 text-text-tertiary" />
<div className="system-md-regular grow px-1 text-text-secondary">{t('userProfile.roadmap', { ns: 'common' })}</div>
<RiArrowRightUpLine className="size-[14px] shrink-0 text-text-tertiary" />
</Link>
</MenuItem>
<MenuItem>
<Link
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
href="https://github.com/langgenius/dify"
target="_blank"
rel="noopener noreferrer"
>
<RiGithubLine className="size-4 shrink-0 text-text-tertiary" />
<div className="system-md-regular grow px-1 text-text-secondary">{t('userProfile.github', { ns: 'common' })}</div>
<div className="flex items-center gap-0.5 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-[5px] py-[3px]">
<RiStarLine className="size-3 shrink-0 text-text-tertiary" />
<GithubStar className="system-2xs-medium-uppercase text-text-tertiary" />
</div>
</Link>
</MenuItem>
{
env.NEXT_PUBLIC_SITE_ABOUT !== 'hide' && (
<MenuItem>
<div
className={cn(itemClassName, 'justify-between', 'data-[active]:bg-state-base-hover')}
onClick={() => setAboutVisible(true)}
>
<RiInformation2Line className="size-4 shrink-0 text-text-tertiary" />
<div className="system-md-regular grow px-1 text-text-secondary">{t('userProfile.about', { ns: 'common' })}</div>
<div className="flex shrink-0 items-center">
<div className="system-xs-regular mr-2 text-text-tertiary">{langGeniusVersionInfo.current_version}</div>
<Indicator color={langGeniusVersionInfo.current_version === langGeniusVersionInfo.latest_version ? 'green' : 'orange'} />
</div>
</div>
</MenuItem>
)
}
</div>
</>
)}
<MenuItem disabled>
<div className="p-1">
<div className={cn(itemClassName, 'hover:bg-transparent')}>
<RiTShirt2Line className="size-4 shrink-0 text-text-tertiary" />
<div className="system-md-regular grow px-1 text-text-secondary">{t('theme.theme', { ns: 'common' })}</div>
<ThemeSwitcher />
</div>
</div>
</MenuItem>
<MenuItem>
<div className="p-1" onClick={() => handleLogout()}>
<div
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
>
<RiLogoutBoxRLine className="size-4 shrink-0 text-text-tertiary" />
<div className="system-md-regular grow px-1 text-text-secondary">{t('userProfile.logout', { ns: 'common' })}</div>
</div>
</div>
</MenuItem>
</MenuItems>
</Transition>
</>
)}
<AccountMenuSection>
<DropdownMenuItem
closeOnClick={false}
className="cursor-default data-[highlighted]:bg-transparent"
>
<MenuItemContent
iconClassName="i-ri-t-shirt-2-line"
label={t('theme.theme', { ns: 'common' })}
trailing={<ThemeSwitcher />}
/>
</DropdownMenuItem>
</AccountMenuSection>
<DropdownMenuSeparator className="!my-0 bg-divider-subtle" />
<AccountMenuSection>
<AccountMenuActionItem
iconClassName="i-ri-logout-box-r-line"
label={t('userProfile.logout', { ns: 'common' })}
onClick={() => {
void handleLogout()
}}
/>
</AccountMenuSection>
</DropdownMenuContent>
</DropdownMenu>
)
}
</Menu>
{
aboutVisible && <AccountAbout onCancel={() => setAboutVisible(false)} langGeniusVersionInfo={langGeniusVersionInfo} />
}

View File

@@ -1,31 +0,0 @@
import type { ReactNode } from 'react'
import { cn } from '@/utils/classnames'
const menuLabelClassName = 'min-w-0 grow truncate px-1 text-text-secondary system-md-regular'
const menuLeadingIconClassName = 'size-4 shrink-0 text-text-tertiary'
export const menuTrailingIconClassName = 'size-[14px] shrink-0 text-text-tertiary'
type MenuItemContentProps = {
iconClassName: string
label: ReactNode
trailing?: ReactNode
}
export function MenuItemContent({
iconClassName,
label,
trailing,
}: MenuItemContentProps) {
return (
<>
<span aria-hidden className={cn(menuLeadingIconClassName, iconClassName)} />
<div className={menuLabelClassName}>{label}</div>
{trailing}
</>
)
}
export function ExternalLinkIndicator() {
return <span aria-hidden className={cn('i-ri-arrow-right-up-line', menuTrailingIconClassName)} />
}

View File

@@ -1,7 +1,6 @@
import type { AppContextValue } from '@/context/app-context'
import { fireEvent, render, screen } from '@testing-library/react'
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/app/components/base/ui/dropdown-menu'
import { Plan } from '@/app/components/billing/type'
import { useAppContext } from '@/context/app-context'
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
@@ -94,21 +93,10 @@ describe('Support', () => {
})
})
const renderSupport = () => {
return render(
<DropdownMenu open={true} onOpenChange={() => {}}>
<DropdownMenuTrigger>open</DropdownMenuTrigger>
<DropdownMenuContent>
<Support closeAccountDropdown={mockCloseAccountDropdown} />
</DropdownMenuContent>
</DropdownMenu>,
)
}
describe('Rendering', () => {
it('should render support menu trigger', () => {
// Act
renderSupport()
render(<Support closeAccountDropdown={mockCloseAccountDropdown} />)
// Assert
expect(screen.getByText('common.userProfile.support')).toBeInTheDocument()
@@ -116,8 +104,8 @@ describe('Support', () => {
it('should show forum and community links when opened', () => {
// Act
renderSupport()
fireEvent.click(screen.getByText('common.userProfile.support'))
render(<Support closeAccountDropdown={mockCloseAccountDropdown} />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(screen.getByText('common.userProfile.forum')).toBeInTheDocument()
@@ -128,8 +116,8 @@ describe('Support', () => {
describe('Plan-based Channels', () => {
it('should show "Contact Us" when ZENDESK_WIDGET_KEY is present', () => {
// Act
renderSupport()
fireEvent.click(screen.getByText('common.userProfile.support'))
render(<Support closeAccountDropdown={mockCloseAccountDropdown} />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(screen.getByText('common.userProfile.contactUs')).toBeInTheDocument()
@@ -146,8 +134,8 @@ describe('Support', () => {
})
// Act
renderSupport()
fireEvent.click(screen.getByText('common.userProfile.support'))
render(<Support closeAccountDropdown={mockCloseAccountDropdown} />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(screen.queryByText('common.userProfile.contactUs')).not.toBeInTheDocument()
@@ -159,8 +147,8 @@ describe('Support', () => {
mockZendeskKey.value = ''
// Act
renderSupport()
fireEvent.click(screen.getByText('common.userProfile.support'))
render(<Support closeAccountDropdown={mockCloseAccountDropdown} />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(screen.getByText('common.userProfile.emailSupport')).toBeInTheDocument()
@@ -171,8 +159,8 @@ describe('Support', () => {
describe('Interactions and Links', () => {
it('should call toggleZendeskWindow and closeAccountDropdown when "Contact Us" is clicked', () => {
// Act
renderSupport()
fireEvent.click(screen.getByText('common.userProfile.support'))
render(<Support closeAccountDropdown={mockCloseAccountDropdown} />)
fireEvent.click(screen.getByRole('button'))
fireEvent.click(screen.getByText('common.userProfile.contactUs'))
// Assert
@@ -182,8 +170,8 @@ describe('Support', () => {
it('should have correct forum and community links', () => {
// Act
renderSupport()
fireEvent.click(screen.getByText('common.userProfile.support'))
render(<Support closeAccountDropdown={mockCloseAccountDropdown} />)
fireEvent.click(screen.getByRole('button'))
// Assert
const forumLink = screen.getByText('common.userProfile.forum').closest('a')

View File

@@ -1,85 +1,119 @@
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
import { RiArrowRightSLine, RiArrowRightUpLine, RiChatSmile2Line, RiDiscordLine, RiDiscussLine, RiMailSendLine, RiQuestionLine } from '@remixicon/react'
import Link from 'next/link'
import { Fragment } from 'react'
import { useTranslation } from 'react-i18next'
import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger } from '@/app/components/base/ui/dropdown-menu'
import { toggleZendeskWindow } from '@/app/components/base/zendesk/utils'
import { Plan } from '@/app/components/billing/type'
import { ZENDESK_WIDGET_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { cn } from '@/utils/classnames'
import { mailToSupport } from '../utils/util'
import { ExternalLinkIndicator, MenuItemContent } from './menu-item-content'
type SupportProps = {
closeAccountDropdown: () => void
}
// Submenu-only: this component must be rendered within an existing DropdownMenu root.
export default function Support({ closeAccountDropdown }: SupportProps) {
const itemClassName = `
flex items-center w-full h-9 pl-3 pr-2 text-text-secondary system-md-regular
rounded-lg hover:bg-state-base-hover cursor-pointer gap-1
`
const { t } = useTranslation()
const { plan } = useProviderContext()
const { userProfile, langGeniusVersionInfo } = useAppContext()
const hasDedicatedChannel = plan.type !== Plan.sandbox
const hasZendeskWidget = !!ZENDESK_WIDGET_KEY?.trim()
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<MenuItemContent
iconClassName="i-ri-question-line"
label={t('userProfile.support', { ns: 'common' })}
/>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
popupClassName="w-[216px] divide-y divide-divider-subtle !bg-components-panel-bg-blur !py-0 backdrop-blur-sm"
>
<DropdownMenuGroup className="p-1">
{hasDedicatedChannel && hasZendeskWidget && (
<DropdownMenuItem
className="justify-between"
onClick={() => {
toggleZendeskWindow(true)
closeAccountDropdown()
}}
<Menu as="div" className="relative h-full w-full">
{
({ open }) => (
<>
<MenuButton className={
cn('group flex h-9 w-full items-center gap-1 rounded-lg py-2 pl-3 pr-2 hover:bg-state-base-hover', open && 'bg-state-base-hover')
}
>
<MenuItemContent
iconClassName="i-ri-chat-smile-2-line"
label={t('userProfile.contactUs', { ns: 'common' })}
/>
</DropdownMenuItem>
)}
{hasDedicatedChannel && !hasZendeskWidget && (
<DropdownMenuItem
className="justify-between"
render={<a href={mailToSupport(userProfile.email, plan.type, langGeniusVersionInfo?.current_version)} rel="noopener noreferrer" target="_blank" />}
<RiQuestionLine className="size-4 shrink-0 text-text-tertiary" />
<div className="system-md-regular grow px-1 text-left text-text-secondary">{t('userProfile.support', { ns: 'common' })}</div>
<RiArrowRightSLine className="size-[14px] shrink-0 text-text-tertiary" />
</MenuButton>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<MenuItemContent
iconClassName="i-ri-mail-send-line"
label={t('userProfile.emailSupport', { ns: 'common' })}
trailing={<ExternalLinkIndicator />}
/>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="justify-between"
render={<a href="https://forum.dify.ai/" rel="noopener noreferrer" target="_blank" />}
>
<MenuItemContent
iconClassName="i-ri-discuss-line"
label={t('userProfile.forum', { ns: 'common' })}
trailing={<ExternalLinkIndicator />}
/>
</DropdownMenuItem>
<DropdownMenuItem
className="justify-between"
render={<a href="https://discord.gg/5AEfbxcd9k" rel="noopener noreferrer" target="_blank" />}
>
<MenuItemContent
iconClassName="i-ri-discord-line"
label={t('userProfile.community', { ns: 'common' })}
trailing={<ExternalLinkIndicator />}
/>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
<MenuItems
className={cn(
`absolute top-[1px] z-10 max-h-[70vh] w-[216px] origin-top-right -translate-x-full divide-y divide-divider-subtle overflow-y-auto
rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px] focus:outline-none
`,
)}
>
<div className="px-1 py-1">
{hasDedicatedChannel && (
<MenuItem>
{ZENDESK_WIDGET_KEY && ZENDESK_WIDGET_KEY.trim() !== ''
? (
<button
className={cn(itemClassName, 'group justify-between text-left data-[active]:bg-state-base-hover')}
onClick={() => {
toggleZendeskWindow(true)
closeAccountDropdown()
}}
>
<RiChatSmile2Line className="size-4 shrink-0 text-text-tertiary" />
<div className="system-md-regular grow px-1 text-text-secondary">{t('userProfile.contactUs', { ns: 'common' })}</div>
</button>
)
: (
<a
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
href={mailToSupport(userProfile.email, plan.type, langGeniusVersionInfo?.current_version)}
target="_blank"
rel="noopener noreferrer"
>
<RiMailSendLine className="size-4 shrink-0 text-text-tertiary" />
<div className="system-md-regular grow px-1 text-text-secondary">{t('userProfile.emailSupport', { ns: 'common' })}</div>
<RiArrowRightUpLine className="size-[14px] shrink-0 text-text-tertiary" />
</a>
)}
</MenuItem>
)}
<MenuItem>
<Link
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
href="https://forum.dify.ai/"
target="_blank"
rel="noopener noreferrer"
>
<RiDiscussLine className="size-4 shrink-0 text-text-tertiary" />
<div className="system-md-regular grow px-1 text-text-secondary">{t('userProfile.forum', { ns: 'common' })}</div>
<RiArrowRightUpLine className="size-[14px] shrink-0 text-text-tertiary" />
</Link>
</MenuItem>
<MenuItem>
<Link
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
href="https://discord.gg/5AEfbxcd9k"
target="_blank"
rel="noopener noreferrer"
>
<RiDiscordLine className="size-4 shrink-0 text-text-tertiary" />
<div className="system-md-regular grow px-1 text-text-secondary">{t('userProfile.community', { ns: 'common' })}</div>
<RiArrowRightUpLine className="size-[14px] shrink-0 text-text-tertiary" />
</Link>
</MenuItem>
</div>
</MenuItems>
</Transition>
</>
)
}
</Menu>
)
}

View File

@@ -1,20 +1,21 @@
import type { UrlUpdateEvent } from 'nuqs/adapters/testing'
import type { ReactNode } from 'react'
import { act, renderHook } from '@testing-library/react'
import { Provider as JotaiProvider } from 'jotai'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
import { DEFAULT_SORT } from '../constants'
const createWrapper = (searchParams = '') => {
const { wrapper: NuqsWrapper } = createNuqsTestWrapper({ searchParams })
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>()
const wrapper = ({ children }: { children: ReactNode }) => (
<JotaiProvider>
<NuqsWrapper>
<NuqsTestingAdapter searchParams={searchParams} onUrlUpdate={onUrlUpdate}>
{children}
</NuqsWrapper>
</NuqsTestingAdapter>
</JotaiProvider>
)
return { wrapper }
return { wrapper, onUrlUpdate }
}
describe('Marketplace sort atoms', () => {

View File

@@ -1,8 +1,9 @@
import type { UrlUpdateEvent } from 'nuqs/adapters/testing'
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { Provider as JotaiProvider } from 'jotai'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
import PluginTypeSwitch from '../plugin-type-switch'
vi.mock('#i18n', () => ({
@@ -24,15 +25,15 @@ vi.mock('#i18n', () => ({
}))
const createWrapper = (searchParams = '') => {
const { wrapper: NuqsWrapper } = createNuqsTestWrapper({ searchParams })
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>()
const Wrapper = ({ children }: { children: ReactNode }) => (
<JotaiProvider>
<NuqsWrapper>
<NuqsTestingAdapter searchParams={searchParams} onUrlUpdate={onUrlUpdate}>
{children}
</NuqsWrapper>
</NuqsTestingAdapter>
</JotaiProvider>
)
return { Wrapper }
return { Wrapper, onUrlUpdate }
}
describe('PluginTypeSwitch', () => {

View File

@@ -2,8 +2,8 @@ import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { renderHook, waitFor } from '@testing-library/react'
import { Provider as JotaiProvider } from 'jotai'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
vi.mock('@/config', () => ({
API_PREFIX: '/api',
@@ -37,7 +37,6 @@ vi.mock('@/service/client', () => ({
}))
const createWrapper = (searchParams = '') => {
const { wrapper: NuqsWrapper } = createNuqsTestWrapper({ searchParams })
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
@@ -46,9 +45,9 @@ const createWrapper = (searchParams = '') => {
const Wrapper = ({ children }: { children: ReactNode }) => (
<JotaiProvider>
<QueryClientProvider client={queryClient}>
<NuqsWrapper>
<NuqsTestingAdapter searchParams={searchParams}>
{children}
</NuqsWrapper>
</NuqsTestingAdapter>
</QueryClientProvider>
</JotaiProvider>
)

View File

@@ -1,8 +1,8 @@
import type { ReactNode } from 'react'
import { render } from '@testing-library/react'
import { Provider as JotaiProvider } from 'jotai'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
import StickySearchAndSwitchWrapper from '../sticky-search-and-switch-wrapper'
vi.mock('#i18n', () => ({
@@ -20,17 +20,13 @@ vi.mock('../search-box/search-box-wrapper', () => ({
default: () => <div data-testid="search-box-wrapper">SearchBoxWrapper</div>,
}))
const createWrapper = () => {
const { wrapper: NuqsWrapper } = createNuqsTestWrapper()
const Wrapper = ({ children }: { children: ReactNode }) => (
<JotaiProvider>
<NuqsWrapper>
{children}
</NuqsWrapper>
</JotaiProvider>
)
return { Wrapper }
}
const Wrapper = ({ children }: { children: ReactNode }) => (
<JotaiProvider>
<NuqsTestingAdapter>
{children}
</NuqsTestingAdapter>
</JotaiProvider>
)
describe('StickySearchAndSwitchWrapper', () => {
beforeEach(() => {
@@ -38,7 +34,6 @@ describe('StickySearchAndSwitchWrapper', () => {
})
it('should render SearchBoxWrapper and PluginTypeSwitch', () => {
const { Wrapper } = createWrapper()
const { getByTestId } = render(
<StickySearchAndSwitchWrapper />,
{ wrapper: Wrapper },
@@ -49,7 +44,6 @@ describe('StickySearchAndSwitchWrapper', () => {
})
it('should not apply sticky class when no pluginTypeSwitchClassName', () => {
const { Wrapper } = createWrapper()
const { container } = render(
<StickySearchAndSwitchWrapper />,
{ wrapper: Wrapper },
@@ -61,7 +55,6 @@ describe('StickySearchAndSwitchWrapper', () => {
})
it('should apply sticky class when pluginTypeSwitchClassName contains top-', () => {
const { Wrapper } = createWrapper()
const { container } = render(
<StickySearchAndSwitchWrapper pluginTypeSwitchClassName="top-10" />,
{ wrapper: Wrapper },
@@ -74,7 +67,6 @@ describe('StickySearchAndSwitchWrapper', () => {
})
it('should not apply sticky class when pluginTypeSwitchClassName does not contain top-', () => {
const { Wrapper } = createWrapper()
const { container } = render(
<StickySearchAndSwitchWrapper pluginTypeSwitchClassName="custom-class" />,
{ wrapper: Wrapper },

View File

@@ -1,5 +1,4 @@
import type { SearchParams } from 'nuqs/server'
import type { MarketplaceSearchParams } from './search-params'
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import { createLoader } from 'nuqs/server'
import { getQueryClientServer } from '@/context/query-client-server'
@@ -15,7 +14,7 @@ async function getDehydratedState(searchParams?: Promise<SearchParams>) {
return
}
const loadSearchParams = createLoader(marketplaceSearchParamsParsers)
const params: MarketplaceSearchParams = await loadSearchParams(searchParams)
const params = await loadSearchParams(searchParams)
if (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(params.category)) {
return

View File

@@ -1,4 +1,3 @@
import type { inferParserType } from 'nuqs/server'
import type { ActivePluginType } from './constants'
import { parseAsArrayOf, parseAsString, parseAsStringEnum } from 'nuqs/server'
import { PLUGIN_TYPE_SEARCH_MAP } from './constants'
@@ -8,5 +7,3 @@ export const marketplaceSearchParamsParsers = {
q: parseAsString.withDefault('').withOptions({ history: 'replace' }),
tags: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }),
}
export type MarketplaceSearchParams = inferParserType<typeof marketplaceSearchParamsParsers>

View File

@@ -7,9 +7,6 @@ import { PluginPageContext, PluginPageContextProvider, usePluginPageContext } fr
// Mock dependencies
vi.mock('nuqs', () => ({
parseAsStringEnum: vi.fn(() => ({
withDefault: vi.fn(() => ({})),
})),
useQueryState: vi.fn(() => ['plugins', vi.fn()]),
}))

View File

@@ -80,9 +80,6 @@ vi.mock('@/service/use-plugins', () => ({
}))
vi.mock('nuqs', () => ({
parseAsStringEnum: vi.fn(() => ({
withDefault: vi.fn(() => ({})),
})),
useQueryState: vi.fn(() => ['plugins', vi.fn()]),
}))

View File

@@ -3,7 +3,7 @@
import type { ReactNode, RefObject } from 'react'
import type { FilterState } from './filter-management'
import { noop } from 'es-toolkit/function'
import { parseAsStringEnum, useQueryState } from 'nuqs'
import { useQueryState } from 'nuqs'
import {
useMemo,
useRef,
@@ -15,19 +15,6 @@ import {
} from 'use-context-selector'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { PLUGIN_PAGE_TABS_MAP, usePluginPageTabs } from '../hooks'
import { PLUGIN_TYPE_SEARCH_MAP } from '../marketplace/constants'
export type PluginPageTab = typeof PLUGIN_PAGE_TABS_MAP[keyof typeof PLUGIN_PAGE_TABS_MAP]
| (typeof PLUGIN_TYPE_SEARCH_MAP)[keyof typeof PLUGIN_TYPE_SEARCH_MAP]
const PLUGIN_PAGE_TAB_VALUES: PluginPageTab[] = [
PLUGIN_PAGE_TABS_MAP.plugins,
PLUGIN_PAGE_TABS_MAP.marketplace,
...Object.values(PLUGIN_TYPE_SEARCH_MAP),
]
const parseAsPluginPageTab = parseAsStringEnum<PluginPageTab>(PLUGIN_PAGE_TAB_VALUES)
.withDefault(PLUGIN_PAGE_TABS_MAP.plugins)
export type PluginPageContextValue = {
containerRef: RefObject<HTMLDivElement | null>
@@ -35,8 +22,8 @@ export type PluginPageContextValue = {
setCurrentPluginID: (pluginID?: string) => void
filters: FilterState
setFilters: (filter: FilterState) => void
activeTab: PluginPageTab
setActiveTab: (tab: PluginPageTab) => void
activeTab: string
setActiveTab: (tab: string) => void
options: Array<{ value: string, text: string }>
}
@@ -52,7 +39,7 @@ export const PluginPageContext = createContext<PluginPageContextValue>({
searchQuery: '',
},
setFilters: noop,
activeTab: PLUGIN_PAGE_TABS_MAP.plugins,
activeTab: '',
setActiveTab: noop,
options: [],
})
@@ -81,7 +68,9 @@ export const PluginPageContextProvider = ({
const options = useMemo(() => {
return enable_marketplace ? tabs : tabs.filter(tab => tab.value !== PLUGIN_PAGE_TABS_MAP.marketplace)
}, [tabs, enable_marketplace])
const [activeTab, setActiveTab] = useQueryState('tab', parseAsPluginPageTab)
const [activeTab, setActiveTab] = useQueryState('tab', {
defaultValue: options[0].value,
})
return (
<PluginPageContext.Provider

View File

@@ -1,7 +1,6 @@
'use client'
import type { Dependency, PluginDeclaration, PluginManifestInMarket } from '../types'
import type { PluginPageTab } from './context'
import {
RiBookOpenLine,
RiDragDropLine,
@@ -38,16 +37,6 @@ import PluginTasks from './plugin-tasks'
import useReferenceSetting from './use-reference-setting'
import { useUploader } from './use-uploader'
const pluginPageTabSet = new Set<string>([
PLUGIN_PAGE_TABS_MAP.plugins,
PLUGIN_PAGE_TABS_MAP.marketplace,
...Object.values(PLUGIN_TYPE_SEARCH_MAP),
])
const isPluginPageTab = (value: string): value is PluginPageTab => {
return pluginPageTabSet.has(value)
}
export type PluginPageProps = {
plugins: React.ReactNode
marketplace: React.ReactNode
@@ -165,10 +154,7 @@ const PluginPage = ({
<div className="flex-1">
<TabSlider
value={isPluginsTab ? PLUGIN_PAGE_TABS_MAP.plugins : PLUGIN_PAGE_TABS_MAP.marketplace}
onChange={(nextTab) => {
if (isPluginPageTab(nextTab))
setActiveTab(nextTab)
}}
onChange={setActiveTab}
options={options}
/>
</div>

View File

@@ -1,6 +1,6 @@
import { cleanup, fireEvent, screen } from '@testing-library/react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { renderWithNuqs } from '@/test/nuqs-testing'
import { ToolTypeEnum } from '../../workflow/block-selector/types'
import ProviderList from '../provider-list'
import { getToolType } from '../utils'
@@ -206,9 +206,10 @@ describe('getToolType', () => {
})
const renderProviderList = (searchParams?: Record<string, string>) => {
return renderWithNuqs(
<ProviderList />,
{ searchParams },
return render(
<NuqsTestingAdapter searchParams={searchParams}>
<ProviderList />
</NuqsTestingAdapter>,
)
}

View File

@@ -1,6 +1,6 @@
'use client'
import type { Collection } from './types'
import { parseAsStringLiteral, useQueryState } from 'nuqs'
import { useQueryState } from 'nuqs'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
@@ -23,17 +23,6 @@ import { useMarketplace } from './marketplace/hooks'
import MCPList from './mcp'
import { getToolType } from './utils'
const TOOL_PROVIDER_CATEGORY_VALUES = ['builtin', 'api', 'workflow', 'mcp'] as const
type ToolProviderCategory = typeof TOOL_PROVIDER_CATEGORY_VALUES[number]
const toolProviderCategorySet = new Set<string>(TOOL_PROVIDER_CATEGORY_VALUES)
const isToolProviderCategory = (value: string): value is ToolProviderCategory => {
return toolProviderCategorySet.has(value)
}
const parseAsToolProviderCategory = parseAsStringLiteral(TOOL_PROVIDER_CATEGORY_VALUES)
.withDefault('builtin')
const ProviderList = () => {
// const searchParams = useSearchParams()
// searchParams.get('category') === 'workflow'
@@ -42,7 +31,9 @@ const ProviderList = () => {
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
const containerRef = useRef<HTMLDivElement>(null)
const [activeTab, setActiveTab] = useQueryState('category', parseAsToolProviderCategory)
const [activeTab, setActiveTab] = useQueryState('category', {
defaultValue: 'builtin',
})
const options = [
{ value: 'builtin', text: t('type.builtIn', { ns: 'tools' }) },
{ value: 'api', text: t('type.custom', { ns: 'tools' }) },
@@ -133,8 +124,6 @@ const ProviderList = () => {
<TabSliderNew
value={activeTab}
onChange={(state) => {
if (!isToolProviderCategory(state))
return
setActiveTab(state)
if (state !== activeTab)
setCurrentProviderId(undefined)

View File

@@ -0,0 +1,59 @@
import { noop } from 'es-toolkit'
/**
* Default hooks store state.
* All function fields default to noop / vi.fn() stubs.
* Use `createHooksStoreState(overrides)` to get a customised state object.
*/
export function createHooksStoreState(overrides: Record<string, unknown> = {}) {
return {
refreshAll: noop,
// draft sync
doSyncWorkflowDraft: vi.fn().mockResolvedValue(undefined),
syncWorkflowDraftWhenPageClose: noop,
handleRefreshWorkflowDraft: noop,
handleBackupDraft: noop,
handleLoadBackupDraft: noop,
handleRestoreFromPublishedWorkflow: noop,
// run
handleRun: noop,
handleStopRun: noop,
handleStartWorkflowRun: noop,
handleWorkflowStartRunInWorkflow: noop,
handleWorkflowStartRunInChatflow: noop,
handleWorkflowTriggerScheduleRunInWorkflow: noop,
handleWorkflowTriggerWebhookRunInWorkflow: noop,
handleWorkflowTriggerPluginRunInWorkflow: noop,
handleWorkflowRunAllTriggersInWorkflow: noop,
// meta
availableNodesMetaData: undefined,
configsMap: undefined,
// export / DSL
exportCheck: vi.fn().mockResolvedValue(undefined),
handleExportDSL: vi.fn().mockResolvedValue(undefined),
getWorkflowRunAndTraceUrl: vi.fn().mockReturnValue({ runUrl: '', traceUrl: '' }),
// inspect vars
fetchInspectVars: vi.fn().mockResolvedValue(undefined),
hasNodeInspectVars: vi.fn().mockReturnValue(false),
hasSetInspectVar: vi.fn().mockReturnValue(false),
fetchInspectVarValue: vi.fn().mockResolvedValue(undefined),
editInspectVarValue: vi.fn().mockResolvedValue(undefined),
renameInspectVarName: vi.fn().mockResolvedValue(undefined),
appendNodeInspectVars: noop,
deleteInspectVar: vi.fn().mockResolvedValue(undefined),
deleteNodeInspectorVars: vi.fn().mockResolvedValue(undefined),
deleteAllInspectorVars: vi.fn().mockResolvedValue(undefined),
isInspectVarEdited: vi.fn().mockReturnValue(false),
resetToLastRunVar: vi.fn().mockResolvedValue(undefined),
invalidateSysVarValues: noop,
resetConversationVar: vi.fn().mockResolvedValue(undefined),
invalidateConversationVarValues: noop,
...overrides,
}
}

View File

@@ -0,0 +1,110 @@
/**
* ReactFlow mock factory for workflow tests.
*
* Usage — add this to the top of any test file that imports reactflow:
*
* vi.mock('reactflow', async () => (await import('../__tests__/mock-reactflow')).createReactFlowMock())
*
* Or for more control:
*
* vi.mock('reactflow', async () => {
* const base = (await import('../__tests__/mock-reactflow')).createReactFlowMock()
* return { ...base, useReactFlow: () => ({ ...base.useReactFlow(), fitView: vi.fn() }) }
* })
*/
import * as React from 'react'
export function createReactFlowMock(overrides: Record<string, unknown> = {}) {
const noopComponent: React.FC<{ children?: React.ReactNode }> = ({ children }) =>
React.createElement('div', { 'data-testid': 'reactflow-mock' }, children)
noopComponent.displayName = 'ReactFlowMock'
const backgroundComponent: React.FC = () => null
backgroundComponent.displayName = 'BackgroundMock'
return {
// re-export the real Position enum
Position: { Left: 'left', Right: 'right', Top: 'top', Bottom: 'bottom' },
MarkerType: { Arrow: 'arrow', ArrowClosed: 'arrowclosed' },
ConnectionMode: { Strict: 'strict', Loose: 'loose' },
ConnectionLineType: { Bezier: 'default', Straight: 'straight', Step: 'step', SmoothStep: 'smoothstep' },
// components
default: noopComponent,
ReactFlow: noopComponent,
ReactFlowProvider: ({ children }: { children?: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
Background: backgroundComponent,
MiniMap: backgroundComponent,
Controls: backgroundComponent,
Handle: (props: Record<string, unknown>) => React.createElement('div', { 'data-testid': 'handle', ...props }),
BaseEdge: (props: Record<string, unknown>) => React.createElement('path', props),
EdgeLabelRenderer: ({ children }: { children?: React.ReactNode }) =>
React.createElement('div', null, children),
// hooks
useReactFlow: () => ({
setCenter: vi.fn(),
fitView: vi.fn(),
zoomIn: vi.fn(),
zoomOut: vi.fn(),
zoomTo: vi.fn(),
getNodes: vi.fn().mockReturnValue([]),
getEdges: vi.fn().mockReturnValue([]),
getNode: vi.fn(),
setNodes: vi.fn(),
setEdges: vi.fn(),
addNodes: vi.fn(),
addEdges: vi.fn(),
deleteElements: vi.fn(),
getViewport: vi.fn().mockReturnValue({ x: 0, y: 0, zoom: 1 }),
setViewport: vi.fn(),
screenToFlowPosition: vi.fn().mockImplementation((pos: { x: number, y: number }) => pos),
flowToScreenPosition: vi.fn().mockImplementation((pos: { x: number, y: number }) => pos),
toObject: vi.fn().mockReturnValue({ nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } }),
viewportInitialized: true,
}),
useStoreApi: () => ({
getState: vi.fn().mockReturnValue({
nodeInternals: new Map(),
edges: [],
transform: [0, 0, 1],
d3Selection: null,
d3Zoom: null,
}),
setState: vi.fn(),
subscribe: vi.fn().mockReturnValue(vi.fn()),
}),
useNodesState: vi.fn((initial: unknown[] = []) => [initial, vi.fn(), vi.fn()]),
useEdgesState: vi.fn((initial: unknown[] = []) => [initial, vi.fn(), vi.fn()]),
useStore: vi.fn().mockReturnValue(null),
useNodes: vi.fn().mockReturnValue([]),
useEdges: vi.fn().mockReturnValue([]),
useViewport: vi.fn().mockReturnValue({ x: 0, y: 0, zoom: 1 }),
useOnSelectionChange: vi.fn(),
useKeyPress: vi.fn().mockReturnValue(false),
useUpdateNodeInternals: vi.fn().mockReturnValue(vi.fn()),
useOnViewportChange: vi.fn(),
useNodeId: vi.fn().mockReturnValue(null),
// utils
getOutgoers: vi.fn().mockReturnValue([]),
getIncomers: vi.fn().mockReturnValue([]),
getConnectedEdges: vi.fn().mockReturnValue([]),
isNode: vi.fn().mockReturnValue(true),
isEdge: vi.fn().mockReturnValue(false),
addEdge: vi.fn().mockImplementation((_edge: unknown, edges: unknown[]) => edges),
applyNodeChanges: vi.fn().mockImplementation((_changes: unknown[], nodes: unknown[]) => nodes),
applyEdgeChanges: vi.fn().mockImplementation((_changes: unknown[], edges: unknown[]) => edges),
getBezierPath: vi.fn().mockReturnValue(['M 0 0', 0, 0]),
getSmoothStepPath: vi.fn().mockReturnValue(['M 0 0', 0, 0]),
getStraightPath: vi.fn().mockReturnValue(['M 0 0', 0, 0]),
internalsSymbol: Symbol('internals'),
...overrides,
}
}

View File

@@ -0,0 +1,199 @@
import type { ControlMode, Node } from '../types'
import { noop } from 'es-toolkit'
import { DEFAULT_ITER_TIMES, DEFAULT_LOOP_TIMES } from '../constants'
/**
* Default workflow store state covering all slices.
* Use `createWorkflowStoreState(overrides)` to get a state object
* that can be injected via `useWorkflowStore.setState(...)` or
* used as the return value of a mocked `useStore` selector.
*/
export function createWorkflowStoreState(overrides: Record<string, unknown> = {}) {
return {
// --- workflow-slice ---
workflowRunningData: undefined,
isListening: false,
listeningTriggerType: null,
listeningTriggerNodeId: null,
listeningTriggerNodeIds: [],
listeningTriggerIsAll: false,
clipboardElements: [] as Node[],
selection: null,
bundleNodeSize: null,
controlMode: 'pointer' as ControlMode,
mousePosition: { pageX: 0, pageY: 0, elementX: 0, elementY: 0 },
showConfirm: undefined,
controlPromptEditorRerenderKey: 0,
showImportDSLModal: false,
fileUploadConfig: undefined,
// --- node-slice ---
showSingleRunPanel: false,
nodeAnimation: false,
candidateNode: undefined,
nodeMenu: undefined,
showAssignVariablePopup: undefined,
hoveringAssignVariableGroupId: undefined,
connectingNodePayload: undefined,
enteringNodePayload: undefined,
iterTimes: DEFAULT_ITER_TIMES,
loopTimes: DEFAULT_LOOP_TIMES,
iterParallelLogMap: new Map(),
pendingSingleRun: undefined,
// --- panel-slice ---
panelWidth: 420,
showFeaturesPanel: false,
showWorkflowVersionHistoryPanel: false,
showInputsPanel: false,
showDebugAndPreviewPanel: false,
panelMenu: undefined,
selectionMenu: undefined,
showVariableInspectPanel: false,
initShowLastRunTab: false,
// --- help-line-slice ---
helpLineHorizontal: undefined,
helpLineVertical: undefined,
// --- history-slice ---
historyWorkflowData: undefined,
showRunHistory: false,
versionHistory: [],
// --- chat-variable-slice ---
showChatVariablePanel: false,
showGlobalVariablePanel: false,
conversationVariables: [],
// --- env-variable-slice ---
showEnvPanel: false,
environmentVariables: [],
envSecrets: {},
// --- form-slice ---
inputs: {},
files: [],
// --- tool-slice ---
toolPublished: false,
lastPublishedHasUserInput: false,
buildInTools: undefined,
customTools: undefined,
workflowTools: undefined,
mcpTools: undefined,
// --- version-slice ---
draftUpdatedAt: 0,
publishedAt: 0,
currentVersion: null,
isRestoring: false,
// --- workflow-draft-slice ---
backupDraft: undefined,
syncWorkflowDraftHash: '',
isSyncingWorkflowDraft: false,
isWorkflowDataLoaded: false,
nodes: [] as Node[],
// --- inspect-vars-slice ---
currentFocusNodeId: null,
nodesWithInspectVars: [],
conversationVars: [],
// --- layout-slice ---
workflowCanvasWidth: undefined,
workflowCanvasHeight: undefined,
rightPanelWidth: undefined,
nodePanelWidth: 420,
previewPanelWidth: 420,
otherPanelWidth: 420,
bottomPanelWidth: 0,
bottomPanelHeight: 0,
variableInspectPanelHeight: 300,
maximizeCanvas: false,
// --- setters (all default to noop, override as needed) ---
setWorkflowRunningData: noop,
setIsListening: noop,
setListeningTriggerType: noop,
setListeningTriggerNodeId: noop,
setListeningTriggerNodeIds: noop,
setListeningTriggerIsAll: noop,
setClipboardElements: noop,
setSelection: noop,
setBundleNodeSize: noop,
setControlMode: noop,
setMousePosition: noop,
setShowConfirm: noop,
setControlPromptEditorRerenderKey: noop,
setShowImportDSLModal: noop,
setFileUploadConfig: noop,
setShowSingleRunPanel: noop,
setNodeAnimation: noop,
setCandidateNode: noop,
setNodeMenu: noop,
setShowAssignVariablePopup: noop,
setHoveringAssignVariableGroupId: noop,
setConnectingNodePayload: noop,
setEnteringNodePayload: noop,
setIterTimes: noop,
setLoopTimes: noop,
setIterParallelLogMap: noop,
setPendingSingleRun: noop,
setShowFeaturesPanel: noop,
setShowWorkflowVersionHistoryPanel: noop,
setShowInputsPanel: noop,
setShowDebugAndPreviewPanel: noop,
setPanelMenu: noop,
setSelectionMenu: noop,
setShowVariableInspectPanel: noop,
setInitShowLastRunTab: noop,
setHelpLineHorizontal: noop,
setHelpLineVertical: noop,
setHistoryWorkflowData: noop,
setShowRunHistory: noop,
setVersionHistory: noop,
setShowChatVariablePanel: noop,
setShowGlobalVariablePanel: noop,
setConversationVariables: noop,
setShowEnvPanel: noop,
setEnvironmentVariables: noop,
setEnvSecrets: noop,
setInputs: noop,
setFiles: noop,
setToolPublished: noop,
setLastPublishedHasUserInput: noop,
setDraftUpdatedAt: noop,
setPublishedAt: noop,
setCurrentVersion: noop,
setIsRestoring: noop,
setBackupDraft: noop,
setSyncWorkflowDraftHash: noop,
setIsSyncingWorkflowDraft: noop,
setIsWorkflowDataLoaded: noop,
setNodes: noop,
flushPendingSync: noop,
setCurrentFocusNodeId: noop,
setNodesWithInspectVars: noop,
setNodeInspectVars: noop,
deleteAllInspectVars: noop,
deleteNodeInspectVars: noop,
setInspectVarValue: noop,
resetToLastRunVar: noop,
renameInspectVarName: noop,
deleteInspectVar: noop,
setWorkflowCanvasWidth: noop,
setWorkflowCanvasHeight: noop,
setRightPanelWidth: noop,
setNodePanelWidth: noop,
setPreviewPanelWidth: noop,
setOtherPanelWidth: noop,
setBottomPanelWidth: noop,
setBottomPanelHeight: noop,
setVariableInspectPanelHeight: noop,
setMaximizeCanvas: noop,
...overrides,
}
}

View File

@@ -1,143 +0,0 @@
/**
* Shared mutable ReactFlow mock state for hook/component tests.
*
* Mutate `rfState` in `beforeEach` to configure nodes/edges,
* then assert on `rfState.setNodes`, `rfState.setEdges`, etc.
*
* Usage (one line at top of test file):
* ```ts
* vi.mock('reactflow', async () =>
* (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock(),
* )
* ```
*/
import * as React from 'react'
type MockNode = {
id: string
position: { x: number, y: number }
width?: number
height?: number
parentId?: string
data: Record<string, unknown>
}
type MockEdge = {
id: string
source: string
target: string
sourceHandle?: string
data: Record<string, unknown>
}
type ReactFlowMockState = {
nodes: MockNode[]
edges: MockEdge[]
transform: [number, number, number]
setViewport: ReturnType<typeof vi.fn>
setNodes: ReturnType<typeof vi.fn>
setEdges: ReturnType<typeof vi.fn>
}
export const rfState: ReactFlowMockState = {
nodes: [],
edges: [],
transform: [0, 0, 1],
setViewport: vi.fn(),
setNodes: vi.fn(),
setEdges: vi.fn(),
}
export function resetReactFlowMockState() {
rfState.nodes = []
rfState.edges = []
rfState.transform = [0, 0, 1]
rfState.setViewport.mockReset()
rfState.setNodes.mockReset()
rfState.setEdges.mockReset()
}
export function createReactFlowModuleMock() {
return {
Position: { Left: 'left', Right: 'right', Top: 'top', Bottom: 'bottom' },
MarkerType: { Arrow: 'arrow', ArrowClosed: 'arrowclosed' },
ConnectionMode: { Strict: 'strict', Loose: 'loose' },
useStoreApi: vi.fn(() => ({
getState: () => ({
getNodes: () => rfState.nodes,
setNodes: rfState.setNodes,
edges: rfState.edges,
setEdges: rfState.setEdges,
transform: rfState.transform,
nodeInternals: new Map(),
d3Selection: null,
d3Zoom: null,
}),
setState: vi.fn(),
subscribe: vi.fn().mockReturnValue(vi.fn()),
})),
useReactFlow: vi.fn(() => ({
setViewport: rfState.setViewport,
setCenter: vi.fn(),
fitView: vi.fn(),
zoomIn: vi.fn(),
zoomOut: vi.fn(),
zoomTo: vi.fn(),
getNodes: () => rfState.nodes,
getEdges: () => rfState.edges,
setNodes: rfState.setNodes,
setEdges: rfState.setEdges,
getViewport: () => ({ x: 0, y: 0, zoom: 1 }),
screenToFlowPosition: (pos: { x: number, y: number }) => pos,
flowToScreenPosition: (pos: { x: number, y: number }) => pos,
deleteElements: vi.fn(),
addNodes: vi.fn(),
addEdges: vi.fn(),
getNode: vi.fn(),
toObject: vi.fn().mockReturnValue({ nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } }),
viewportInitialized: true,
})),
useStore: vi.fn().mockReturnValue(null),
useNodes: vi.fn(() => rfState.nodes),
useEdges: vi.fn(() => rfState.edges),
useViewport: vi.fn(() => ({ x: 0, y: 0, zoom: 1 })),
useKeyPress: vi.fn(() => false),
useOnSelectionChange: vi.fn(),
useOnViewportChange: vi.fn(),
useUpdateNodeInternals: vi.fn(() => vi.fn()),
useNodeId: vi.fn(() => null),
useNodesState: vi.fn((initial: unknown[] = []) => [initial, vi.fn(), vi.fn()]),
useEdgesState: vi.fn((initial: unknown[] = []) => [initial, vi.fn(), vi.fn()]),
ReactFlowProvider: ({ children }: { children?: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
ReactFlow: ({ children }: { children?: React.ReactNode }) =>
React.createElement('div', { 'data-testid': 'reactflow-mock' }, children),
Background: () => null,
MiniMap: () => null,
Controls: () => null,
Handle: (props: Record<string, unknown>) => React.createElement('div', props),
BaseEdge: (props: Record<string, unknown>) => React.createElement('path', props),
EdgeLabelRenderer: ({ children }: { children?: React.ReactNode }) =>
React.createElement('div', null, children),
getOutgoers: vi.fn().mockReturnValue([]),
getIncomers: vi.fn().mockReturnValue([]),
getConnectedEdges: vi.fn().mockReturnValue([]),
isNode: vi.fn().mockReturnValue(true),
isEdge: vi.fn().mockReturnValue(false),
addEdge: vi.fn().mockImplementation((_e: unknown, edges: unknown[]) => edges),
applyNodeChanges: vi.fn().mockImplementation((_c: unknown[], nodes: unknown[]) => nodes),
applyEdgeChanges: vi.fn().mockImplementation((_c: unknown[], edges: unknown[]) => edges),
getBezierPath: vi.fn().mockReturnValue(['M 0 0', 0, 0]),
getSmoothStepPath: vi.fn().mockReturnValue(['M 0 0', 0, 0]),
getStraightPath: vi.fn().mockReturnValue(['M 0 0', 0, 0]),
internalsSymbol: Symbol('internals'),
}
}
export type { MockEdge, MockNode, ReactFlowMockState }

View File

@@ -1,75 +0,0 @@
/**
* Centralized mock factories for external services used by workflow.
*
* Usage:
* ```ts
* vi.mock('@/service/use-tools', async () =>
* (await import('../../__tests__/service-mock-factory')).createToolServiceMock(),
* )
* vi.mock('@/app/components/app/store', async () =>
* (await import('../../__tests__/service-mock-factory')).createAppStoreMock(),
* )
* ```
*/
// ---------------------------------------------------------------------------
// App store
// ---------------------------------------------------------------------------
type AppStoreMockData = {
appId?: string
appMode?: string
}
export function createAppStoreMock(data?: AppStoreMockData) {
return {
useStore: {
getState: () => ({
appDetail: {
id: data?.appId ?? 'app-test-id',
mode: data?.appMode ?? 'workflow',
},
}),
},
}
}
// ---------------------------------------------------------------------------
// SWR service hooks
// ---------------------------------------------------------------------------
type ToolMockData = {
buildInTools?: unknown[]
customTools?: unknown[]
workflowTools?: unknown[]
mcpTools?: unknown[]
}
type TriggerMockData = {
triggerPlugins?: unknown[]
}
type StrategyMockData = {
strategyProviders?: unknown[]
}
export function createToolServiceMock(data?: ToolMockData) {
return {
useAllBuiltInTools: vi.fn(() => ({ data: data?.buildInTools ?? [] })),
useAllCustomTools: vi.fn(() => ({ data: data?.customTools ?? [] })),
useAllWorkflowTools: vi.fn(() => ({ data: data?.workflowTools ?? [] })),
useAllMCPTools: vi.fn(() => ({ data: data?.mcpTools ?? [] })),
}
}
export function createTriggerServiceMock(data?: TriggerMockData) {
return {
useAllTriggerPlugins: vi.fn(() => ({ data: data?.triggerPlugins ?? [] })),
}
}
export function createStrategyServiceMock(data?: StrategyMockData) {
return {
useStrategyProviders: vi.fn(() => ({ data: data?.strategyProviders ?? [] })),
}
}

View File

@@ -1,128 +0,0 @@
/**
* Workflow test environment — composable providers + render helpers.
*
* ## Quick start
*
* ```ts
* import { renderWorkflowHook, rfState, resetReactFlowMockState } from '../../__tests__/workflow-test-env'
*
* // Mock ReactFlow (one line, only needed when the hook imports reactflow)
* vi.mock('reactflow', async () =>
* (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock(),
* )
*
* it('example', () => {
* resetReactFlowMockState()
* rfState.nodes = [{ id: 'n1', position: { x: 0, y: 0 }, data: {} }]
*
* const { result, store } = renderWorkflowHook(
* () => useMyHook(),
* {
* initialStoreState: { workflowRunningData: {...} },
* hooksStoreProps: { doSyncWorkflowDraft: vi.fn() },
* },
* )
*
* result.current.doSomething()
* expect(store.getState().someValue).toBe(expected)
* expect(rfState.setNodes).toHaveBeenCalled()
* })
* ```
*/
import type { RenderHookOptions, RenderHookResult } from '@testing-library/react'
import type { Shape as HooksStoreShape } from '../hooks-store/store'
import type { Shape } from '../store/workflow'
import type { WorkflowRunningData } from '../types'
import { renderHook } from '@testing-library/react'
import * as React from 'react'
import { WorkflowContext } from '../context'
import { HooksStoreContext } from '../hooks-store/provider'
import { createHooksStore } from '../hooks-store/store'
import { createWorkflowStore } from '../store/workflow'
import { WorkflowRunningStatus } from '../types'
// Re-exports are in a separate non-JSX file to avoid react-refresh warnings.
// Import directly from the individual modules:
// reactflow-mock-state.ts → rfState, resetReactFlowMockState, createReactFlowModuleMock
// service-mock-factory.ts → createToolServiceMock, createTriggerServiceMock, ...
// fixtures.ts → createNode, createEdge, createLinearGraph, ...
// ---------------------------------------------------------------------------
// Test data factories
// ---------------------------------------------------------------------------
export function baseRunningData(overrides: Record<string, unknown> = {}) {
return {
task_id: 'task-1',
result: { status: WorkflowRunningStatus.Running } as WorkflowRunningData['result'],
tracing: [],
resultText: '',
resultTabActive: false,
...overrides,
} as WorkflowRunningData
}
// ---------------------------------------------------------------------------
// Store creation helpers
// ---------------------------------------------------------------------------
type WorkflowStore = ReturnType<typeof createWorkflowStore>
type HooksStore = ReturnType<typeof createHooksStore>
export function createTestWorkflowStore(initialState?: Partial<Shape>): WorkflowStore {
const store = createWorkflowStore({})
if (initialState)
store.setState(initialState)
return store
}
export function createTestHooksStore(props?: Partial<HooksStoreShape>): HooksStore {
return createHooksStore(props ?? {})
}
// ---------------------------------------------------------------------------
// renderWorkflowHook — composable hook renderer
// ---------------------------------------------------------------------------
type WorkflowTestOptions<P> = Omit<RenderHookOptions<P>, 'wrapper'> & {
initialStoreState?: Partial<Shape>
hooksStoreProps?: Partial<HooksStoreShape>
}
type WorkflowTestResult<R, P> = RenderHookResult<R, P> & {
store: WorkflowStore
hooksStore?: HooksStore
}
export function renderWorkflowHook<R, P = undefined>(
hook: (props: P) => R,
options?: WorkflowTestOptions<P>,
): WorkflowTestResult<R, P> {
const { initialStoreState, hooksStoreProps, ...rest } = options ?? {}
const store = createTestWorkflowStore(initialStoreState)
const hooksStore = hooksStoreProps !== undefined
? createTestHooksStore(hooksStoreProps)
: undefined
const wrapper = ({ children }: { children: React.ReactNode }) => {
let tree = React.createElement(
WorkflowContext.Provider,
{ value: store },
children,
)
if (hooksStore) {
tree = React.createElement(
WorkflowContext.Provider,
{ value: store },
React.createElement(HooksStoreContext.Provider, { value: hooksStore }, children),
)
}
return tree
}
const renderResult = renderHook(hook, { wrapper, ...rest })
return { ...renderResult, store, hooksStore }
}

View File

@@ -1,83 +0,0 @@
import { renderHook } from '@testing-library/react'
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
import { BlockEnum } from '../../types'
import { useAutoGenerateWebhookUrl } from '../use-auto-generate-webhook-url'
vi.mock('reactflow', async () =>
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
vi.mock('@/app/components/app/store', async () =>
(await import('../../__tests__/service-mock-factory')).createAppStoreMock({ appId: 'app-123' }))
const mockFetchWebhookUrl = vi.fn()
vi.mock('@/service/apps', () => ({
fetchWebhookUrl: (...args: unknown[]) => mockFetchWebhookUrl(...args),
}))
describe('useAutoGenerateWebhookUrl', () => {
beforeEach(() => {
vi.clearAllMocks()
resetReactFlowMockState()
rfState.nodes = [
{ id: 'webhook-1', position: { x: 0, y: 0 }, data: { type: BlockEnum.TriggerWebhook, webhook_url: '' } },
{ id: 'code-1', position: { x: 300, y: 0 }, data: { type: BlockEnum.Code } },
]
})
it('should fetch and set webhook URL for a webhook trigger node', async () => {
mockFetchWebhookUrl.mockResolvedValue({
webhook_url: 'https://example.com/webhook',
webhook_debug_url: 'https://example.com/webhook-debug',
})
const { result } = renderHook(() => useAutoGenerateWebhookUrl())
await result.current('webhook-1')
expect(mockFetchWebhookUrl).toHaveBeenCalledWith({ appId: 'app-123', nodeId: 'webhook-1' })
expect(rfState.setNodes).toHaveBeenCalledOnce()
const updatedNodes = rfState.setNodes.mock.calls[0][0]
const webhookNode = updatedNodes.find((n: { id: string }) => n.id === 'webhook-1')
expect(webhookNode.data.webhook_url).toBe('https://example.com/webhook')
expect(webhookNode.data.webhook_debug_url).toBe('https://example.com/webhook-debug')
})
it('should not fetch when node is not a webhook trigger', async () => {
const { result } = renderHook(() => useAutoGenerateWebhookUrl())
await result.current('code-1')
expect(mockFetchWebhookUrl).not.toHaveBeenCalled()
expect(rfState.setNodes).not.toHaveBeenCalled()
})
it('should not fetch when node does not exist', async () => {
const { result } = renderHook(() => useAutoGenerateWebhookUrl())
await result.current('nonexistent')
expect(mockFetchWebhookUrl).not.toHaveBeenCalled()
})
it('should not fetch when webhook_url already exists', async () => {
rfState.nodes[0].data.webhook_url = 'https://existing.com/webhook'
const { result } = renderHook(() => useAutoGenerateWebhookUrl())
await result.current('webhook-1')
expect(mockFetchWebhookUrl).not.toHaveBeenCalled()
})
it('should handle API errors gracefully', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockFetchWebhookUrl.mockRejectedValue(new Error('network error'))
const { result } = renderHook(() => useAutoGenerateWebhookUrl())
await result.current('webhook-1')
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to auto-generate webhook URL:',
expect.any(Error),
)
expect(rfState.setNodes).not.toHaveBeenCalled()
consoleSpy.mockRestore()
})
})

View File

@@ -1,142 +0,0 @@
import { renderHook } from '@testing-library/react'
import { BlockEnum } from '../../types'
import { useAvailableBlocks } from '../use-available-blocks'
const mockNodeTypes = [
BlockEnum.Start,
BlockEnum.End,
BlockEnum.LLM,
BlockEnum.Code,
BlockEnum.IfElse,
BlockEnum.Iteration,
BlockEnum.Loop,
BlockEnum.Tool,
BlockEnum.DataSource,
BlockEnum.KnowledgeBase,
BlockEnum.HumanInput,
BlockEnum.LoopEnd,
]
vi.mock('../use-nodes-meta-data', () => ({
useNodesMetaData: () => ({
nodes: mockNodeTypes.map(type => ({ metaData: { type } })),
nodesMap: {},
}),
}))
describe('useAvailableBlocks', () => {
describe('availablePrevBlocks', () => {
it('should return empty array when nodeType is undefined', () => {
const { result } = renderHook(() => useAvailableBlocks(undefined))
expect(result.current.availablePrevBlocks).toEqual([])
})
it('should return empty array for Start node', () => {
const { result } = renderHook(() => useAvailableBlocks(BlockEnum.Start))
expect(result.current.availablePrevBlocks).toEqual([])
})
it('should return empty array for trigger nodes', () => {
for (const trigger of [BlockEnum.TriggerPlugin, BlockEnum.TriggerWebhook, BlockEnum.TriggerSchedule]) {
const { result } = renderHook(() => useAvailableBlocks(trigger))
expect(result.current.availablePrevBlocks).toEqual([])
}
})
it('should return empty array for DataSource node', () => {
const { result } = renderHook(() => useAvailableBlocks(BlockEnum.DataSource))
expect(result.current.availablePrevBlocks).toEqual([])
})
it('should return all available nodes for regular block types', () => {
const { result } = renderHook(() => useAvailableBlocks(BlockEnum.LLM))
expect(result.current.availablePrevBlocks.length).toBeGreaterThan(0)
expect(result.current.availablePrevBlocks).toContain(BlockEnum.Code)
})
})
describe('availableNextBlocks', () => {
it('should return empty array when nodeType is undefined', () => {
const { result } = renderHook(() => useAvailableBlocks(undefined))
expect(result.current.availableNextBlocks).toEqual([])
})
it('should return empty array for End node', () => {
const { result } = renderHook(() => useAvailableBlocks(BlockEnum.End))
expect(result.current.availableNextBlocks).toEqual([])
})
it('should return empty array for LoopEnd node', () => {
const { result } = renderHook(() => useAvailableBlocks(BlockEnum.LoopEnd))
expect(result.current.availableNextBlocks).toEqual([])
})
it('should return empty array for KnowledgeBase node', () => {
const { result } = renderHook(() => useAvailableBlocks(BlockEnum.KnowledgeBase))
expect(result.current.availableNextBlocks).toEqual([])
})
it('should return all available nodes for regular block types', () => {
const { result } = renderHook(() => useAvailableBlocks(BlockEnum.LLM))
expect(result.current.availableNextBlocks.length).toBeGreaterThan(0)
})
})
describe('inContainer filtering', () => {
it('should exclude Iteration, Loop, End, DataSource, KnowledgeBase, HumanInput when inContainer=true', () => {
const { result } = renderHook(() => useAvailableBlocks(BlockEnum.LLM, true))
expect(result.current.availableNextBlocks).not.toContain(BlockEnum.Iteration)
expect(result.current.availableNextBlocks).not.toContain(BlockEnum.Loop)
expect(result.current.availableNextBlocks).not.toContain(BlockEnum.End)
expect(result.current.availableNextBlocks).not.toContain(BlockEnum.DataSource)
expect(result.current.availableNextBlocks).not.toContain(BlockEnum.KnowledgeBase)
expect(result.current.availableNextBlocks).not.toContain(BlockEnum.HumanInput)
})
it('should exclude LoopEnd when not in container', () => {
const { result } = renderHook(() => useAvailableBlocks(BlockEnum.LLM, false))
expect(result.current.availableNextBlocks).not.toContain(BlockEnum.LoopEnd)
})
})
describe('getAvailableBlocks callback', () => {
it('should return prev and next blocks for a given node type', () => {
const { result } = renderHook(() => useAvailableBlocks(BlockEnum.LLM))
const blocks = result.current.getAvailableBlocks(BlockEnum.Code)
expect(blocks.availablePrevBlocks.length).toBeGreaterThan(0)
expect(blocks.availableNextBlocks.length).toBeGreaterThan(0)
})
it('should return empty prevBlocks for Start node', () => {
const { result } = renderHook(() => useAvailableBlocks(BlockEnum.LLM))
const blocks = result.current.getAvailableBlocks(BlockEnum.Start)
expect(blocks.availablePrevBlocks).toEqual([])
})
it('should return empty prevBlocks for DataSource node', () => {
const { result } = renderHook(() => useAvailableBlocks(BlockEnum.LLM))
const blocks = result.current.getAvailableBlocks(BlockEnum.DataSource)
expect(blocks.availablePrevBlocks).toEqual([])
})
it('should return empty nextBlocks for End/LoopEnd/KnowledgeBase', () => {
const { result } = renderHook(() => useAvailableBlocks(BlockEnum.LLM))
expect(result.current.getAvailableBlocks(BlockEnum.End).availableNextBlocks).toEqual([])
expect(result.current.getAvailableBlocks(BlockEnum.LoopEnd).availableNextBlocks).toEqual([])
expect(result.current.getAvailableBlocks(BlockEnum.KnowledgeBase).availableNextBlocks).toEqual([])
})
it('should filter by inContainer when provided', () => {
const { result } = renderHook(() => useAvailableBlocks(BlockEnum.LLM))
const blocks = result.current.getAvailableBlocks(BlockEnum.Code, true)
expect(blocks.availableNextBlocks).not.toContain(BlockEnum.Iteration)
expect(blocks.availableNextBlocks).not.toContain(BlockEnum.Loop)
})
})
})

View File

@@ -1,99 +0,0 @@
import type { WorkflowRunningData } from '../../types'
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { WorkflowRunningStatus } from '../../types'
import { useNodeDataUpdate } from '../use-node-data-update'
vi.mock('reactflow', async () =>
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
describe('useNodeDataUpdate', () => {
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'node-1', position: { x: 0, y: 0 }, data: { title: 'Node 1', value: 'original' } },
{ id: 'node-2', position: { x: 300, y: 0 }, data: { title: 'Node 2' } },
]
})
describe('handleNodeDataUpdate', () => {
it('should merge data into the target node and call setNodes', () => {
const { result } = renderWorkflowHook(() => useNodeDataUpdate(), {
hooksStoreProps: {},
})
result.current.handleNodeDataUpdate({
id: 'node-1',
data: { value: 'updated', extra: true },
})
expect(rfState.setNodes).toHaveBeenCalledOnce()
const updatedNodes = rfState.setNodes.mock.calls[0][0]
expect(updatedNodes.find((n: { id: string }) => n.id === 'node-1').data).toEqual({
title: 'Node 1',
value: 'updated',
extra: true,
})
expect(updatedNodes.find((n: { id: string }) => n.id === 'node-2').data).toEqual({
title: 'Node 2',
})
})
})
describe('handleNodeDataUpdateWithSyncDraft', () => {
it('should update node data and trigger debounced sync draft', () => {
const mockDoSync = vi.fn().mockResolvedValue(undefined)
const { result, store } = renderWorkflowHook(() => useNodeDataUpdate(), {
hooksStoreProps: { doSyncWorkflowDraft: mockDoSync },
})
result.current.handleNodeDataUpdateWithSyncDraft({
id: 'node-1',
data: { value: 'synced' },
})
expect(rfState.setNodes).toHaveBeenCalledOnce()
store.getState().flushPendingSync()
expect(mockDoSync).toHaveBeenCalledOnce()
})
it('should call doSyncWorkflowDraft directly when sync=true', () => {
const mockDoSync = vi.fn().mockResolvedValue(undefined)
const callback = { onSuccess: vi.fn() }
const { result } = renderWorkflowHook(() => useNodeDataUpdate(), {
hooksStoreProps: { doSyncWorkflowDraft: mockDoSync },
})
result.current.handleNodeDataUpdateWithSyncDraft(
{ id: 'node-1', data: { value: 'synced' } },
{ sync: true, notRefreshWhenSyncError: true, callback },
)
expect(mockDoSync).toHaveBeenCalledWith(true, callback)
})
it('should do nothing when nodes are read-only', () => {
const mockDoSync = vi.fn().mockResolvedValue(undefined)
const { result } = renderWorkflowHook(() => useNodeDataUpdate(), {
initialStoreState: {
workflowRunningData: {
result: { status: WorkflowRunningStatus.Running },
} as WorkflowRunningData,
},
hooksStoreProps: { doSyncWorkflowDraft: mockDoSync },
})
result.current.handleNodeDataUpdateWithSyncDraft({
id: 'node-1',
data: { value: 'should-not-update' },
})
expect(rfState.setNodes).not.toHaveBeenCalled()
expect(mockDoSync).not.toHaveBeenCalled()
})
})
})

View File

@@ -1,79 +0,0 @@
import type { WorkflowRunningData } from '../../types'
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { WorkflowRunningStatus } from '../../types'
import { useNodesSyncDraft } from '../use-nodes-sync-draft'
describe('useNodesSyncDraft', () => {
it('should return doSyncWorkflowDraft, handleSyncWorkflowDraft, and syncWorkflowDraftWhenPageClose', () => {
const mockDoSync = vi.fn().mockResolvedValue(undefined)
const mockSyncClose = vi.fn()
const { result } = renderWorkflowHook(() => useNodesSyncDraft(), {
hooksStoreProps: {
doSyncWorkflowDraft: mockDoSync,
syncWorkflowDraftWhenPageClose: mockSyncClose,
},
})
expect(result.current.doSyncWorkflowDraft).toBe(mockDoSync)
expect(result.current.syncWorkflowDraftWhenPageClose).toBe(mockSyncClose)
expect(typeof result.current.handleSyncWorkflowDraft).toBe('function')
})
it('should call doSyncWorkflowDraft synchronously when sync=true', () => {
const mockDoSync = vi.fn().mockResolvedValue(undefined)
const { result } = renderWorkflowHook(() => useNodesSyncDraft(), {
hooksStoreProps: { doSyncWorkflowDraft: mockDoSync },
})
const callback = { onSuccess: vi.fn() }
result.current.handleSyncWorkflowDraft(true, false, callback)
expect(mockDoSync).toHaveBeenCalledWith(false, callback)
})
it('should use debounced path when sync is falsy, then flush triggers doSync', () => {
const mockDoSync = vi.fn().mockResolvedValue(undefined)
const { result, store } = renderWorkflowHook(() => useNodesSyncDraft(), {
hooksStoreProps: { doSyncWorkflowDraft: mockDoSync },
})
result.current.handleSyncWorkflowDraft()
expect(mockDoSync).not.toHaveBeenCalled()
store.getState().flushPendingSync()
expect(mockDoSync).toHaveBeenCalledOnce()
})
it('should do nothing when nodes are read-only (workflow running)', () => {
const mockDoSync = vi.fn().mockResolvedValue(undefined)
const { result } = renderWorkflowHook(() => useNodesSyncDraft(), {
initialStoreState: {
workflowRunningData: {
result: { status: WorkflowRunningStatus.Running },
} as WorkflowRunningData,
},
hooksStoreProps: { doSyncWorkflowDraft: mockDoSync },
})
result.current.handleSyncWorkflowDraft(true)
expect(mockDoSync).not.toHaveBeenCalled()
})
it('should pass notRefreshWhenSyncError to doSyncWorkflowDraft', () => {
const mockDoSync = vi.fn().mockResolvedValue(undefined)
const { result } = renderWorkflowHook(() => useNodesSyncDraft(), {
hooksStoreProps: { doSyncWorkflowDraft: mockDoSync },
})
result.current.handleSyncWorkflowDraft(true, true)
expect(mockDoSync).toHaveBeenCalledWith(true, undefined)
})
})

View File

@@ -1,94 +0,0 @@
import { act, renderHook } from '@testing-library/react'
import { useSerialAsyncCallback } from '../use-serial-async-callback'
describe('useSerialAsyncCallback', () => {
it('should execute a synchronous function and return its result', async () => {
const fn = vi.fn((..._args: number[]) => 42)
const { result } = renderHook(() => useSerialAsyncCallback(fn))
const value = await act(() => result.current(1, 2))
expect(value).toBe(42)
expect(fn).toHaveBeenCalledWith(1, 2)
})
it('should execute an async function and return its result', async () => {
const fn = vi.fn(async (x: number) => x * 2)
const { result } = renderHook(() => useSerialAsyncCallback(fn))
const value = await act(() => result.current(5))
expect(value).toBe(10)
})
it('should serialize concurrent calls sequentially', async () => {
const order: number[] = []
const fn = vi.fn(async (id: number, delay: number) => {
await new Promise(resolve => setTimeout(resolve, delay))
order.push(id)
return id
})
const { result } = renderHook(() => useSerialAsyncCallback(fn))
let r1: number | undefined
let r2: number | undefined
let r3: number | undefined
await act(async () => {
const p1 = result.current(1, 30)
const p2 = result.current(2, 10)
const p3 = result.current(3, 5)
r1 = await p1
r2 = await p2
r3 = await p3
})
expect(order).toEqual([1, 2, 3])
expect(r1).toBe(1)
expect(r2).toBe(2)
expect(r3).toBe(3)
})
it('should skip execution when shouldSkip returns true', async () => {
const fn = vi.fn(async () => 'executed')
const shouldSkip = vi.fn(() => true)
const { result } = renderHook(() => useSerialAsyncCallback(fn, shouldSkip))
const value = await act(() => result.current())
expect(value).toBeUndefined()
expect(fn).not.toHaveBeenCalled()
})
it('should execute when shouldSkip returns false', async () => {
const fn = vi.fn(async () => 'executed')
const shouldSkip = vi.fn(() => false)
const { result } = renderHook(() => useSerialAsyncCallback(fn, shouldSkip))
const value = await act(() => result.current())
expect(value).toBe('executed')
expect(fn).toHaveBeenCalledOnce()
})
it('should continue queuing after a previous call rejects', async () => {
let callCount = 0
const fn = vi.fn(async () => {
callCount++
if (callCount === 1)
throw new Error('fail')
return 'ok'
})
const { result } = renderHook(() => useSerialAsyncCallback(fn))
await act(async () => {
await result.current().catch(() => {})
const value = await result.current()
expect(value).toBe('ok')
})
expect(fn).toHaveBeenCalledTimes(2)
})
})

View File

@@ -1,242 +0,0 @@
import type {
AgentLogResponse,
HumanInputFormFilledResponse,
HumanInputFormTimeoutResponse,
TextChunkResponse,
TextReplaceResponse,
WorkflowFinishedResponse,
} from '@/types/workflow'
import { baseRunningData, renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { WorkflowRunningStatus } from '../../types'
import { useWorkflowAgentLog } from '../use-workflow-run-event/use-workflow-agent-log'
import { useWorkflowFailed } from '../use-workflow-run-event/use-workflow-failed'
import { useWorkflowFinished } from '../use-workflow-run-event/use-workflow-finished'
import { useWorkflowNodeHumanInputFormFilled } from '../use-workflow-run-event/use-workflow-node-human-input-form-filled'
import { useWorkflowNodeHumanInputFormTimeout } from '../use-workflow-run-event/use-workflow-node-human-input-form-timeout'
import { useWorkflowPaused } from '../use-workflow-run-event/use-workflow-paused'
import { useWorkflowTextChunk } from '../use-workflow-run-event/use-workflow-text-chunk'
import { useWorkflowTextReplace } from '../use-workflow-run-event/use-workflow-text-replace'
vi.mock('@/app/components/base/file-uploader/utils', () => ({
getFilesInLogs: vi.fn(() => []),
}))
describe('useWorkflowFailed', () => {
it('should set status to Failed', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowFailed(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
result.current.handleWorkflowFailed()
expect(store.getState().workflowRunningData!.result.status).toBe(WorkflowRunningStatus.Failed)
})
})
describe('useWorkflowPaused', () => {
it('should set status to Paused', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowPaused(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
result.current.handleWorkflowPaused()
expect(store.getState().workflowRunningData!.result.status).toBe(WorkflowRunningStatus.Paused)
})
})
describe('useWorkflowTextChunk', () => {
it('should append text and activate result tab', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowTextChunk(), {
initialStoreState: {
workflowRunningData: baseRunningData({ resultText: 'Hello' }),
},
})
result.current.handleWorkflowTextChunk({ data: { text: ' World' } } as TextChunkResponse)
const state = store.getState().workflowRunningData!
expect(state.resultText).toBe('Hello World')
expect(state.resultTabActive).toBe(true)
})
})
describe('useWorkflowTextReplace', () => {
it('should replace resultText', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowTextReplace(), {
initialStoreState: {
workflowRunningData: baseRunningData({ resultText: 'old text' }),
},
})
result.current.handleWorkflowTextReplace({ data: { text: 'new text' } } as TextReplaceResponse)
expect(store.getState().workflowRunningData!.resultText).toBe('new text')
})
})
describe('useWorkflowFinished', () => {
it('should merge data into result and activate result tab for single string output', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowFinished(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
result.current.handleWorkflowFinished({
data: { status: 'succeeded', outputs: { answer: 'hello' } },
} as WorkflowFinishedResponse)
const state = store.getState().workflowRunningData!
expect(state.result.status).toBe('succeeded')
expect(state.resultTabActive).toBe(true)
expect(state.resultText).toBe('hello')
})
it('should not activate result tab for multi-key outputs', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowFinished(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
result.current.handleWorkflowFinished({
data: { status: 'succeeded', outputs: { a: 'hello', b: 'world' } },
} as WorkflowFinishedResponse)
expect(store.getState().workflowRunningData!.resultTabActive).toBeFalsy()
})
})
describe('useWorkflowAgentLog', () => {
it('should create agent_log array when execution_metadata has no agent_log', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowAgentLog(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ node_id: 'n1', execution_metadata: {} }],
}),
},
})
result.current.handleWorkflowAgentLog({
data: { node_id: 'n1', message_id: 'm1' },
} as AgentLogResponse)
const trace = store.getState().workflowRunningData!.tracing![0]
expect(trace.execution_metadata!.agent_log).toHaveLength(1)
expect(trace.execution_metadata!.agent_log![0].message_id).toBe('m1')
})
it('should append to existing agent_log', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowAgentLog(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{
node_id: 'n1',
execution_metadata: { agent_log: [{ message_id: 'm1', text: 'log1' }] },
}],
}),
},
})
result.current.handleWorkflowAgentLog({
data: { node_id: 'n1', message_id: 'm2' },
} as AgentLogResponse)
expect(store.getState().workflowRunningData!.tracing![0].execution_metadata!.agent_log).toHaveLength(2)
})
it('should update existing log entry by message_id', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowAgentLog(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{
node_id: 'n1',
execution_metadata: { agent_log: [{ message_id: 'm1', text: 'old' }] },
}],
}),
},
})
result.current.handleWorkflowAgentLog({
data: { node_id: 'n1', message_id: 'm1', text: 'new' },
} as unknown as AgentLogResponse)
const log = store.getState().workflowRunningData!.tracing![0].execution_metadata!.agent_log!
expect(log).toHaveLength(1)
expect((log[0] as unknown as { text: string }).text).toBe('new')
})
it('should create execution_metadata when it does not exist', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowAgentLog(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ node_id: 'n1' }],
}),
},
})
result.current.handleWorkflowAgentLog({
data: { node_id: 'n1', message_id: 'm1' },
} as AgentLogResponse)
expect(store.getState().workflowRunningData!.tracing![0].execution_metadata!.agent_log).toHaveLength(1)
})
})
describe('useWorkflowNodeHumanInputFormFilled', () => {
it('should remove form from humanInputFormDataList and add to humanInputFilledFormDataList', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputFormFilled(), {
initialStoreState: {
workflowRunningData: baseRunningData({
humanInputFormDataList: [
{ node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: '' },
],
}),
},
})
result.current.handleWorkflowNodeHumanInputFormFilled({
data: { node_id: 'n1', node_title: 'Node 1', rendered_content: 'done' },
} as HumanInputFormFilledResponse)
const state = store.getState().workflowRunningData!
expect(state.humanInputFormDataList).toHaveLength(0)
expect(state.humanInputFilledFormDataList).toHaveLength(1)
expect(state.humanInputFilledFormDataList![0].node_id).toBe('n1')
})
it('should create humanInputFilledFormDataList when it does not exist', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputFormFilled(), {
initialStoreState: {
workflowRunningData: baseRunningData({
humanInputFormDataList: [
{ node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: '' },
],
}),
},
})
result.current.handleWorkflowNodeHumanInputFormFilled({
data: { node_id: 'n1', node_title: 'Node 1', rendered_content: 'done' },
} as HumanInputFormFilledResponse)
expect(store.getState().workflowRunningData!.humanInputFilledFormDataList).toBeDefined()
})
})
describe('useWorkflowNodeHumanInputFormTimeout', () => {
it('should set expiration_time on the matching form', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputFormTimeout(), {
initialStoreState: {
workflowRunningData: baseRunningData({
humanInputFormDataList: [
{ node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: '', expiration_time: 0 },
],
}),
},
})
result.current.handleWorkflowNodeHumanInputFormTimeout({
data: { node_id: 'n1', node_title: 'Node 1', expiration_time: 1000 },
} as HumanInputFormTimeoutResponse)
expect(store.getState().workflowRunningData!.humanInputFormDataList![0].expiration_time).toBe(1000)
})
})

View File

@@ -1,269 +0,0 @@
import type { WorkflowRunningData } from '../../types'
import type {
IterationFinishedResponse,
IterationNextResponse,
LoopFinishedResponse,
LoopNextResponse,
NodeFinishedResponse,
WorkflowStartedResponse,
} from '@/types/workflow'
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
import { baseRunningData, renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { DEFAULT_ITER_TIMES } from '../../constants'
import { NodeRunningStatus, WorkflowRunningStatus } from '../../types'
import { useWorkflowNodeFinished } from '../use-workflow-run-event/use-workflow-node-finished'
import { useWorkflowNodeIterationFinished } from '../use-workflow-run-event/use-workflow-node-iteration-finished'
import { useWorkflowNodeIterationNext } from '../use-workflow-run-event/use-workflow-node-iteration-next'
import { useWorkflowNodeLoopFinished } from '../use-workflow-run-event/use-workflow-node-loop-finished'
import { useWorkflowNodeLoopNext } from '../use-workflow-run-event/use-workflow-node-loop-next'
import { useWorkflowNodeRetry } from '../use-workflow-run-event/use-workflow-node-retry'
import { useWorkflowStarted } from '../use-workflow-run-event/use-workflow-started'
vi.mock('reactflow', async () =>
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
describe('useWorkflowStarted', () => {
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, width: 200, height: 80, data: { _waitingRun: false } },
]
rfState.edges = [
{ id: 'e1', source: 'n0', target: 'n1', data: {} },
]
})
it('should initialize workflow running data and reset nodes/edges', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowStarted(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
result.current.handleWorkflowStarted({
task_id: 'task-2',
data: { id: 'run-1', workflow_id: 'wf-1', created_at: 1000 },
} as WorkflowStartedResponse)
const state = store.getState().workflowRunningData!
expect(state.task_id).toBe('task-2')
expect(state.result.status).toBe(WorkflowRunningStatus.Running)
expect(state.resultText).toBe('')
expect(rfState.setNodes).toHaveBeenCalledOnce()
const updatedNodes = rfState.setNodes.mock.calls[0][0]
expect(updatedNodes[0].data._waitingRun).toBe(true)
expect(rfState.setEdges).toHaveBeenCalledOnce()
})
it('should resume from Paused without resetting nodes/edges', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowStarted(), {
initialStoreState: {
workflowRunningData: baseRunningData({
result: { status: WorkflowRunningStatus.Paused } as WorkflowRunningData['result'],
}),
},
})
result.current.handleWorkflowStarted({
task_id: 'task-2',
data: { id: 'run-2', workflow_id: 'wf-1', created_at: 2000 },
} as WorkflowStartedResponse)
expect(store.getState().workflowRunningData!.result.status).toBe(WorkflowRunningStatus.Running)
expect(rfState.setNodes).not.toHaveBeenCalled()
expect(rfState.setEdges).not.toHaveBeenCalled()
})
})
describe('useWorkflowNodeFinished', () => {
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } },
]
rfState.edges = [
{ id: 'e1', source: 'n0', target: 'n1', data: {} },
]
})
it('should update tracing and node running status', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeFinished(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Running }],
}),
},
})
result.current.handleWorkflowNodeFinished({
data: { id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
} as NodeFinishedResponse)
const trace = store.getState().workflowRunningData!.tracing![0]
expect(trace.status).toBe(NodeRunningStatus.Succeeded)
const updatedNodes = rfState.setNodes.mock.calls[0][0]
expect(updatedNodes[0].data._runningStatus).toBe(NodeRunningStatus.Succeeded)
expect(rfState.setEdges).toHaveBeenCalledOnce()
})
it('should set _runningBranchId for IfElse node', () => {
const { result } = renderWorkflowHook(() => useWorkflowNodeFinished(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Running }],
}),
},
})
result.current.handleWorkflowNodeFinished({
data: {
id: 'trace-1',
node_id: 'n1',
node_type: 'if-else',
status: NodeRunningStatus.Succeeded,
outputs: { selected_case_id: 'branch-a' },
},
} as unknown as NodeFinishedResponse)
const updatedNodes = rfState.setNodes.mock.calls[0][0]
expect(updatedNodes[0].data._runningBranchId).toBe('branch-a')
})
})
describe('useWorkflowNodeRetry', () => {
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, data: {} },
]
})
it('should push retry data to tracing and update _retryIndex', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeRetry(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
result.current.handleWorkflowNodeRetry({
data: { node_id: 'n1', retry_index: 2 },
} as NodeFinishedResponse)
expect(store.getState().workflowRunningData!.tracing).toHaveLength(1)
const updatedNodes = rfState.setNodes.mock.calls[0][0]
expect(updatedNodes[0].data._retryIndex).toBe(2)
})
})
describe('useWorkflowNodeIterationNext', () => {
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, data: {} },
]
})
it('should set _iterationIndex and increment iterTimes', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeIterationNext(), {
initialStoreState: {
workflowRunningData: baseRunningData(),
iterTimes: 3,
},
})
result.current.handleWorkflowNodeIterationNext({
data: { node_id: 'n1' },
} as IterationNextResponse)
const updatedNodes = rfState.setNodes.mock.calls[0][0]
expect(updatedNodes[0].data._iterationIndex).toBe(3)
expect(store.getState().iterTimes).toBe(4)
})
})
describe('useWorkflowNodeIterationFinished', () => {
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } },
]
rfState.edges = [
{ id: 'e1', source: 'n0', target: 'n1', data: {} },
]
})
it('should update tracing, reset iterTimes, update node status and edges', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeIterationFinished(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ id: 'iter-1', node_id: 'n1', status: NodeRunningStatus.Running }],
}),
iterTimes: 10,
},
})
result.current.handleWorkflowNodeIterationFinished({
data: { id: 'iter-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
} as IterationFinishedResponse)
expect(store.getState().iterTimes).toBe(DEFAULT_ITER_TIMES)
const updatedNodes = rfState.setNodes.mock.calls[0][0]
expect(updatedNodes[0].data._runningStatus).toBe(NodeRunningStatus.Succeeded)
expect(rfState.setEdges).toHaveBeenCalledOnce()
})
})
describe('useWorkflowNodeLoopNext', () => {
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, data: {} },
{ id: 'n2', position: { x: 300, y: 0 }, parentId: 'n1', data: { _waitingRun: false } },
]
})
it('should set _loopIndex and reset child nodes to waiting', () => {
const { result } = renderWorkflowHook(() => useWorkflowNodeLoopNext(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
result.current.handleWorkflowNodeLoopNext({
data: { node_id: 'n1', index: 5 },
} as LoopNextResponse)
const updatedNodes = rfState.setNodes.mock.calls[0][0]
expect(updatedNodes[0].data._loopIndex).toBe(5)
expect(updatedNodes[1].data._waitingRun).toBe(true)
expect(updatedNodes[1].data._runningStatus).toBe(NodeRunningStatus.Waiting)
})
})
describe('useWorkflowNodeLoopFinished', () => {
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } },
]
rfState.edges = [
{ id: 'e1', source: 'n0', target: 'n1', data: {} },
]
})
it('should update tracing, node status and edges', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeLoopFinished(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ id: 'loop-1', node_id: 'n1', status: NodeRunningStatus.Running }],
}),
},
})
result.current.handleWorkflowNodeLoopFinished({
data: { id: 'loop-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
} as LoopFinishedResponse)
const trace = store.getState().workflowRunningData!.tracing![0]
expect(trace.status).toBe(NodeRunningStatus.Succeeded)
expect(rfState.setEdges).toHaveBeenCalledOnce()
})
})

View File

@@ -1,244 +0,0 @@
import type {
HumanInputRequiredResponse,
IterationStartedResponse,
LoopStartedResponse,
NodeStartedResponse,
} from '@/types/workflow'
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
import { baseRunningData, renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { DEFAULT_ITER_TIMES } from '../../constants'
import { NodeRunningStatus } from '../../types'
import { useWorkflowNodeHumanInputRequired } from '../use-workflow-run-event/use-workflow-node-human-input-required'
import { useWorkflowNodeIterationStarted } from '../use-workflow-run-event/use-workflow-node-iteration-started'
import { useWorkflowNodeLoopStarted } from '../use-workflow-run-event/use-workflow-node-loop-started'
import { useWorkflowNodeStarted } from '../use-workflow-run-event/use-workflow-node-started'
vi.mock('reactflow', async () =>
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
function findNodeById(nodes: Array<{ id: string, data: Record<string, unknown> }>, id: string) {
return nodes.find(n => n.id === id)!
}
const containerParams = { clientWidth: 1200, clientHeight: 800 }
describe('useWorkflowNodeStarted', () => {
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n0', position: { x: 0, y: 0 }, width: 200, height: 80, data: { _runningStatus: NodeRunningStatus.Succeeded } },
{ id: 'n1', position: { x: 100, y: 50 }, width: 200, height: 80, data: { _waitingRun: true } },
{ id: 'n2', position: { x: 400, y: 50 }, width: 200, height: 80, parentId: 'n1', data: { _waitingRun: true } },
]
rfState.edges = [
{ id: 'e1', source: 'n0', target: 'n1', data: {} },
]
})
it('should push to tracing, set node running, and adjust viewport for root node', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeStarted(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
result.current.handleWorkflowNodeStarted(
{ data: { node_id: 'n1' } } as NodeStartedResponse,
containerParams,
)
const tracing = store.getState().workflowRunningData!.tracing!
expect(tracing).toHaveLength(1)
expect(tracing[0].status).toBe(NodeRunningStatus.Running)
expect(rfState.setViewport).toHaveBeenCalledOnce()
const updatedNodes = rfState.setNodes.mock.calls[0][0]
const n1 = findNodeById(updatedNodes, 'n1')
expect(n1.data._runningStatus).toBe(NodeRunningStatus.Running)
expect(n1.data._waitingRun).toBe(false)
expect(rfState.setEdges).toHaveBeenCalledOnce()
})
it('should not adjust viewport for child node (has parentId)', () => {
const { result } = renderWorkflowHook(() => useWorkflowNodeStarted(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
result.current.handleWorkflowNodeStarted(
{ data: { node_id: 'n2' } } as NodeStartedResponse,
containerParams,
)
expect(rfState.setViewport).not.toHaveBeenCalled()
})
it('should update existing tracing entry if node_id exists at non-zero index', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeStarted(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [
{ node_id: 'n0', status: NodeRunningStatus.Succeeded },
{ node_id: 'n1', status: NodeRunningStatus.Succeeded },
],
}),
},
})
result.current.handleWorkflowNodeStarted(
{ data: { node_id: 'n1' } } as NodeStartedResponse,
containerParams,
)
const tracing = store.getState().workflowRunningData!.tracing!
expect(tracing).toHaveLength(2)
expect(tracing[1].status).toBe(NodeRunningStatus.Running)
})
})
describe('useWorkflowNodeIterationStarted', () => {
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n0', position: { x: 0, y: 0 }, width: 200, height: 80, data: { _runningStatus: NodeRunningStatus.Succeeded } },
{ id: 'n1', position: { x: 100, y: 50 }, width: 200, height: 80, data: { _waitingRun: true } },
]
rfState.edges = [
{ id: 'e1', source: 'n0', target: 'n1', data: {} },
]
})
it('should push to tracing, reset iterTimes, set viewport, and update node with _iterationLength', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeIterationStarted(), {
initialStoreState: {
workflowRunningData: baseRunningData(),
iterTimes: 99,
},
})
result.current.handleWorkflowNodeIterationStarted(
{ data: { node_id: 'n1', metadata: { iterator_length: 10 } } } as IterationStartedResponse,
containerParams,
)
const tracing = store.getState().workflowRunningData!.tracing!
expect(tracing[0].status).toBe(NodeRunningStatus.Running)
expect(store.getState().iterTimes).toBe(DEFAULT_ITER_TIMES)
expect(rfState.setViewport).toHaveBeenCalledOnce()
const updatedNodes = rfState.setNodes.mock.calls[0][0]
const n1 = findNodeById(updatedNodes, 'n1')
expect(n1.data._runningStatus).toBe(NodeRunningStatus.Running)
expect(n1.data._iterationLength).toBe(10)
expect(n1.data._waitingRun).toBe(false)
expect(rfState.setEdges).toHaveBeenCalledOnce()
})
})
describe('useWorkflowNodeLoopStarted', () => {
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n0', position: { x: 0, y: 0 }, width: 200, height: 80, data: { _runningStatus: NodeRunningStatus.Succeeded } },
{ id: 'n1', position: { x: 100, y: 50 }, width: 200, height: 80, data: { _waitingRun: true } },
]
rfState.edges = [
{ id: 'e1', source: 'n0', target: 'n1', data: {} },
]
})
it('should push to tracing, set viewport, and update node with _loopLength', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeLoopStarted(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
result.current.handleWorkflowNodeLoopStarted(
{ data: { node_id: 'n1', metadata: { loop_length: 5 } } } as LoopStartedResponse,
containerParams,
)
expect(store.getState().workflowRunningData!.tracing![0].status).toBe(NodeRunningStatus.Running)
expect(rfState.setViewport).toHaveBeenCalledOnce()
const updatedNodes = rfState.setNodes.mock.calls[0][0]
const n1 = findNodeById(updatedNodes, 'n1')
expect(n1.data._runningStatus).toBe(NodeRunningStatus.Running)
expect(n1.data._loopLength).toBe(5)
expect(n1.data._waitingRun).toBe(false)
expect(rfState.setEdges).toHaveBeenCalledOnce()
})
})
describe('useWorkflowNodeHumanInputRequired', () => {
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } },
{ id: 'n2', position: { x: 300, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } },
]
})
it('should create humanInputFormDataList and set tracing/node to Paused', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputRequired(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ node_id: 'n1', status: NodeRunningStatus.Running }],
}),
},
})
result.current.handleWorkflowNodeHumanInputRequired({
data: { node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: 'content' },
} as HumanInputRequiredResponse)
const state = store.getState().workflowRunningData!
expect(state.humanInputFormDataList).toHaveLength(1)
expect(state.humanInputFormDataList![0].form_id).toBe('f1')
expect(state.tracing![0].status).toBe(NodeRunningStatus.Paused)
const updatedNodes = rfState.setNodes.mock.calls[0][0]
expect(findNodeById(updatedNodes, 'n1').data._runningStatus).toBe(NodeRunningStatus.Paused)
})
it('should update existing form entry for same node_id', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputRequired(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ node_id: 'n1', status: NodeRunningStatus.Running }],
humanInputFormDataList: [
{ node_id: 'n1', form_id: 'old', node_title: 'Node 1', form_content: 'old' },
],
}),
},
})
result.current.handleWorkflowNodeHumanInputRequired({
data: { node_id: 'n1', form_id: 'new', node_title: 'Node 1', form_content: 'new' },
} as HumanInputRequiredResponse)
const formList = store.getState().workflowRunningData!.humanInputFormDataList!
expect(formList).toHaveLength(1)
expect(formList[0].form_id).toBe('new')
})
it('should append new form entry for different node_id', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputRequired(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ node_id: 'n2', status: NodeRunningStatus.Running }],
humanInputFormDataList: [
{ node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: '' },
],
}),
},
})
result.current.handleWorkflowNodeHumanInputRequired({
data: { node_id: 'n2', form_id: 'f2', node_title: 'Node 2', form_content: 'content2' },
} as HumanInputRequiredResponse)
expect(store.getState().workflowRunningData!.humanInputFormDataList).toHaveLength(2)
})
})

View File

@@ -1,67 +0,0 @@
import type { ConversationVariable } from '@/app/components/workflow/types'
import { ChatVarType } from '@/app/components/workflow/panel/chat-variable-panel/type'
import { createTestWorkflowStore } from '../../__tests__/workflow-test-env'
function createStore() {
return createTestWorkflowStore()
}
describe('Chat Variable Slice', () => {
describe('setShowChatVariablePanel', () => {
it('should hide other panels when opening', () => {
const store = createStore()
store.getState().setShowDebugAndPreviewPanel(true)
store.getState().setShowEnvPanel(true)
store.getState().setShowChatVariablePanel(true)
const state = store.getState()
expect(state.showChatVariablePanel).toBe(true)
expect(state.showDebugAndPreviewPanel).toBe(false)
expect(state.showEnvPanel).toBe(false)
expect(state.showGlobalVariablePanel).toBe(false)
})
it('should only close itself when setting false', () => {
const store = createStore()
store.getState().setShowChatVariablePanel(true)
store.getState().setShowChatVariablePanel(false)
expect(store.getState().showChatVariablePanel).toBe(false)
})
})
describe('setShowGlobalVariablePanel', () => {
it('should hide other panels when opening', () => {
const store = createStore()
store.getState().setShowDebugAndPreviewPanel(true)
store.getState().setShowChatVariablePanel(true)
store.getState().setShowGlobalVariablePanel(true)
const state = store.getState()
expect(state.showGlobalVariablePanel).toBe(true)
expect(state.showDebugAndPreviewPanel).toBe(false)
expect(state.showChatVariablePanel).toBe(false)
expect(state.showEnvPanel).toBe(false)
})
it('should only close itself when setting false', () => {
const store = createStore()
store.getState().setShowGlobalVariablePanel(true)
store.getState().setShowGlobalVariablePanel(false)
expect(store.getState().showGlobalVariablePanel).toBe(false)
})
})
describe('setConversationVariables', () => {
it('should update conversationVariables', () => {
const store = createStore()
const vars: ConversationVariable[] = [{ id: 'cv1', name: 'history', value: [], value_type: ChatVarType.String, description: '' }]
store.getState().setConversationVariables(vars)
expect(store.getState().conversationVariables).toEqual(vars)
})
})
})

View File

@@ -1,62 +0,0 @@
import type { DataSet } from '@/models/datasets'
import { createDatasetsDetailStore } from '../../datasets-detail-store/store'
function makeDataset(id: string, name: string): DataSet {
return { id, name } as DataSet
}
describe('DatasetsDetailStore', () => {
describe('Initial State', () => {
it('should start with empty datasetsDetail', () => {
const store = createDatasetsDetailStore()
expect(store.getState().datasetsDetail).toEqual({})
})
})
describe('updateDatasetsDetail', () => {
it('should add datasets by id', () => {
const store = createDatasetsDetailStore()
const ds1 = makeDataset('ds-1', 'Dataset 1')
const ds2 = makeDataset('ds-2', 'Dataset 2')
store.getState().updateDatasetsDetail([ds1, ds2])
expect(store.getState().datasetsDetail['ds-1']).toEqual(ds1)
expect(store.getState().datasetsDetail['ds-2']).toEqual(ds2)
})
it('should merge new datasets into existing ones', () => {
const store = createDatasetsDetailStore()
const ds1 = makeDataset('ds-1', 'First')
const ds2 = makeDataset('ds-2', 'Second')
const ds3 = makeDataset('ds-3', 'Third')
store.getState().updateDatasetsDetail([ds1, ds2])
store.getState().updateDatasetsDetail([ds3])
const detail = store.getState().datasetsDetail
expect(detail['ds-1']).toEqual(ds1)
expect(detail['ds-2']).toEqual(ds2)
expect(detail['ds-3']).toEqual(ds3)
})
it('should overwrite existing datasets with same id', () => {
const store = createDatasetsDetailStore()
const ds1v1 = makeDataset('ds-1', 'Version 1')
const ds1v2 = makeDataset('ds-1', 'Version 2')
store.getState().updateDatasetsDetail([ds1v1])
store.getState().updateDatasetsDetail([ds1v2])
expect(store.getState().datasetsDetail['ds-1'].name).toBe('Version 2')
})
it('should handle empty array without errors', () => {
const store = createDatasetsDetailStore()
store.getState().updateDatasetsDetail([makeDataset('ds-1', 'Test')])
store.getState().updateDatasetsDetail([])
expect(store.getState().datasetsDetail['ds-1'].name).toBe('Test')
})
})
})

View File

@@ -1,67 +0,0 @@
import type { EnvironmentVariable } from '@/app/components/workflow/types'
import { createTestWorkflowStore } from '../../__tests__/workflow-test-env'
function createStore() {
return createTestWorkflowStore()
}
describe('Env Variable Slice', () => {
describe('setShowEnvPanel', () => {
it('should hide other panels when opening', () => {
const store = createStore()
store.getState().setShowDebugAndPreviewPanel(true)
store.getState().setShowChatVariablePanel(true)
store.getState().setShowEnvPanel(true)
const state = store.getState()
expect(state.showEnvPanel).toBe(true)
expect(state.showDebugAndPreviewPanel).toBe(false)
expect(state.showChatVariablePanel).toBe(false)
expect(state.showGlobalVariablePanel).toBe(false)
})
it('should only close itself when setting false', () => {
const store = createStore()
store.getState().setShowEnvPanel(true)
store.getState().setShowEnvPanel(false)
expect(store.getState().showEnvPanel).toBe(false)
})
})
describe('setEnvironmentVariables', () => {
it('should update environmentVariables', () => {
const store = createStore()
const vars: EnvironmentVariable[] = [{ id: 'v1', name: 'API_KEY', value: 'secret', value_type: 'string', description: '' }]
store.getState().setEnvironmentVariables(vars)
expect(store.getState().environmentVariables).toEqual(vars)
})
})
describe('setEnvSecrets', () => {
it('should update envSecrets', () => {
const store = createStore()
store.getState().setEnvSecrets({ API_KEY: '***' })
expect(store.getState().envSecrets).toEqual({ API_KEY: '***' })
})
})
describe('Sequential Panel Switching', () => {
it('should correctly switch between exclusive panels', () => {
const store = createStore()
store.getState().setShowChatVariablePanel(true)
expect(store.getState().showChatVariablePanel).toBe(true)
store.getState().setShowEnvPanel(true)
expect(store.getState().showEnvPanel).toBe(true)
expect(store.getState().showChatVariablePanel).toBe(false)
store.getState().setShowGlobalVariablePanel(true)
expect(store.getState().showGlobalVariablePanel).toBe(true)
expect(store.getState().showEnvPanel).toBe(false)
})
})
})

View File

@@ -1,240 +0,0 @@
import type { NodeWithVar, VarInInspect } from '@/types/workflow'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import { VarInInspectType } from '@/types/workflow'
import { createTestWorkflowStore } from '../../__tests__/workflow-test-env'
function createStore() {
return createTestWorkflowStore()
}
function makeVar(overrides: Partial<VarInInspect> = {}): VarInInspect {
return {
id: 'var-1',
name: 'output',
type: VarInInspectType.node,
description: '',
selector: ['node-1', 'output'],
value_type: VarType.string,
value: 'hello',
edited: false,
visible: true,
is_truncated: false,
full_content: { size_bytes: 0, download_url: '' },
...overrides,
}
}
function makeNodeWithVar(nodeId: string, vars: VarInInspect[]): NodeWithVar {
return {
nodeId,
nodePayload: { title: `Node ${nodeId}`, desc: '', type: BlockEnum.Code } as NodeWithVar['nodePayload'],
nodeType: BlockEnum.Code,
title: `Node ${nodeId}`,
vars,
isValueFetched: false,
}
}
describe('Inspect Vars Slice', () => {
describe('setNodesWithInspectVars', () => {
it('should replace the entire list', () => {
const store = createStore()
const nodes = [makeNodeWithVar('n1', [makeVar()])]
store.getState().setNodesWithInspectVars(nodes)
expect(store.getState().nodesWithInspectVars).toEqual(nodes)
})
})
describe('deleteAllInspectVars', () => {
it('should clear all nodes', () => {
const store = createStore()
store.getState().setNodesWithInspectVars([makeNodeWithVar('n1', [makeVar()])])
store.getState().deleteAllInspectVars()
expect(store.getState().nodesWithInspectVars).toEqual([])
})
})
describe('setNodeInspectVars', () => {
it('should update vars for a specific node and mark as fetched', () => {
const store = createStore()
const v1 = makeVar({ id: 'v1', name: 'a' })
const v2 = makeVar({ id: 'v2', name: 'b' })
store.getState().setNodesWithInspectVars([makeNodeWithVar('n1', [v1])])
store.getState().setNodeInspectVars('n1', [v2])
const node = store.getState().nodesWithInspectVars[0]
expect(node.vars).toEqual([v2])
expect(node.isValueFetched).toBe(true)
})
it('should not modify state when node is not found', () => {
const store = createStore()
store.getState().setNodesWithInspectVars([makeNodeWithVar('n1', [makeVar()])])
store.getState().setNodeInspectVars('non-existent', [])
expect(store.getState().nodesWithInspectVars[0].vars).toHaveLength(1)
})
})
describe('deleteNodeInspectVars', () => {
it('should remove the matching node', () => {
const store = createStore()
store.getState().setNodesWithInspectVars([
makeNodeWithVar('n1', [makeVar()]),
makeNodeWithVar('n2', [makeVar()]),
])
store.getState().deleteNodeInspectVars('n1')
expect(store.getState().nodesWithInspectVars).toHaveLength(1)
expect(store.getState().nodesWithInspectVars[0].nodeId).toBe('n2')
})
})
describe('setInspectVarValue', () => {
it('should update the value and set edited=true', () => {
const store = createStore()
const v = makeVar({ id: 'v1', value: 'old', edited: false })
store.getState().setNodesWithInspectVars([makeNodeWithVar('n1', [v])])
store.getState().setInspectVarValue('n1', 'v1', 'new')
const updated = store.getState().nodesWithInspectVars[0].vars[0]
expect(updated.value).toBe('new')
expect(updated.edited).toBe(true)
})
it('should not change state when var is not found', () => {
const store = createStore()
const v = makeVar({ id: 'v1', value: 'old' })
store.getState().setNodesWithInspectVars([makeNodeWithVar('n1', [v])])
store.getState().setInspectVarValue('n1', 'wrong-id', 'new')
expect(store.getState().nodesWithInspectVars[0].vars[0].value).toBe('old')
})
it('should not change state when node is not found', () => {
const store = createStore()
const v = makeVar({ id: 'v1', value: 'old' })
store.getState().setNodesWithInspectVars([makeNodeWithVar('n1', [v])])
store.getState().setInspectVarValue('wrong-node', 'v1', 'new')
expect(store.getState().nodesWithInspectVars[0].vars[0].value).toBe('old')
})
})
describe('resetToLastRunVar', () => {
it('should restore value and set edited=false', () => {
const store = createStore()
const v = makeVar({ id: 'v1', value: 'modified', edited: true })
store.getState().setNodesWithInspectVars([makeNodeWithVar('n1', [v])])
store.getState().resetToLastRunVar('n1', 'v1', 'original')
const updated = store.getState().nodesWithInspectVars[0].vars[0]
expect(updated.value).toBe('original')
expect(updated.edited).toBe(false)
})
it('should not change state when node is not found', () => {
const store = createStore()
store.getState().setNodesWithInspectVars([makeNodeWithVar('n1', [makeVar()])])
store.getState().resetToLastRunVar('wrong-node', 'v1', 'val')
expect(store.getState().nodesWithInspectVars[0].vars[0].edited).toBe(false)
})
it('should not change state when var is not found', () => {
const store = createStore()
store.getState().setNodesWithInspectVars([makeNodeWithVar('n1', [makeVar({ id: 'v1', edited: true })])])
store.getState().resetToLastRunVar('n1', 'wrong-var', 'val')
expect(store.getState().nodesWithInspectVars[0].vars[0].edited).toBe(true)
})
})
describe('renameInspectVarName', () => {
it('should update name and selector', () => {
const store = createStore()
const v = makeVar({ id: 'v1', name: 'old_name', selector: ['n1', 'old_name'] })
store.getState().setNodesWithInspectVars([makeNodeWithVar('n1', [v])])
store.getState().renameInspectVarName('n1', 'v1', ['n1', 'new_name'])
const updated = store.getState().nodesWithInspectVars[0].vars[0]
expect(updated.name).toBe('new_name')
expect(updated.selector).toEqual(['n1', 'new_name'])
})
it('should not change state when node is not found', () => {
const store = createStore()
const v = makeVar({ id: 'v1', name: 'old' })
store.getState().setNodesWithInspectVars([makeNodeWithVar('n1', [v])])
store.getState().renameInspectVarName('wrong-node', 'v1', ['x', 'y'])
expect(store.getState().nodesWithInspectVars[0].vars[0].name).toBe('old')
})
it('should not change state when var is not found', () => {
const store = createStore()
const v = makeVar({ id: 'v1', name: 'old' })
store.getState().setNodesWithInspectVars([makeNodeWithVar('n1', [v])])
store.getState().renameInspectVarName('n1', 'wrong-var', ['x', 'y'])
expect(store.getState().nodesWithInspectVars[0].vars[0].name).toBe('old')
})
})
describe('deleteInspectVar', () => {
it('should remove the matching var from the node', () => {
const store = createStore()
const v1 = makeVar({ id: 'v1' })
const v2 = makeVar({ id: 'v2' })
store.getState().setNodesWithInspectVars([makeNodeWithVar('n1', [v1, v2])])
store.getState().deleteInspectVar('n1', 'v1')
const vars = store.getState().nodesWithInspectVars[0].vars
expect(vars).toHaveLength(1)
expect(vars[0].id).toBe('v2')
})
it('should not change state when var is not found', () => {
const store = createStore()
const v = makeVar({ id: 'v1' })
store.getState().setNodesWithInspectVars([makeNodeWithVar('n1', [v])])
store.getState().deleteInspectVar('n1', 'wrong-id')
expect(store.getState().nodesWithInspectVars[0].vars).toHaveLength(1)
})
it('should not change state when node is not found', () => {
const store = createStore()
store.getState().setNodesWithInspectVars([makeNodeWithVar('n1', [makeVar()])])
store.getState().deleteInspectVar('wrong-node', 'v1')
expect(store.getState().nodesWithInspectVars[0].vars).toHaveLength(1)
})
})
describe('currentFocusNodeId', () => {
it('should update and clear focus node', () => {
const store = createStore()
store.getState().setCurrentFocusNodeId('n1')
expect(store.getState().currentFocusNodeId).toBe('n1')
store.getState().setCurrentFocusNodeId(null)
expect(store.getState().currentFocusNodeId).toBeNull()
})
})
})

View File

@@ -1,43 +0,0 @@
import type { Dependency } from '@/app/components/plugins/types'
import { useStore } from '../../plugin-dependency/store'
describe('Plugin Dependency Store', () => {
beforeEach(() => {
useStore.setState({ dependencies: [] })
})
describe('Initial State', () => {
it('should start with empty dependencies', () => {
expect(useStore.getState().dependencies).toEqual([])
})
})
describe('setDependencies', () => {
it('should update dependencies list', () => {
const deps: Dependency[] = [
{ type: 'marketplace', value: { plugin_unique_identifier: 'p1' } },
{ type: 'marketplace', value: { plugin_unique_identifier: 'p2' } },
] as Dependency[]
useStore.getState().setDependencies(deps)
expect(useStore.getState().dependencies).toEqual(deps)
})
it('should replace existing dependencies', () => {
const dep1: Dependency = { type: 'marketplace', value: { plugin_unique_identifier: 'p1' } } as Dependency
const dep2: Dependency = { type: 'marketplace', value: { plugin_unique_identifier: 'p2' } } as Dependency
useStore.getState().setDependencies([dep1])
useStore.getState().setDependencies([dep2])
expect(useStore.getState().dependencies).toHaveLength(1)
})
it('should handle empty array', () => {
const dep: Dependency = { type: 'marketplace', value: { plugin_unique_identifier: 'p1' } } as Dependency
useStore.getState().setDependencies([dep])
useStore.getState().setDependencies([])
expect(useStore.getState().dependencies).toEqual([])
})
})
})

View File

@@ -1,61 +0,0 @@
import type { VersionHistory } from '@/types/workflow'
import { createTestWorkflowStore } from '../../__tests__/workflow-test-env'
function createStore() {
return createTestWorkflowStore()
}
describe('Version Slice', () => {
describe('setDraftUpdatedAt', () => {
it('should multiply timestamp by 1000 (seconds to milliseconds)', () => {
const store = createStore()
store.getState().setDraftUpdatedAt(1704067200)
expect(store.getState().draftUpdatedAt).toBe(1704067200000)
})
it('should set 0 when given 0', () => {
const store = createStore()
store.getState().setDraftUpdatedAt(0)
expect(store.getState().draftUpdatedAt).toBe(0)
})
})
describe('setPublishedAt', () => {
it('should multiply timestamp by 1000', () => {
const store = createStore()
store.getState().setPublishedAt(1704067200)
expect(store.getState().publishedAt).toBe(1704067200000)
})
it('should set 0 when given 0', () => {
const store = createStore()
store.getState().setPublishedAt(0)
expect(store.getState().publishedAt).toBe(0)
})
})
describe('currentVersion', () => {
it('should default to null', () => {
const store = createStore()
expect(store.getState().currentVersion).toBeNull()
})
it('should update current version', () => {
const store = createStore()
const version = { hash: 'abc', updated_at: 1000, version: '1.0' } as VersionHistory
store.getState().setCurrentVersion(version)
expect(store.getState().currentVersion).toEqual(version)
})
})
describe('isRestoring', () => {
it('should toggle restoring state', () => {
const store = createStore()
store.getState().setIsRestoring(true)
expect(store.getState().isRestoring).toBe(true)
store.getState().setIsRestoring(false)
expect(store.getState().isRestoring).toBe(false)
})
})
})

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