mirror of
https://github.com/langgenius/dify.git
synced 2026-05-25 03:17:15 +08:00
Compare commits
19 Commits
feat/evalu
...
codex/new-
| Author | SHA1 | Date | |
|---|---|---|---|
| 4dc15d39b1 | |||
| cd1cb8f175 | |||
| 2dd683e613 | |||
| 7489893ea4 | |||
| 9dfb9a4b4d | |||
| 1012d84242 | |||
| 80ccec9457 | |||
| d8a48bc62f | |||
| 32270c3e63 | |||
| c6291e928d | |||
| 687377cc76 | |||
| fa730139a6 | |||
| 5fd873d033 | |||
| fb6a495fa5 | |||
| 99d5f80e68 | |||
| 6b1b1f3790 | |||
| 7c65975507 | |||
| 72ee50c74f | |||
| 8d99326fb3 |
@ -657,6 +657,7 @@ PLUGIN_REMOTE_INSTALL_PORT=5003
|
||||
PLUGIN_REMOTE_INSTALL_HOST=localhost
|
||||
PLUGIN_MAX_PACKAGE_SIZE=15728640
|
||||
PLUGIN_MODEL_SCHEMA_CACHE_TTL=3600
|
||||
PLUGIN_MODEL_PROVIDERS_CACHE_TTL=86400
|
||||
INNER_API_KEY_FOR_PLUGIN=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1
|
||||
|
||||
# Marketplace configuration
|
||||
|
||||
@ -11,6 +11,7 @@ from configs import dify_config
|
||||
from core.helper import encrypter
|
||||
from core.plugin.entities.plugin_daemon import CredentialType
|
||||
from core.plugin.impl.plugin import PluginInstaller
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from core.tools.utils.system_encryption import encrypt_system_params
|
||||
from extensions.ext_database import db
|
||||
from models import Tenant
|
||||
@ -20,7 +21,6 @@ from models.source import DataSourceApiKeyAuthBinding, DataSourceOauthBinding
|
||||
from models.tools import ToolOAuthSystemClient
|
||||
from services.plugin.data_migration import PluginDataMigration
|
||||
from services.plugin.plugin_migration import PluginMigration
|
||||
from services.plugin.plugin_service import PluginService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ -265,6 +265,11 @@ class PluginConfig(BaseSettings):
|
||||
default=60 * 60,
|
||||
)
|
||||
|
||||
PLUGIN_MODEL_PROVIDERS_CACHE_TTL: PositiveInt = Field(
|
||||
description="TTL in seconds for caching tenant plugin model providers in Redis",
|
||||
default=60 * 60 * 24,
|
||||
)
|
||||
|
||||
PLUGIN_MAX_FILE_SIZE: PositiveInt = Field(
|
||||
description="Maximum allowed size (bytes) for plugin-generated files",
|
||||
default=50 * 1024 * 1024,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
from typing import Any, override
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic.fields import FieldInfo
|
||||
@ -48,6 +48,7 @@ class ApolloSettingsSource(RemoteSettingsSource):
|
||||
self.namespace = configs["APOLLO_NAMESPACE"]
|
||||
self.remote_configs = self.client.get_all_dicts(self.namespace)
|
||||
|
||||
@override
|
||||
def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]:
|
||||
if not isinstance(self.remote_configs, dict):
|
||||
raise ValueError(f"remote configs is not dict, but {type(self.remote_configs)}")
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
from typing import Any, override
|
||||
|
||||
from pydantic.fields import FieldInfo
|
||||
|
||||
@ -41,6 +41,7 @@ class NacosSettingsSource(RemoteSettingsSource):
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to parse config: {e}")
|
||||
|
||||
@override
|
||||
def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]:
|
||||
field_value = self.remote_configs.get(field_name)
|
||||
if field_value is None:
|
||||
|
||||
@ -10,7 +10,7 @@ import threading
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Callable, Generator
|
||||
from contextlib import AbstractContextManager, contextmanager
|
||||
from typing import Any, Protocol, final, runtime_checkable
|
||||
from typing import Any, Protocol, final, override, runtime_checkable
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
@ -133,10 +133,12 @@ class NullAppContext(AppContext):
|
||||
self._config = config or {}
|
||||
self._extensions: dict[str, Any] = {}
|
||||
|
||||
@override
|
||||
def get_config(self, key: str, default: Any = None) -> Any:
|
||||
"""Get configuration value by key."""
|
||||
return self._config.get(key, default)
|
||||
|
||||
@override
|
||||
def get_extension(self, name: str) -> Any:
|
||||
"""Get extension by name."""
|
||||
return self._extensions.get(name)
|
||||
@ -146,6 +148,7 @@ class NullAppContext(AppContext):
|
||||
self._extensions[name] = extension
|
||||
|
||||
@contextmanager
|
||||
@override
|
||||
def enter(self) -> Generator[None, None, None]:
|
||||
"""Enter null context (no-op)."""
|
||||
yield
|
||||
|
||||
@ -6,7 +6,7 @@ import contextvars
|
||||
import threading
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, final
|
||||
from typing import Any, final, override
|
||||
|
||||
from flask import Flask, current_app, g
|
||||
|
||||
@ -30,15 +30,18 @@ class FlaskAppContext(AppContext):
|
||||
"""
|
||||
self._flask_app = flask_app
|
||||
|
||||
@override
|
||||
def get_config(self, key: str, default: Any = None) -> Any:
|
||||
"""Get configuration value from Flask app config."""
|
||||
return self._flask_app.config.get(key, default)
|
||||
|
||||
@override
|
||||
def get_extension(self, name: str) -> Any:
|
||||
"""Get Flask extension by name."""
|
||||
return self._flask_app.extensions.get(name)
|
||||
|
||||
@contextmanager
|
||||
@override
|
||||
def enter(self) -> Generator[None, None, None]:
|
||||
"""Enter Flask app context."""
|
||||
with self._flask_app.app_context():
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
from flask_restx import Resource, marshal
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
import services
|
||||
@ -54,12 +54,13 @@ class CreateRagPipelineDatasetApi(Resource):
|
||||
yaml_content=payload.yaml_content,
|
||||
)
|
||||
try:
|
||||
with sessionmaker(db.engine).begin() as session:
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
rag_pipeline_dsl_service = RagPipelineDslService(session)
|
||||
import_info = rag_pipeline_dsl_service.create_rag_pipeline_dataset(
|
||||
tenant_id=current_tenant_id,
|
||||
rag_pipeline_dataset_create_entity=rag_pipeline_dataset_create_entity,
|
||||
)
|
||||
session.commit()
|
||||
if rag_pipeline_dataset_create_entity.permission == "partial_members":
|
||||
DatasetPermissionService.update_partial_member_list(
|
||||
current_tenant_id,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
from flask import request
|
||||
from flask_restx import Resource, fields, marshal_with # type: ignore
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.common.schema import get_or_create_model, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
@ -67,10 +67,12 @@ class RagPipelineImportApi(Resource):
|
||||
current_user, _ = current_account_with_tenant()
|
||||
payload = RagPipelineImportPayload.model_validate(console_ns.payload or {})
|
||||
|
||||
# Create service with session
|
||||
with sessionmaker(db.engine).begin() as session:
|
||||
# Use a plain Session so that caught exceptions inside the service
|
||||
# (which return FAILED status instead of re-raising) do not leave the
|
||||
# transaction in a closed state that a .begin() context manager cannot
|
||||
# handle. See app_import.py for the canonical pattern.
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
import_service = RagPipelineDslService(session)
|
||||
# Import app
|
||||
account = current_user
|
||||
result = import_service.import_rag_pipeline(
|
||||
account=account,
|
||||
@ -80,6 +82,10 @@ class RagPipelineImportApi(Resource):
|
||||
pipeline_id=payload.pipeline_id,
|
||||
dataset_name=payload.name,
|
||||
)
|
||||
if result.status == ImportStatus.FAILED:
|
||||
session.rollback()
|
||||
else:
|
||||
session.commit()
|
||||
|
||||
# Return appropriate status code based on result
|
||||
status = result.status
|
||||
@ -102,12 +108,14 @@ class RagPipelineImportConfirmApi(Resource):
|
||||
def post(self, import_id):
|
||||
current_user, _ = current_account_with_tenant()
|
||||
|
||||
# Create service with session
|
||||
with sessionmaker(db.engine).begin() as session:
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
import_service = RagPipelineDslService(session)
|
||||
# Confirm import
|
||||
account = current_user
|
||||
result = import_service.confirm_import(import_id=import_id, account=account)
|
||||
if result.status == ImportStatus.FAILED:
|
||||
session.rollback()
|
||||
else:
|
||||
session.commit()
|
||||
|
||||
# Return appropriate status code based on result
|
||||
if result.status == ImportStatus.FAILED:
|
||||
@ -124,7 +132,7 @@ class RagPipelineImportCheckDependenciesApi(Resource):
|
||||
@edit_permission_required
|
||||
@marshal_with(pipeline_import_check_dependencies_model)
|
||||
def get(self, pipeline: Pipeline):
|
||||
with sessionmaker(db.engine).begin() as session:
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
import_service = RagPipelineDslService(session)
|
||||
result = import_service.check_dependencies(pipeline=pipeline)
|
||||
|
||||
@ -142,7 +150,7 @@ class RagPipelineExportApi(Resource):
|
||||
# Add include_secret params
|
||||
query = IncludeSecretQuery.model_validate(request.args.to_dict())
|
||||
|
||||
with sessionmaker(db.engine).begin() as session:
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
export_service = RagPipelineDslService(session)
|
||||
result = export_service.export_rag_pipeline_dsl(
|
||||
pipeline=pipeline, include_secret=query.include_secret == "true"
|
||||
|
||||
@ -15,6 +15,7 @@ from controllers.console import console_ns
|
||||
from controllers.console.workspace import plugin_permission_required
|
||||
from controllers.console.wraps import account_initialization_required, is_admin_or_owner_required, setup_required
|
||||
from core.plugin.impl.exc import PluginDaemonClientSideError
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from fields.base import ResponseModel
|
||||
from graphon.model_runtime.utils.encoders import jsonable_encoder
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
@ -22,7 +23,6 @@ from models.account import TenantPluginAutoUpgradeStrategy, TenantPluginPermissi
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
from services.plugin.plugin_parameter_service import PluginParameterService
|
||||
from services.plugin.plugin_permission_service import PluginPermissionService
|
||||
from services.plugin.plugin_service import PluginService
|
||||
|
||||
|
||||
class ParserList(BaseModel):
|
||||
|
||||
@ -3,7 +3,6 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import logging
|
||||
from collections.abc import Generator, Iterable, Sequence
|
||||
from threading import Lock
|
||||
from typing import IO, Any, Literal, cast, overload, override
|
||||
|
||||
from pydantic import ValidationError
|
||||
@ -13,9 +12,9 @@ from configs import dify_config
|
||||
from core.llm_generator.output_parser.structured_output import (
|
||||
invoke_llm_with_structured_output as invoke_llm_with_structured_output_helper,
|
||||
)
|
||||
from core.plugin.entities.plugin_daemon import PluginModelProviderEntity
|
||||
from core.plugin.impl.asset import PluginAssetManager
|
||||
from core.plugin.impl.model import PluginModelClient
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from extensions.ext_redis import redis_client
|
||||
from graphon.model_runtime.entities.llm_entities import (
|
||||
LLMResult,
|
||||
@ -101,35 +100,36 @@ class _PluginStructuredOutputModelInstance:
|
||||
|
||||
|
||||
class PluginModelRuntime(ModelRuntime):
|
||||
"""Plugin-backed runtime adapter bound to tenant context and optional caller scope."""
|
||||
"""Plugin-backed runtime adapter bound to tenant context and optional caller scope.
|
||||
|
||||
Provider discovery goes through ``PluginService`` so the plugin lifecycle
|
||||
methods and provider reads share one tenant-scoped cache owner.
|
||||
"""
|
||||
|
||||
tenant_id: str
|
||||
user_id: str | None
|
||||
client: PluginModelClient
|
||||
_provider_entities: tuple[ProviderEntity, ...] | None
|
||||
_provider_entities_lock: Lock
|
||||
_plugin_service: type[PluginService]
|
||||
|
||||
def __init__(self, tenant_id: str, user_id: str | None, client: PluginModelClient) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
tenant_id: str,
|
||||
user_id: str | None,
|
||||
client: PluginModelClient,
|
||||
plugin_service: type[PluginService],
|
||||
) -> None:
|
||||
if client is None:
|
||||
raise ValueError("client is required.")
|
||||
if plugin_service is None:
|
||||
raise ValueError("plugin_service is required.")
|
||||
self.tenant_id = tenant_id
|
||||
self.user_id = user_id
|
||||
self.client = client
|
||||
self._provider_entities = None
|
||||
self._provider_entities_lock = Lock()
|
||||
self._plugin_service = plugin_service
|
||||
|
||||
@override
|
||||
def fetch_model_providers(self) -> Sequence[ProviderEntity]:
|
||||
if self._provider_entities is not None:
|
||||
return self._provider_entities
|
||||
|
||||
with self._provider_entities_lock:
|
||||
if self._provider_entities is None:
|
||||
self._provider_entities = tuple(
|
||||
self._to_provider_entity(provider) for provider in self.client.fetch_model_providers(self.tenant_id)
|
||||
)
|
||||
|
||||
return self._provider_entities
|
||||
return self._plugin_service.fetch_plugin_model_providers(tenant_id=self.tenant_id, client=self.client)
|
||||
|
||||
@override
|
||||
def get_provider_icon(self, *, provider: str, icon_type: str, lang: str) -> tuple[bytes, str]:
|
||||
@ -628,34 +628,6 @@ class PluginModelRuntime(ModelRuntime):
|
||||
text=text,
|
||||
)
|
||||
|
||||
def _get_provider_short_name_alias(self, provider: PluginModelProviderEntity) -> str:
|
||||
"""
|
||||
Expose a bare provider alias only for the canonical provider mapping.
|
||||
|
||||
Multiple plugins can publish the same short provider slug. If every
|
||||
provider entity keeps that slug in ``provider_name``, callers that still
|
||||
resolve by short name become order-dependent. Restrict the alias to the
|
||||
provider selected by ``ModelProviderID`` so legacy short-name lookups
|
||||
remain deterministic while the runtime surface stays canonical.
|
||||
"""
|
||||
try:
|
||||
canonical_provider_id = ModelProviderID(provider.provider)
|
||||
except ValueError:
|
||||
return ""
|
||||
|
||||
if canonical_provider_id.plugin_id != provider.plugin_id:
|
||||
return ""
|
||||
if canonical_provider_id.provider_name != provider.provider:
|
||||
return ""
|
||||
|
||||
return provider.provider
|
||||
|
||||
def _to_provider_entity(self, provider: PluginModelProviderEntity) -> ProviderEntity:
|
||||
declaration = provider.declaration.model_copy(deep=True)
|
||||
declaration.provider = f"{provider.plugin_id}/{provider.provider}"
|
||||
declaration.provider_name = self._get_provider_short_name_alias(provider)
|
||||
return declaration
|
||||
|
||||
def _get_provider_schema(self, provider: str) -> ProviderEntity:
|
||||
providers = self.fetch_model_providers()
|
||||
provider_entity = next((item for item in providers if item.provider == provider), None)
|
||||
|
||||
@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from core.plugin.impl.model import PluginModelClient
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from graphon.model_runtime.entities.provider_entities import ProviderEntity
|
||||
from graphon.model_runtime.model_providers.base.ai_model import AIModel
|
||||
@ -117,6 +118,7 @@ def create_plugin_model_runtime(*, tenant_id: str, user_id: str | None = None) -
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
client=PluginModelClient(),
|
||||
plugin_service=PluginService,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -1,8 +1,17 @@
|
||||
"""Core plugin service and tenant-scoped plugin metadata cache ownership.
|
||||
|
||||
This module owns plugin daemon management calls that are shared by API services
|
||||
and core runtimes. Plugin model provider discovery is cached here, alongside
|
||||
plugin install, uninstall, and upgrade invalidation, so all cache mutations for
|
||||
plugin-owned provider metadata stay tenant-scoped and in one place.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections.abc import Mapping, Sequence
|
||||
from mimetypes import guess_type
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, TypeAdapter, ValidationError
|
||||
from redis import RedisError
|
||||
from sqlalchemy import delete, select, update
|
||||
from sqlalchemy.orm import Session
|
||||
from yarl import URL
|
||||
@ -22,16 +31,20 @@ from core.plugin.entities.plugin import (
|
||||
from core.plugin.entities.plugin_daemon import (
|
||||
PluginDecodeResponse,
|
||||
PluginInstallTask,
|
||||
PluginInstallTaskStatus,
|
||||
PluginListResponse,
|
||||
PluginModelProviderEntity,
|
||||
PluginVerification,
|
||||
)
|
||||
from core.plugin.impl.asset import PluginAssetManager
|
||||
from core.plugin.impl.debugging import PluginDebuggingClient
|
||||
from core.plugin.impl.model import PluginModelClient
|
||||
from core.plugin.impl.plugin import PluginInstaller
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_redis import redis_client
|
||||
from graphon.model_runtime.entities.provider_entities import ProviderEntity
|
||||
from models.provider import Provider, ProviderCredential, TenantPreferredModelProvider
|
||||
from models.provider_ids import GenericProviderID
|
||||
from models.provider_ids import GenericProviderID, ModelProviderID
|
||||
from services.enterprise.plugin_manager_service import (
|
||||
PluginManagerService,
|
||||
PreUninstallPluginRequest,
|
||||
@ -40,6 +53,7 @@ from services.errors.plugin import PluginInstallationForbiddenError
|
||||
from services.feature_service import FeatureService, PluginInstallationScope
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_provider_entities_adapter: TypeAdapter[list[ProviderEntity]] = TypeAdapter(list[ProviderEntity])
|
||||
|
||||
|
||||
class PluginService:
|
||||
@ -53,6 +67,102 @@ class PluginService:
|
||||
|
||||
REDIS_KEY_PREFIX = "plugin_service:latest_plugin:"
|
||||
REDIS_TTL = 60 * 5 # 5 minutes
|
||||
PLUGIN_MODEL_PROVIDERS_REDIS_KEY_PREFIX = "plugin_model_providers:tenant_id:"
|
||||
PLUGIN_INSTALL_TASK_TERMINAL_STATUSES = (PluginInstallTaskStatus.Success, PluginInstallTaskStatus.Failed)
|
||||
|
||||
@classmethod
|
||||
def _get_plugin_model_providers_cache_key(cls, tenant_id: str) -> str:
|
||||
return f"{cls.PLUGIN_MODEL_PROVIDERS_REDIS_KEY_PREFIX}{tenant_id}"
|
||||
|
||||
@staticmethod
|
||||
def _get_provider_short_name_alias(provider: PluginModelProviderEntity) -> str:
|
||||
"""
|
||||
Expose a bare provider alias only for the canonical provider mapping.
|
||||
|
||||
Multiple plugins can publish the same short provider slug. If every
|
||||
provider entity keeps that slug in ``provider_name``, callers that still
|
||||
resolve by short name become order-dependent. Restrict the alias to the
|
||||
provider selected by ``ModelProviderID`` so legacy short-name lookups
|
||||
remain deterministic while the runtime surface stays canonical.
|
||||
"""
|
||||
try:
|
||||
canonical_provider_id = ModelProviderID(provider.provider)
|
||||
except ValueError:
|
||||
return ""
|
||||
|
||||
if canonical_provider_id.plugin_id != provider.plugin_id:
|
||||
return ""
|
||||
if canonical_provider_id.provider_name != provider.provider:
|
||||
return ""
|
||||
|
||||
return provider.provider
|
||||
|
||||
@classmethod
|
||||
def _to_provider_entity(cls, provider: PluginModelProviderEntity) -> ProviderEntity:
|
||||
declaration = provider.declaration.model_copy(deep=True)
|
||||
declaration.provider = f"{provider.plugin_id}/{provider.provider}"
|
||||
declaration.provider_name = cls._get_provider_short_name_alias(provider)
|
||||
return declaration
|
||||
|
||||
@classmethod
|
||||
def _load_cached_plugin_model_providers(cls, tenant_id: str) -> tuple[ProviderEntity, ...] | None:
|
||||
cache_key = cls._get_plugin_model_providers_cache_key(tenant_id)
|
||||
try:
|
||||
cached_providers = redis_client.get(cache_key)
|
||||
except (RedisError, RuntimeError):
|
||||
logger.warning("Failed to read cached plugin model providers for tenant %s.", tenant_id, exc_info=True)
|
||||
return None
|
||||
|
||||
if not cached_providers:
|
||||
return None
|
||||
|
||||
try:
|
||||
return tuple(_provider_entities_adapter.validate_json(cached_providers))
|
||||
except (TypeError, ValueError, ValidationError):
|
||||
logger.warning(
|
||||
"Invalid cached plugin model providers for tenant %s; deleting cache.", tenant_id, exc_info=True
|
||||
)
|
||||
cls.invalidate_plugin_model_providers_cache(tenant_id)
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _store_cached_plugin_model_providers(cls, tenant_id: str, providers: Sequence[ProviderEntity]) -> None:
|
||||
cache_key = cls._get_plugin_model_providers_cache_key(tenant_id)
|
||||
try:
|
||||
payload = _provider_entities_adapter.dump_json(list(providers)).decode("utf-8")
|
||||
redis_client.setex(cache_key, dify_config.PLUGIN_MODEL_PROVIDERS_CACHE_TTL, payload)
|
||||
except (RedisError, RuntimeError):
|
||||
logger.warning("Failed to cache plugin model providers for tenant %s.", tenant_id, exc_info=True)
|
||||
|
||||
@classmethod
|
||||
def invalidate_plugin_model_providers_cache(cls, tenant_id: str) -> None:
|
||||
"""Delete the tenant-scoped plugin model provider list cache."""
|
||||
try:
|
||||
redis_client.delete(cls._get_plugin_model_providers_cache_key(tenant_id))
|
||||
except (RedisError, RuntimeError):
|
||||
logger.warning("Failed to invalidate plugin model providers cache for tenant %s.", tenant_id, exc_info=True)
|
||||
|
||||
@classmethod
|
||||
def fetch_plugin_model_providers(
|
||||
cls, *, tenant_id: str, client: PluginModelClient | None = None
|
||||
) -> Sequence[ProviderEntity]:
|
||||
"""
|
||||
Fetch plugin model providers through the tenant-scoped plugin cache.
|
||||
|
||||
Plugin daemon provider discovery and plugin lifecycle cache invalidation
|
||||
are intentionally owned by this service so tenant isolation and cache
|
||||
expiry are handled in one place.
|
||||
"""
|
||||
cached_providers = cls._load_cached_plugin_model_providers(tenant_id)
|
||||
if cached_providers is not None:
|
||||
return cached_providers
|
||||
|
||||
model_client = client or PluginModelClient()
|
||||
providers = tuple(
|
||||
cls._to_provider_entity(provider) for provider in model_client.fetch_model_providers(tenant_id)
|
||||
)
|
||||
cls._store_cached_plugin_model_providers(tenant_id, providers)
|
||||
return providers
|
||||
|
||||
@staticmethod
|
||||
def fetch_latest_plugin_version(plugin_ids: Sequence[str]) -> Mapping[str, LatestPluginCache | None]:
|
||||
@ -248,12 +358,18 @@ class PluginService:
|
||||
Fetch plugin installation tasks
|
||||
"""
|
||||
manager = PluginInstaller()
|
||||
return manager.fetch_plugin_installation_tasks(tenant_id, page, page_size)
|
||||
tasks = manager.fetch_plugin_installation_tasks(tenant_id, page, page_size)
|
||||
if any(task.status in PluginService.PLUGIN_INSTALL_TASK_TERMINAL_STATUSES for task in tasks):
|
||||
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
|
||||
return tasks
|
||||
|
||||
@staticmethod
|
||||
def fetch_install_task(tenant_id: str, task_id: str) -> PluginInstallTask:
|
||||
manager = PluginInstaller()
|
||||
return manager.fetch_plugin_installation_task(tenant_id, task_id)
|
||||
task = manager.fetch_plugin_installation_task(tenant_id, task_id)
|
||||
if task.status in PluginService.PLUGIN_INSTALL_TASK_TERMINAL_STATUSES:
|
||||
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
|
||||
return task
|
||||
|
||||
@staticmethod
|
||||
def delete_install_task(tenant_id: str, task_id: str) -> bool:
|
||||
@ -315,7 +431,7 @@ class PluginService:
|
||||
# check if the plugin is available to install
|
||||
PluginService._check_plugin_installation_scope(response.verification)
|
||||
|
||||
return manager.upgrade_plugin(
|
||||
result = manager.upgrade_plugin(
|
||||
tenant_id,
|
||||
original_plugin_unique_identifier,
|
||||
new_plugin_unique_identifier,
|
||||
@ -324,6 +440,8 @@ class PluginService:
|
||||
"plugin_unique_identifier": new_plugin_unique_identifier,
|
||||
},
|
||||
)
|
||||
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def upgrade_plugin_with_github(
|
||||
@ -339,7 +457,7 @@ class PluginService:
|
||||
"""
|
||||
PluginService._check_marketplace_only_permission()
|
||||
manager = PluginInstaller()
|
||||
return manager.upgrade_plugin(
|
||||
result = manager.upgrade_plugin(
|
||||
tenant_id,
|
||||
original_plugin_unique_identifier,
|
||||
new_plugin_unique_identifier,
|
||||
@ -350,6 +468,8 @@ class PluginService:
|
||||
"package": package,
|
||||
},
|
||||
)
|
||||
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def upload_pkg(tenant_id: str, pkg: bytes, verify_signature: bool = False) -> PluginDecodeResponse:
|
||||
@ -415,12 +535,14 @@ class PluginService:
|
||||
resp = manager.decode_plugin_from_identifier(tenant_id, plugin_unique_identifier)
|
||||
PluginService._check_plugin_installation_scope(resp.verification)
|
||||
|
||||
return manager.install_from_identifiers(
|
||||
result = manager.install_from_identifiers(
|
||||
tenant_id,
|
||||
plugin_unique_identifiers,
|
||||
PluginInstallationSource.Package,
|
||||
[{}],
|
||||
)
|
||||
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def install_from_github(tenant_id: str, plugin_unique_identifier: str, repo: str, version: str, package: str):
|
||||
@ -434,7 +556,7 @@ class PluginService:
|
||||
plugin_decode_response = manager.decode_plugin_from_identifier(tenant_id, plugin_unique_identifier)
|
||||
PluginService._check_plugin_installation_scope(plugin_decode_response.verification)
|
||||
|
||||
return manager.install_from_identifiers(
|
||||
result = manager.install_from_identifiers(
|
||||
tenant_id,
|
||||
[plugin_unique_identifier],
|
||||
PluginInstallationSource.Github,
|
||||
@ -446,6 +568,8 @@ class PluginService:
|
||||
}
|
||||
],
|
||||
)
|
||||
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def fetch_marketplace_pkg(tenant_id: str, plugin_unique_identifier: str) -> PluginDeclaration:
|
||||
@ -513,12 +637,14 @@ class PluginService:
|
||||
actual_plugin_unique_identifiers.append(response.unique_identifier)
|
||||
metas.append({"plugin_unique_identifier": response.unique_identifier})
|
||||
|
||||
return manager.install_from_identifiers(
|
||||
result = manager.install_from_identifiers(
|
||||
tenant_id,
|
||||
actual_plugin_unique_identifiers,
|
||||
PluginInstallationSource.Marketplace,
|
||||
metas,
|
||||
)
|
||||
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def uninstall(tenant_id: str, plugin_installation_id: str) -> bool:
|
||||
@ -529,7 +655,10 @@ class PluginService:
|
||||
plugin = next((p for p in plugins if p.installation_id == plugin_installation_id), None)
|
||||
|
||||
if not plugin:
|
||||
return manager.uninstall(tenant_id, plugin_installation_id)
|
||||
result = manager.uninstall(tenant_id, plugin_installation_id)
|
||||
if result:
|
||||
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
|
||||
return result
|
||||
|
||||
if dify_config.ENTERPRISE_ENABLED:
|
||||
PluginManagerService.try_pre_uninstall_plugin(
|
||||
@ -559,37 +688,39 @@ class PluginService:
|
||||
|
||||
if not credential_ids:
|
||||
logger.info("No credentials found for plugin: %s", plugin_id)
|
||||
return manager.uninstall(tenant_id, plugin_installation_id)
|
||||
else:
|
||||
provider_ids = session.scalars(
|
||||
select(Provider.id).where(
|
||||
Provider.tenant_id == tenant_id,
|
||||
Provider.provider_name.like(f"{plugin_id}/%"),
|
||||
Provider.credential_id.in_(credential_ids),
|
||||
)
|
||||
).all()
|
||||
|
||||
provider_ids = session.scalars(
|
||||
select(Provider.id).where(
|
||||
Provider.tenant_id == tenant_id,
|
||||
Provider.provider_name.like(f"{plugin_id}/%"),
|
||||
Provider.credential_id.in_(credential_ids),
|
||||
session.execute(update(Provider).where(Provider.id.in_(provider_ids)).values(credential_id=None))
|
||||
|
||||
for provider_id in provider_ids:
|
||||
ProviderCredentialsCache(
|
||||
tenant_id=tenant_id,
|
||||
identity_id=provider_id,
|
||||
cache_type=ProviderCredentialsCacheType.PROVIDER,
|
||||
).delete()
|
||||
|
||||
session.execute(
|
||||
delete(ProviderCredential).where(
|
||||
ProviderCredential.id.in_(credential_ids),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
|
||||
session.execute(update(Provider).where(Provider.id.in_(provider_ids)).values(credential_id=None))
|
||||
|
||||
for provider_id in provider_ids:
|
||||
ProviderCredentialsCache(
|
||||
tenant_id=tenant_id,
|
||||
identity_id=provider_id,
|
||||
cache_type=ProviderCredentialsCacheType.PROVIDER,
|
||||
).delete()
|
||||
|
||||
session.execute(
|
||||
delete(ProviderCredential).where(
|
||||
ProviderCredential.id.in_(credential_ids),
|
||||
logger.info(
|
||||
"Completed deleting credentials and cleaning provider associations for plugin: %s",
|
||||
plugin_id,
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Completed deleting credentials and cleaning provider associations for plugin: %s",
|
||||
plugin_id,
|
||||
)
|
||||
|
||||
return manager.uninstall(tenant_id, plugin_installation_id)
|
||||
result = manager.uninstall(tenant_id, plugin_installation_id)
|
||||
if result:
|
||||
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def check_tools_existence(tenant_id: str, provider_ids: Sequence[GenericProviderID]) -> Sequence[bool]:
|
||||
@ -16,6 +16,7 @@ from core.plugin.entities.request import (
|
||||
TriggerSubscriptionResponse,
|
||||
)
|
||||
from core.plugin.impl.trigger import PluginTriggerClient
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from core.trigger.entities.api_entities import EventApiEntity, TriggerProviderApiEntity
|
||||
from core.trigger.entities.entities import (
|
||||
EventEntity,
|
||||
@ -30,7 +31,6 @@ from core.trigger.entities.entities import (
|
||||
)
|
||||
from core.trigger.errors import TriggerProviderCredentialValidationError
|
||||
from models.provider_ids import TriggerProviderID
|
||||
from services.plugin.plugin_service import PluginService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
from typing import Union
|
||||
from typing import Any, Union
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
from core.rag.entities import RerankingModelConfig, WeightedScoreConfig
|
||||
from core.rag.index_processor.index_processor_base import SummaryIndexSettingDict
|
||||
@ -101,3 +101,14 @@ class KnowledgeIndexNodeData(BaseNodeData):
|
||||
index_chunk_variable_selector: list[str]
|
||||
indexing_technique: str | None = None
|
||||
summary_index_setting: SummaryIndexSettingDict | None = None
|
||||
|
||||
@field_validator("summary_index_setting", mode="before")
|
||||
@classmethod
|
||||
def normalize_summary_index_setting(cls, v: Any) -> Any:
|
||||
"""Treat dicts with enable=None (or missing enable) as None (#36233)."""
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, dict):
|
||||
if v.get("enable") is None:
|
||||
return None
|
||||
return v
|
||||
|
||||
@ -492,8 +492,8 @@ class App(Base):
|
||||
|
||||
@property
|
||||
def deleted_tools(self) -> list[DeletedToolInfo]:
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from core.tools.tool_manager import ToolManager, ToolProviderType
|
||||
from services.plugin.plugin_service import PluginService
|
||||
|
||||
# get agent mode tools
|
||||
app_model_config = self.app_model_config
|
||||
|
||||
@ -14,13 +14,13 @@ from core.helper.provider_cache import NoOpProviderCredentialCache
|
||||
from core.plugin.entities.plugin_daemon import CredentialType
|
||||
from core.plugin.impl.datasource import PluginDatasourceManager
|
||||
from core.plugin.impl.oauth import OAuthHandler
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from core.tools.utils.encryption import ProviderConfigCache, ProviderConfigEncrypter, create_provider_encrypter
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_redis import redis_client
|
||||
from graphon.model_runtime.entities.provider_entities import FormType
|
||||
from models.oauth import DatasourceOauthParamConfig, DatasourceOauthTenantParamConfig, DatasourceProvider
|
||||
from models.provider_ids import DatasourceProviderID
|
||||
from services.plugin.plugin_service import PluginService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ -22,6 +22,7 @@ from core.helper import marketplace
|
||||
from core.plugin.entities.plugin import PluginInstallationSource
|
||||
from core.plugin.entities.plugin_daemon import PluginInstallTaskStatus
|
||||
from core.plugin.impl.plugin import PluginInstaller
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from core.tools.entities.tool_entities import ToolProviderType
|
||||
from extensions.ext_database import db
|
||||
from models.account import Tenant
|
||||
@ -29,7 +30,6 @@ from models.model import App, AppMode, AppModelConfig
|
||||
from models.provider_ids import ModelProviderID, ToolProviderID
|
||||
from models.tools import BuiltinToolProvider
|
||||
from models.workflow import Workflow
|
||||
from services.plugin.plugin_service import PluginService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -389,17 +389,19 @@ class PluginMigration:
|
||||
for plugin_id in batch_plugin_ids
|
||||
if plugin_id not in installed_plugins_ids and plugin_id in plugins["plugins"]
|
||||
]
|
||||
manager.install_from_identifiers(
|
||||
tenant_id,
|
||||
batch_plugin_identifiers,
|
||||
PluginInstallationSource.Marketplace,
|
||||
metas=[
|
||||
{
|
||||
"plugin_unique_identifier": identifier,
|
||||
}
|
||||
for identifier in batch_plugin_identifiers
|
||||
],
|
||||
)
|
||||
if batch_plugin_identifiers:
|
||||
manager.install_from_identifiers(
|
||||
tenant_id,
|
||||
batch_plugin_identifiers,
|
||||
PluginInstallationSource.Marketplace,
|
||||
metas=[
|
||||
{
|
||||
"plugin_unique_identifier": identifier,
|
||||
}
|
||||
for identifier in batch_plugin_identifiers
|
||||
],
|
||||
)
|
||||
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
|
||||
|
||||
with open(extracted_plugins) as f:
|
||||
"""
|
||||
@ -595,6 +597,7 @@ class PluginMigration:
|
||||
for identifier in batch_plugin_identifiers
|
||||
],
|
||||
)
|
||||
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
|
||||
except Exception:
|
||||
# add to failed
|
||||
failed.extend(batch_plugin_identifiers)
|
||||
@ -609,6 +612,7 @@ class PluginMigration:
|
||||
while not done:
|
||||
status = manager.fetch_plugin_installation_task(tenant_id, task_id)
|
||||
if status.status in [PluginInstallTaskStatus.Failed, PluginInstallTaskStatus.Success]:
|
||||
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
|
||||
for plugin in status.plugins:
|
||||
if plugin.status == PluginInstallTaskStatus.Success:
|
||||
success.append(reverse_map[plugin.plugin_unique_identifier])
|
||||
|
||||
@ -78,9 +78,9 @@ class CheckDependenciesPendingData(BaseModel):
|
||||
class RagPipelineDslService:
|
||||
"""Import, export, and inspect RAG pipeline DSL using the caller-owned session.
|
||||
|
||||
Controllers wrap this service in a SQLAlchemy transaction context, so methods must only flush interim changes when
|
||||
generated IDs are needed. Committing inside the service would close the caller's transaction and break later work in
|
||||
the same context manager.
|
||||
Callers pass a plain ``Session`` (not wrapped in ``.begin()``) and are responsible for calling
|
||||
``session.commit()`` on success or ``session.rollback()`` on failure. Methods here only flush
|
||||
when generated IDs are needed mid-operation; they never commit or rollback.
|
||||
"""
|
||||
|
||||
def __init__(self, session: Session):
|
||||
|
||||
@ -12,6 +12,7 @@ from sqlalchemy import select
|
||||
from configs import dify_config
|
||||
from constants import DOCUMENT_EXTENSIONS
|
||||
from core.plugin.impl.plugin import PluginInstaller
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType
|
||||
from core.rag.retrieval.retrieval_methods import RetrievalMethod
|
||||
from extensions.ext_database import db
|
||||
@ -22,7 +23,6 @@ from models.model import UploadFile
|
||||
from models.workflow import Workflow, WorkflowType
|
||||
from services.entities.knowledge_entities.rag_pipeline_entities import KnowledgeConfiguration, RetrievalSetting
|
||||
from services.plugin.plugin_migration import PluginMigration
|
||||
from services.plugin.plugin_service import PluginService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ -13,6 +13,7 @@ from core.helper.name_generator import generate_incremental_name
|
||||
from core.helper.position_helper import is_filtered
|
||||
from core.helper.provider_cache import NoOpProviderCredentialCache, ToolProviderCredentialsCache
|
||||
from core.plugin.entities.plugin_daemon import CredentialType
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from core.tools.builtin_tool.provider import BuiltinToolProviderController
|
||||
from core.tools.builtin_tool.providers._positions import BuiltinToolProviderSort
|
||||
from core.tools.entities.api_entities import (
|
||||
@ -31,7 +32,6 @@ from extensions.ext_database import db
|
||||
from extensions.ext_redis import redis_client
|
||||
from models.provider_ids import ToolProviderID
|
||||
from models.tools import BuiltinToolProvider, ToolOAuthSystemClient, ToolOAuthTenantClient
|
||||
from services.plugin.plugin_service import PluginService
|
||||
from services.tools.tools_transform_service import ToolTransformService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -9,6 +9,7 @@ from configs import dify_config
|
||||
from core.helper.provider_cache import ToolProviderCredentialsCache
|
||||
from core.mcp.types import Tool as MCPTool
|
||||
from core.plugin.entities.plugin_daemon import CredentialType, PluginDatasourceProviderEntity
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from core.tools.__base.tool import Tool
|
||||
from core.tools.__base.tool_runtime import ToolRuntime
|
||||
from core.tools.builtin_tool.provider import BuiltinToolProviderController
|
||||
@ -27,7 +28,6 @@ from core.tools.utils.encryption import create_provider_encrypter, create_tool_p
|
||||
from core.tools.workflow_as_tool.provider import WorkflowToolProviderController
|
||||
from core.tools.workflow_as_tool.tool import WorkflowTool
|
||||
from models.tools import ApiToolProvider, BuiltinToolProvider, MCPToolProvider, WorkflowToolProvider
|
||||
from services.plugin.plugin_service import PluginService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ from core.helper.provider_cache import NoOpProviderCredentialCache
|
||||
from core.helper.provider_encryption import ProviderConfigEncrypter, create_provider_encrypter
|
||||
from core.plugin.entities.plugin_daemon import CredentialType
|
||||
from core.plugin.impl.oauth import OAuthHandler
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from core.tools.utils.system_encryption import decrypt_system_params
|
||||
from core.trigger.entities.api_entities import (
|
||||
TriggerProviderApiEntity,
|
||||
@ -37,7 +38,6 @@ from models.trigger import (
|
||||
TriggerSubscription,
|
||||
WorkflowPluginTrigger,
|
||||
)
|
||||
from services.plugin.plugin_service import PluginService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ -6,11 +6,11 @@ from typing import Any, TypedDict
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from graphon.enums import WorkflowExecutionStatus
|
||||
from models import Account, App, EndUser, TenantAccountJoin, WorkflowAppLog, WorkflowArchiveLog, WorkflowRun
|
||||
from models.enums import AppTriggerType, CreatorUserRole
|
||||
from models.trigger import WorkflowTriggerLog
|
||||
from services.plugin.plugin_service import PluginService
|
||||
from services.workflow.entities import TriggerMetadata
|
||||
|
||||
|
||||
|
||||
@ -9,9 +9,9 @@ from celery import shared_task
|
||||
from core.plugin.entities.marketplace import MarketplacePluginSnapshot
|
||||
from core.plugin.entities.plugin import PluginInstallationSource
|
||||
from core.plugin.impl.plugin import PluginInstaller
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from extensions.ext_redis import redis_client
|
||||
from models.account import TenantPluginAutoUpgradeStrategy
|
||||
from services.plugin.plugin_service import PluginService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Tests for services.plugin.plugin_service.PluginService.
|
||||
"""Tests for core.plugin.plugin_service.PluginService.
|
||||
|
||||
Covers: version caching with Redis, install permission/scope gates,
|
||||
icon URL construction, asset retrieval with MIME guessing, plugin
|
||||
@ -17,11 +17,11 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from core.plugin.entities.plugin import PluginInstallationSource
|
||||
from core.plugin.entities.plugin_daemon import PluginVerification
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from models import ProviderType
|
||||
from models.provider import Provider, ProviderCredential, TenantPreferredModelProvider
|
||||
from services.errors.plugin import PluginInstallationForbiddenError
|
||||
from services.feature_service import PluginInstallationScope
|
||||
from services.plugin.plugin_service import PluginService
|
||||
|
||||
|
||||
def _make_features(
|
||||
@ -35,8 +35,8 @@ def _make_features(
|
||||
|
||||
|
||||
class TestFetchLatestPluginVersion:
|
||||
@patch("services.plugin.plugin_service.marketplace")
|
||||
@patch("services.plugin.plugin_service.redis_client")
|
||||
@patch("core.plugin.plugin_service.marketplace")
|
||||
@patch("core.plugin.plugin_service.redis_client")
|
||||
def test_returns_cached_version(self, mock_redis, mock_marketplace):
|
||||
cached_json = PluginService.LatestPluginCache(
|
||||
plugin_id="p1",
|
||||
@ -53,8 +53,8 @@ class TestFetchLatestPluginVersion:
|
||||
assert result["p1"].version == "1.0.0"
|
||||
mock_marketplace.batch_fetch_plugin_manifests.assert_not_called()
|
||||
|
||||
@patch("services.plugin.plugin_service.marketplace")
|
||||
@patch("services.plugin.plugin_service.redis_client")
|
||||
@patch("core.plugin.plugin_service.marketplace")
|
||||
@patch("core.plugin.plugin_service.redis_client")
|
||||
def test_fetches_from_marketplace_on_cache_miss(self, mock_redis, mock_marketplace):
|
||||
mock_redis.get.return_value = None
|
||||
manifest = MagicMock()
|
||||
@ -71,8 +71,8 @@ class TestFetchLatestPluginVersion:
|
||||
assert result["p1"].version == "2.0.0"
|
||||
mock_redis.setex.assert_called_once()
|
||||
|
||||
@patch("services.plugin.plugin_service.marketplace")
|
||||
@patch("services.plugin.plugin_service.redis_client")
|
||||
@patch("core.plugin.plugin_service.marketplace")
|
||||
@patch("core.plugin.plugin_service.redis_client")
|
||||
def test_returns_none_for_unknown_plugin(self, mock_redis, mock_marketplace):
|
||||
mock_redis.get.return_value = None
|
||||
mock_marketplace.batch_fetch_plugin_manifests.return_value = []
|
||||
@ -81,8 +81,8 @@ class TestFetchLatestPluginVersion:
|
||||
|
||||
assert result["unknown"] is None
|
||||
|
||||
@patch("services.plugin.plugin_service.marketplace")
|
||||
@patch("services.plugin.plugin_service.redis_client")
|
||||
@patch("core.plugin.plugin_service.marketplace")
|
||||
@patch("core.plugin.plugin_service.redis_client")
|
||||
def test_handles_marketplace_exception_gracefully(self, mock_redis, mock_marketplace):
|
||||
mock_redis.get.return_value = None
|
||||
mock_marketplace.batch_fetch_plugin_manifests.side_effect = RuntimeError("network error")
|
||||
@ -93,14 +93,14 @@ class TestFetchLatestPluginVersion:
|
||||
|
||||
|
||||
class TestCheckMarketplaceOnlyPermission:
|
||||
@patch("services.plugin.plugin_service.FeatureService")
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_raises_when_restricted(self, mock_fs):
|
||||
mock_fs.get_system_features.return_value = _make_features(restrict_to_marketplace=True)
|
||||
|
||||
with pytest.raises(PluginInstallationForbiddenError):
|
||||
PluginService._check_marketplace_only_permission()
|
||||
|
||||
@patch("services.plugin.plugin_service.FeatureService")
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_passes_when_not_restricted(self, mock_fs):
|
||||
mock_fs.get_system_features.return_value = _make_features(restrict_to_marketplace=False)
|
||||
|
||||
@ -108,7 +108,7 @@ class TestCheckMarketplaceOnlyPermission:
|
||||
|
||||
|
||||
class TestCheckPluginInstallationScope:
|
||||
@patch("services.plugin.plugin_service.FeatureService")
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_official_only_allows_langgenius(self, mock_fs):
|
||||
mock_fs.get_system_features.return_value = _make_features(scope=PluginInstallationScope.OFFICIAL_ONLY)
|
||||
verification = MagicMock()
|
||||
@ -116,14 +116,14 @@ class TestCheckPluginInstallationScope:
|
||||
|
||||
PluginService._check_plugin_installation_scope(verification) # should not raise
|
||||
|
||||
@patch("services.plugin.plugin_service.FeatureService")
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_official_only_rejects_third_party(self, mock_fs):
|
||||
mock_fs.get_system_features.return_value = _make_features(scope=PluginInstallationScope.OFFICIAL_ONLY)
|
||||
|
||||
with pytest.raises(PluginInstallationForbiddenError):
|
||||
PluginService._check_plugin_installation_scope(None)
|
||||
|
||||
@patch("services.plugin.plugin_service.FeatureService")
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_official_and_partners_allows_partner(self, mock_fs):
|
||||
mock_fs.get_system_features.return_value = _make_features(
|
||||
scope=PluginInstallationScope.OFFICIAL_AND_SPECIFIC_PARTNERS
|
||||
@ -133,7 +133,7 @@ class TestCheckPluginInstallationScope:
|
||||
|
||||
PluginService._check_plugin_installation_scope(verification) # should not raise
|
||||
|
||||
@patch("services.plugin.plugin_service.FeatureService")
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_official_and_partners_rejects_none(self, mock_fs):
|
||||
mock_fs.get_system_features.return_value = _make_features(
|
||||
scope=PluginInstallationScope.OFFICIAL_AND_SPECIFIC_PARTNERS
|
||||
@ -142,7 +142,7 @@ class TestCheckPluginInstallationScope:
|
||||
with pytest.raises(PluginInstallationForbiddenError):
|
||||
PluginService._check_plugin_installation_scope(None)
|
||||
|
||||
@patch("services.plugin.plugin_service.FeatureService")
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_none_scope_always_raises(self, mock_fs):
|
||||
mock_fs.get_system_features.return_value = _make_features(scope=PluginInstallationScope.NONE)
|
||||
verification = MagicMock()
|
||||
@ -151,7 +151,7 @@ class TestCheckPluginInstallationScope:
|
||||
with pytest.raises(PluginInstallationForbiddenError):
|
||||
PluginService._check_plugin_installation_scope(verification)
|
||||
|
||||
@patch("services.plugin.plugin_service.FeatureService")
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_all_scope_passes_any(self, mock_fs):
|
||||
mock_fs.get_system_features.return_value = _make_features(scope=PluginInstallationScope.ALL)
|
||||
|
||||
@ -159,7 +159,7 @@ class TestCheckPluginInstallationScope:
|
||||
|
||||
|
||||
class TestGetPluginIconUrl:
|
||||
@patch("services.plugin.plugin_service.dify_config")
|
||||
@patch("core.plugin.plugin_service.dify_config")
|
||||
def test_constructs_url_with_params(self, mock_config):
|
||||
mock_config.CONSOLE_API_URL = "https://console.example.com"
|
||||
|
||||
@ -171,7 +171,7 @@ class TestGetPluginIconUrl:
|
||||
|
||||
|
||||
class TestGetAsset:
|
||||
@patch("services.plugin.plugin_service.PluginAssetManager")
|
||||
@patch("core.plugin.plugin_service.PluginAssetManager")
|
||||
def test_returns_bytes_and_guessed_mime(self, mock_asset_cls):
|
||||
mock_asset_cls.return_value.fetch_asset.return_value = b"<svg/>"
|
||||
|
||||
@ -180,7 +180,7 @@ class TestGetAsset:
|
||||
assert data == b"<svg/>"
|
||||
assert "svg" in mime
|
||||
|
||||
@patch("services.plugin.plugin_service.PluginAssetManager")
|
||||
@patch("core.plugin.plugin_service.PluginAssetManager")
|
||||
def test_fallback_to_octet_stream_for_unknown(self, mock_asset_cls):
|
||||
mock_asset_cls.return_value.fetch_asset.return_value = b"\x00"
|
||||
|
||||
@ -190,13 +190,13 @@ class TestGetAsset:
|
||||
|
||||
|
||||
class TestIsPluginVerified:
|
||||
@patch("services.plugin.plugin_service.PluginInstaller")
|
||||
@patch("core.plugin.plugin_service.PluginInstaller")
|
||||
def test_returns_true_when_verified(self, mock_installer_cls):
|
||||
mock_installer_cls.return_value.fetch_plugin_manifest.return_value.verified = True
|
||||
|
||||
assert PluginService.is_plugin_verified("t1", "uid-1") is True
|
||||
|
||||
@patch("services.plugin.plugin_service.PluginInstaller")
|
||||
@patch("core.plugin.plugin_service.PluginInstaller")
|
||||
def test_returns_false_on_exception(self, mock_installer_cls):
|
||||
mock_installer_cls.return_value.fetch_plugin_manifest.side_effect = RuntimeError("not found")
|
||||
|
||||
@ -204,24 +204,24 @@ class TestIsPluginVerified:
|
||||
|
||||
|
||||
class TestUpgradePluginWithMarketplace:
|
||||
@patch("services.plugin.plugin_service.dify_config")
|
||||
@patch("core.plugin.plugin_service.dify_config")
|
||||
def test_raises_when_marketplace_disabled(self, mock_config):
|
||||
mock_config.MARKETPLACE_ENABLED = False
|
||||
|
||||
with pytest.raises(ValueError, match="marketplace is not enabled"):
|
||||
PluginService.upgrade_plugin_with_marketplace("t1", "old-uid", "new-uid")
|
||||
|
||||
@patch("services.plugin.plugin_service.dify_config")
|
||||
@patch("core.plugin.plugin_service.dify_config")
|
||||
def test_raises_when_same_identifier(self, mock_config):
|
||||
mock_config.MARKETPLACE_ENABLED = True
|
||||
|
||||
with pytest.raises(ValueError, match="same plugin"):
|
||||
PluginService.upgrade_plugin_with_marketplace("t1", "same-uid", "same-uid")
|
||||
|
||||
@patch("services.plugin.plugin_service.marketplace")
|
||||
@patch("services.plugin.plugin_service.FeatureService")
|
||||
@patch("services.plugin.plugin_service.PluginInstaller")
|
||||
@patch("services.plugin.plugin_service.dify_config")
|
||||
@patch("core.plugin.plugin_service.marketplace")
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
@patch("core.plugin.plugin_service.PluginInstaller")
|
||||
@patch("core.plugin.plugin_service.dify_config")
|
||||
def test_skips_download_when_already_installed(self, mock_config, mock_installer_cls, mock_fs, mock_marketplace):
|
||||
mock_config.MARKETPLACE_ENABLED = True
|
||||
mock_fs.get_system_features.return_value = _make_features()
|
||||
@ -234,10 +234,10 @@ class TestUpgradePluginWithMarketplace:
|
||||
mock_marketplace.record_install_plugin_event.assert_called_once_with("new-uid")
|
||||
installer.upgrade_plugin.assert_called_once()
|
||||
|
||||
@patch("services.plugin.plugin_service.download_plugin_pkg")
|
||||
@patch("services.plugin.plugin_service.FeatureService")
|
||||
@patch("services.plugin.plugin_service.PluginInstaller")
|
||||
@patch("services.plugin.plugin_service.dify_config")
|
||||
@patch("core.plugin.plugin_service.download_plugin_pkg")
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
@patch("core.plugin.plugin_service.PluginInstaller")
|
||||
@patch("core.plugin.plugin_service.dify_config")
|
||||
def test_downloads_when_not_installed(self, mock_config, mock_installer_cls, mock_fs, mock_download):
|
||||
mock_config.MARKETPLACE_ENABLED = True
|
||||
mock_fs.get_system_features.return_value = _make_features()
|
||||
@ -256,8 +256,8 @@ class TestUpgradePluginWithMarketplace:
|
||||
|
||||
|
||||
class TestUpgradePluginWithGithub:
|
||||
@patch("services.plugin.plugin_service.FeatureService")
|
||||
@patch("services.plugin.plugin_service.PluginInstaller")
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
@patch("core.plugin.plugin_service.PluginInstaller")
|
||||
def test_checks_marketplace_permission_and_delegates(self, mock_installer_cls, mock_fs):
|
||||
mock_fs.get_system_features.return_value = _make_features()
|
||||
installer = mock_installer_cls.return_value
|
||||
@ -271,8 +271,8 @@ class TestUpgradePluginWithGithub:
|
||||
|
||||
|
||||
class TestUploadPkg:
|
||||
@patch("services.plugin.plugin_service.FeatureService")
|
||||
@patch("services.plugin.plugin_service.PluginInstaller")
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
@patch("core.plugin.plugin_service.PluginInstaller")
|
||||
def test_runs_permission_and_scope_checks(self, mock_installer_cls, mock_fs):
|
||||
mock_fs.get_system_features.return_value = _make_features()
|
||||
upload_resp = MagicMock()
|
||||
@ -285,17 +285,17 @@ class TestUploadPkg:
|
||||
|
||||
|
||||
class TestInstallFromMarketplacePkg:
|
||||
@patch("services.plugin.plugin_service.dify_config")
|
||||
@patch("core.plugin.plugin_service.dify_config")
|
||||
def test_raises_when_marketplace_disabled(self, mock_config):
|
||||
mock_config.MARKETPLACE_ENABLED = False
|
||||
|
||||
with pytest.raises(ValueError, match="marketplace is not enabled"):
|
||||
PluginService.install_from_marketplace_pkg("t1", ["uid-1"])
|
||||
|
||||
@patch("services.plugin.plugin_service.download_plugin_pkg")
|
||||
@patch("services.plugin.plugin_service.FeatureService")
|
||||
@patch("services.plugin.plugin_service.PluginInstaller")
|
||||
@patch("services.plugin.plugin_service.dify_config")
|
||||
@patch("core.plugin.plugin_service.download_plugin_pkg")
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
@patch("core.plugin.plugin_service.PluginInstaller")
|
||||
@patch("core.plugin.plugin_service.dify_config")
|
||||
def test_downloads_when_not_cached(self, mock_config, mock_installer_cls, mock_fs, mock_download):
|
||||
mock_config.MARKETPLACE_ENABLED = True
|
||||
mock_fs.get_system_features.return_value = _make_features()
|
||||
@ -315,9 +315,9 @@ class TestInstallFromMarketplacePkg:
|
||||
call_args = installer.install_from_identifiers.call_args[0]
|
||||
assert call_args[1] == ["resolved-uid"]
|
||||
|
||||
@patch("services.plugin.plugin_service.FeatureService")
|
||||
@patch("services.plugin.plugin_service.PluginInstaller")
|
||||
@patch("services.plugin.plugin_service.dify_config")
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
@patch("core.plugin.plugin_service.PluginInstaller")
|
||||
@patch("core.plugin.plugin_service.dify_config")
|
||||
def test_uses_cached_when_already_downloaded(self, mock_config, mock_installer_cls, mock_fs):
|
||||
mock_config.MARKETPLACE_ENABLED = True
|
||||
mock_fs.get_system_features.return_value = _make_features()
|
||||
@ -336,7 +336,7 @@ class TestInstallFromMarketplacePkg:
|
||||
|
||||
|
||||
class TestUninstall:
|
||||
@patch("services.plugin.plugin_service.PluginInstaller")
|
||||
@patch("core.plugin.plugin_service.PluginInstaller")
|
||||
def test_direct_uninstall_when_plugin_not_found(self, mock_installer_cls):
|
||||
installer = mock_installer_cls.return_value
|
||||
installer.list_plugins.return_value = []
|
||||
@ -347,7 +347,7 @@ class TestUninstall:
|
||||
assert result is True
|
||||
installer.uninstall.assert_called_once_with("t1", "install-1")
|
||||
|
||||
@patch("services.plugin.plugin_service.PluginInstaller")
|
||||
@patch("core.plugin.plugin_service.PluginInstaller")
|
||||
def test_cleans_credentials_when_plugin_found(
|
||||
self, mock_installer_cls, flask_app_with_containers: Flask, db_session_with_containers: Session
|
||||
):
|
||||
@ -389,7 +389,7 @@ class TestUninstall:
|
||||
installer.list_plugins.return_value = [plugin]
|
||||
installer.uninstall.return_value = True
|
||||
|
||||
with patch("services.plugin.plugin_service.dify_config") as mock_config:
|
||||
with patch("core.plugin.plugin_service.dify_config") as mock_config:
|
||||
mock_config.ENTERPRISE_ENABLED = False
|
||||
result = PluginService.uninstall(tenant_id, "install-1")
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ import pytest
|
||||
from faker import Faker
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from core.tools.__base.tool import Tool
|
||||
from core.tools.entities.api_entities import ToolApiEntity, ToolProviderApiEntity
|
||||
from core.tools.entities.common_entities import I18nObject
|
||||
@ -20,7 +21,6 @@ from core.tools.entities.tool_entities import (
|
||||
ToolProviderType,
|
||||
)
|
||||
from models.tools import ApiToolProvider, BuiltinToolProvider, MCPToolProvider, WorkflowToolProvider
|
||||
from services.plugin.plugin_service import PluginService
|
||||
from services.tools.tools_transform_service import ToolTransformService
|
||||
|
||||
|
||||
@ -31,7 +31,7 @@ class TestToolTransformService:
|
||||
def mock_external_service_dependencies(self):
|
||||
"""Mock setup for external service dependencies."""
|
||||
with patch("services.tools.tools_transform_service.dify_config") as mock_dify_config:
|
||||
with patch("services.plugin.plugin_service.dify_config", new=mock_dify_config):
|
||||
with patch("core.plugin.plugin_service.dify_config", new=mock_dify_config):
|
||||
# Setup default mock returns
|
||||
mock_dify_config.CONSOLE_API_URL = "https://console.example.com"
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from core.plugin.impl.model_runtime_factory import create_plugin_model_assembly
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
|
||||
def test_plugin_model_assembly_reuses_single_runtime_across_views():
|
||||
@ -34,3 +35,11 @@ def test_plugin_model_assembly_reuses_single_runtime_across_views():
|
||||
mock_provider_factory_cls.assert_called_once_with(runtime=runtime)
|
||||
mock_provider_manager_cls.assert_called_once_with(model_runtime=runtime)
|
||||
mock_model_manager_cls.assert_called_once_with(provider_manager=provider_manager)
|
||||
|
||||
|
||||
def test_create_plugin_model_runtime_injects_plugin_service():
|
||||
from core.plugin.impl.model_runtime_factory import create_plugin_model_runtime
|
||||
|
||||
runtime = create_plugin_model_runtime(tenant_id="tenant-1", user_id="user-1")
|
||||
|
||||
assert runtime._plugin_service is PluginService
|
||||
|
||||
@ -12,6 +12,7 @@ from core.plugin.impl import model_runtime as model_runtime_module
|
||||
from core.plugin.impl.model import PluginModelClient
|
||||
from core.plugin.impl.model_runtime import TENANT_SCOPE_SCHEMA_CACHE_USER_ID, PluginModelRuntime
|
||||
from core.plugin.impl.model_runtime_factory import create_plugin_model_runtime
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from graphon.model_runtime.entities.common_entities import I18nObject
|
||||
from graphon.model_runtime.entities.llm_entities import LLMResultChunk, LLMResultChunkDelta, LLMUsage
|
||||
from graphon.model_runtime.entities.message_entities import AssistantPromptMessage
|
||||
@ -19,6 +20,22 @@ from graphon.model_runtime.entities.model_entities import AIModelEntity, FetchFr
|
||||
from graphon.model_runtime.entities.provider_entities import ConfigurateMethod, ProviderEntity
|
||||
|
||||
|
||||
class _FakeRedis:
|
||||
def __init__(self) -> None:
|
||||
self._values: dict[str, str] = {}
|
||||
self.setex_calls: list[tuple[str, int, str]] = []
|
||||
|
||||
def get(self, key: str) -> str | None:
|
||||
return self._values.get(key)
|
||||
|
||||
def setex(self, key: str, ttl: int, value: str) -> None:
|
||||
self._values[key] = value
|
||||
self.setex_calls.append((key, ttl, value))
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
self._values.pop(key, None)
|
||||
|
||||
|
||||
def _build_model_schema() -> AIModelEntity:
|
||||
return AIModelEntity(
|
||||
model="gpt-4o-mini",
|
||||
@ -29,6 +46,24 @@ def _build_model_schema() -> AIModelEntity:
|
||||
)
|
||||
|
||||
|
||||
def _build_plugin_model_provider(*, tenant_id: str, provider: str = "openai") -> PluginModelProviderEntity:
|
||||
return PluginModelProviderEntity(
|
||||
id=uuid.uuid4().hex,
|
||||
created_at=datetime.datetime.now(),
|
||||
updated_at=datetime.datetime.now(),
|
||||
provider=provider,
|
||||
tenant_id=tenant_id,
|
||||
plugin_unique_identifier=f"langgenius/{provider}/{provider}",
|
||||
plugin_id=f"langgenius/{provider}",
|
||||
declaration=ProviderEntity(
|
||||
provider=provider,
|
||||
label=I18nObject(en_US=provider.title()),
|
||||
supported_model_types=[],
|
||||
configurate_methods=[ConfigurateMethod.PREDEFINED_MODEL],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TestPluginModelRuntime:
|
||||
"""Validate the adapter keeps plugin-specific routing out of the runtime port."""
|
||||
|
||||
@ -51,7 +86,7 @@ class TestPluginModelRuntime:
|
||||
),
|
||||
)
|
||||
]
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService)
|
||||
|
||||
providers = runtime.fetch_model_providers()
|
||||
|
||||
@ -95,7 +130,7 @@ class TestPluginModelRuntime:
|
||||
),
|
||||
),
|
||||
]
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService)
|
||||
|
||||
providers = runtime.fetch_model_providers()
|
||||
|
||||
@ -122,7 +157,7 @@ class TestPluginModelRuntime:
|
||||
),
|
||||
)
|
||||
]
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService)
|
||||
|
||||
providers = runtime.fetch_model_providers()
|
||||
|
||||
@ -131,7 +166,7 @@ class TestPluginModelRuntime:
|
||||
|
||||
def test_validate_provider_credentials_resolves_plugin_fields(self) -> None:
|
||||
client = Mock(spec=PluginModelClient)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService)
|
||||
|
||||
runtime.validate_provider_credentials(
|
||||
provider="langgenius/openai/openai",
|
||||
@ -173,7 +208,7 @@ class TestPluginModelRuntime:
|
||||
),
|
||||
]
|
||||
)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService)
|
||||
|
||||
result = runtime.invoke_llm(
|
||||
provider="langgenius/openai/openai",
|
||||
@ -209,7 +244,7 @@ class TestPluginModelRuntime:
|
||||
client = Mock(spec=PluginModelClient)
|
||||
stream_result = iter([])
|
||||
client.invoke_llm.return_value = stream_result
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService)
|
||||
|
||||
result = runtime.invoke_llm(
|
||||
provider="langgenius/openai/openai",
|
||||
@ -240,7 +275,9 @@ class TestPluginModelRuntime:
|
||||
def test_invoke_llm_rejects_per_call_user_override(self) -> None:
|
||||
client = Mock(spec=PluginModelClient)
|
||||
client.invoke_llm.return_value = sentinel.result
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="bound-user", client=client)
|
||||
runtime = PluginModelRuntime(
|
||||
tenant_id="tenant", user_id="bound-user", client=client, plugin_service=PluginService
|
||||
)
|
||||
|
||||
with pytest.raises(TypeError, match="unexpected keyword argument 'user_id'"):
|
||||
runtime.invoke_llm( # type: ignore[call-arg]
|
||||
@ -260,7 +297,7 @@ class TestPluginModelRuntime:
|
||||
def test_invoke_tts_uses_bound_runtime_user_when_runtime_is_unbound(self) -> None:
|
||||
client = Mock(spec=PluginModelClient)
|
||||
client.invoke_tts.return_value = iter([b"chunk"])
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id=None, client=client)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id=None, client=client, plugin_service=PluginService)
|
||||
|
||||
result = runtime.invoke_tts(
|
||||
provider="langgenius/openai/openai",
|
||||
@ -282,15 +319,107 @@ class TestPluginModelRuntime:
|
||||
voice="alloy",
|
||||
)
|
||||
|
||||
def test_fetch_model_providers_uses_bound_runtime_cache(self) -> None:
|
||||
def test_fetch_model_providers_does_not_keep_bound_runtime_cache(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = Mock(spec=PluginModelClient)
|
||||
client.fetch_model_providers.return_value = []
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client)
|
||||
from core.plugin import plugin_service as plugin_service_module
|
||||
|
||||
monkeypatch.setattr(
|
||||
plugin_service_module,
|
||||
"redis_client",
|
||||
SimpleNamespace(
|
||||
get=Mock(return_value=None),
|
||||
delete=Mock(),
|
||||
setex=Mock(),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(plugin_service_module.dify_config, "PLUGIN_MODEL_PROVIDERS_CACHE_TTL", 300)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService)
|
||||
|
||||
runtime.fetch_model_providers()
|
||||
runtime.fetch_model_providers()
|
||||
|
||||
client.fetch_model_providers.assert_called_once_with("tenant")
|
||||
assert client.fetch_model_providers.call_count == 2
|
||||
|
||||
def test_fetch_model_providers_uses_tenant_ttl_cache_across_runtime_instances(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
redis = _FakeRedis()
|
||||
from core.plugin import plugin_service as plugin_service_module
|
||||
|
||||
monkeypatch.setattr(plugin_service_module, "redis_client", redis)
|
||||
monkeypatch.setattr(plugin_service_module.dify_config, "PLUGIN_MODEL_PROVIDERS_CACHE_TTL", 300)
|
||||
first_client = Mock(spec=PluginModelClient)
|
||||
first_client.fetch_model_providers.return_value = [_build_plugin_model_provider(tenant_id="tenant")]
|
||||
second_client = Mock(spec=PluginModelClient)
|
||||
first_runtime = PluginModelRuntime(
|
||||
tenant_id="tenant", user_id="user-a", client=first_client, plugin_service=PluginService
|
||||
)
|
||||
second_runtime = PluginModelRuntime(
|
||||
tenant_id="tenant", user_id="user-b", client=second_client, plugin_service=PluginService
|
||||
)
|
||||
|
||||
first_providers = first_runtime.fetch_model_providers()
|
||||
second_providers = second_runtime.fetch_model_providers()
|
||||
|
||||
assert [provider.provider for provider in first_providers] == ["langgenius/openai/openai"]
|
||||
assert [provider.provider for provider in second_providers] == ["langgenius/openai/openai"]
|
||||
first_client.fetch_model_providers.assert_called_once_with("tenant")
|
||||
second_client.fetch_model_providers.assert_not_called()
|
||||
assert redis.setex_calls[0][1] == 300
|
||||
|
||||
def test_fetch_model_providers_cache_is_tenant_isolated(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
redis = _FakeRedis()
|
||||
from core.plugin import plugin_service as plugin_service_module
|
||||
|
||||
monkeypatch.setattr(plugin_service_module, "redis_client", redis)
|
||||
monkeypatch.setattr(plugin_service_module.dify_config, "PLUGIN_MODEL_PROVIDERS_CACHE_TTL", 300)
|
||||
first_client = Mock(spec=PluginModelClient)
|
||||
first_client.fetch_model_providers.return_value = [_build_plugin_model_provider(tenant_id="tenant-a")]
|
||||
second_client = Mock(spec=PluginModelClient)
|
||||
second_client.fetch_model_providers.return_value = [_build_plugin_model_provider(tenant_id="tenant-b")]
|
||||
first_runtime = PluginModelRuntime(
|
||||
tenant_id="tenant-a", user_id="user", client=first_client, plugin_service=PluginService
|
||||
)
|
||||
second_runtime = PluginModelRuntime(
|
||||
tenant_id="tenant-b", user_id="user", client=second_client, plugin_service=PluginService
|
||||
)
|
||||
|
||||
first_providers = first_runtime.fetch_model_providers()
|
||||
second_providers = second_runtime.fetch_model_providers()
|
||||
|
||||
assert [provider.provider for provider in first_providers] == ["langgenius/openai/openai"]
|
||||
assert [provider.provider for provider in second_providers] == ["langgenius/openai/openai"]
|
||||
first_client.fetch_model_providers.assert_called_once_with("tenant-a")
|
||||
second_client.fetch_model_providers.assert_called_once_with("tenant-b")
|
||||
assert len(redis.setex_calls) == 2
|
||||
|
||||
def test_fetch_model_providers_delegates_cache_to_injected_plugin_service(self) -> None:
|
||||
client = Mock(spec=PluginModelClient)
|
||||
service_result = [
|
||||
ProviderEntity(
|
||||
provider="langgenius/openai/openai",
|
||||
label=I18nObject(en_US="OpenAI"),
|
||||
supported_model_types=[],
|
||||
configurate_methods=[ConfigurateMethod.PREDEFINED_MODEL],
|
||||
)
|
||||
]
|
||||
fetch_plugin_model_providers = Mock(return_value=service_result)
|
||||
|
||||
class TestPluginService(PluginService):
|
||||
pass
|
||||
|
||||
TestPluginService.fetch_plugin_model_providers = fetch_plugin_model_providers
|
||||
|
||||
runtime = PluginModelRuntime(
|
||||
tenant_id="tenant", user_id="user", client=client, plugin_service=TestPluginService
|
||||
)
|
||||
|
||||
result = runtime.fetch_model_providers()
|
||||
|
||||
assert result is service_result
|
||||
fetch_plugin_model_providers.assert_called_once_with(tenant_id="tenant", client=client)
|
||||
client.fetch_model_providers.assert_not_called()
|
||||
|
||||
|
||||
def test_create_plugin_model_runtime_without_user_context() -> None:
|
||||
@ -301,7 +430,17 @@ def test_create_plugin_model_runtime_without_user_context() -> None:
|
||||
|
||||
def test_plugin_model_runtime_requires_client() -> None:
|
||||
with pytest.raises(ValueError, match="client is required"):
|
||||
PluginModelRuntime(tenant_id="tenant", user_id="user", client=None) # type: ignore[arg-type]
|
||||
PluginModelRuntime(tenant_id="tenant", user_id="user", client=None, plugin_service=PluginService) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_plugin_model_runtime_requires_plugin_service() -> None:
|
||||
with pytest.raises(ValueError, match="plugin_service is required"):
|
||||
PluginModelRuntime(
|
||||
tenant_id="tenant",
|
||||
user_id="user",
|
||||
client=Mock(spec=PluginModelClient),
|
||||
plugin_service=None, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def test_get_model_schema_uses_cached_schema_without_hitting_client(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@ -317,7 +456,7 @@ def test_get_model_schema_uses_cached_schema_without_hitting_client(monkeypatch:
|
||||
),
|
||||
)
|
||||
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService)
|
||||
result = runtime.get_model_schema(
|
||||
provider="langgenius/openai/openai",
|
||||
model_type=ModelType.LLM,
|
||||
@ -395,7 +534,7 @@ def test_structured_output_adapter_invokes_bound_runtime_non_streaming() -> None
|
||||
|
||||
def test_invoke_llm_with_structured_output_delegates_with_bound_adapter() -> None:
|
||||
client = Mock(spec=PluginModelClient)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService)
|
||||
schema = _build_model_schema()
|
||||
runtime.get_model_schema = Mock(return_value=schema) # type: ignore[method-assign]
|
||||
|
||||
@ -436,7 +575,7 @@ def test_invoke_llm_with_structured_output_delegates_with_bound_adapter() -> Non
|
||||
|
||||
def test_invoke_llm_with_structured_output_raises_when_model_schema_is_missing() -> None:
|
||||
client = Mock(spec=PluginModelClient)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService)
|
||||
runtime.get_model_schema = Mock(return_value=None) # type: ignore[method-assign]
|
||||
|
||||
with pytest.raises(ValueError, match="Model schema not found for gpt-4o-mini"):
|
||||
@ -468,7 +607,7 @@ def test_get_model_schema_deletes_invalid_cache_and_refetches(monkeypatch: pytes
|
||||
)
|
||||
monkeypatch.setattr(model_runtime_module.dify_config, "PLUGIN_MODEL_SCHEMA_CACHE_TTL", 300)
|
||||
client.get_model_schema.return_value = schema
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService)
|
||||
|
||||
result = runtime.get_model_schema(
|
||||
provider="langgenius/openai/openai",
|
||||
@ -494,7 +633,7 @@ def test_get_model_schema_deletes_invalid_cache_and_refetches(monkeypatch: pytes
|
||||
def test_get_llm_num_tokens_returns_zero_when_plugin_counting_is_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = Mock(spec=PluginModelClient)
|
||||
monkeypatch.setattr(model_runtime_module.dify_config, "PLUGIN_BASED_TOKEN_COUNTING_ENABLED", False)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService)
|
||||
|
||||
assert (
|
||||
runtime.get_llm_num_tokens(
|
||||
@ -533,7 +672,7 @@ def test_get_provider_icon_reads_requested_variant_and_detects_svg_mime(monkeypa
|
||||
]
|
||||
fetch_asset = Mock(return_value=b"<svg></svg>")
|
||||
monkeypatch.setattr(model_runtime_module.PluginAssetManager, "fetch_asset", fetch_asset)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService)
|
||||
|
||||
icon_bytes, mime_type = runtime.get_provider_icon(
|
||||
provider="langgenius/openai/openai",
|
||||
@ -565,7 +704,7 @@ def test_get_provider_icon_rejects_unsupported_types_and_missing_variants() -> N
|
||||
),
|
||||
)
|
||||
]
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService)
|
||||
|
||||
with pytest.raises(ValueError, match="does not have small dark icon"):
|
||||
runtime.get_provider_icon(
|
||||
@ -583,7 +722,9 @@ def test_get_provider_icon_rejects_unsupported_types_and_missing_variants() -> N
|
||||
|
||||
|
||||
def test_get_schema_cache_key_is_stable_across_credential_order() -> None:
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=Mock(spec=PluginModelClient))
|
||||
runtime = PluginModelRuntime(
|
||||
tenant_id="tenant", user_id="user", client=Mock(spec=PluginModelClient), plugin_service=PluginService
|
||||
)
|
||||
|
||||
first = runtime._get_schema_cache_key(
|
||||
provider="langgenius/openai/openai",
|
||||
@ -602,8 +743,12 @@ def test_get_schema_cache_key_is_stable_across_credential_order() -> None:
|
||||
|
||||
|
||||
def test_get_schema_cache_key_separates_distinct_user_scopes() -> None:
|
||||
first_runtime = PluginModelRuntime(tenant_id="tenant", user_id="user-a", client=Mock(spec=PluginModelClient))
|
||||
second_runtime = PluginModelRuntime(tenant_id="tenant", user_id="user-b", client=Mock(spec=PluginModelClient))
|
||||
first_runtime = PluginModelRuntime(
|
||||
tenant_id="tenant", user_id="user-a", client=Mock(spec=PluginModelClient), plugin_service=PluginService
|
||||
)
|
||||
second_runtime = PluginModelRuntime(
|
||||
tenant_id="tenant", user_id="user-b", client=Mock(spec=PluginModelClient), plugin_service=PluginService
|
||||
)
|
||||
|
||||
first = first_runtime._get_schema_cache_key(
|
||||
provider="langgenius/openai/openai",
|
||||
@ -622,8 +767,12 @@ def test_get_schema_cache_key_separates_distinct_user_scopes() -> None:
|
||||
|
||||
|
||||
def test_get_schema_cache_key_separates_tenant_scope_from_user_scope() -> None:
|
||||
tenant_runtime = PluginModelRuntime(tenant_id="tenant", user_id=None, client=Mock(spec=PluginModelClient))
|
||||
user_runtime = PluginModelRuntime(tenant_id="tenant", user_id="user-a", client=Mock(spec=PluginModelClient))
|
||||
tenant_runtime = PluginModelRuntime(
|
||||
tenant_id="tenant", user_id=None, client=Mock(spec=PluginModelClient), plugin_service=PluginService
|
||||
)
|
||||
user_runtime = PluginModelRuntime(
|
||||
tenant_id="tenant", user_id="user-a", client=Mock(spec=PluginModelClient), plugin_service=PluginService
|
||||
)
|
||||
|
||||
tenant_key = tenant_runtime._get_schema_cache_key(
|
||||
provider="langgenius/openai/openai",
|
||||
@ -643,8 +792,12 @@ def test_get_schema_cache_key_separates_tenant_scope_from_user_scope() -> None:
|
||||
|
||||
|
||||
def test_get_schema_cache_key_separates_tenant_scope_from_empty_string_user_scope() -> None:
|
||||
tenant_runtime = PluginModelRuntime(tenant_id="tenant", user_id=None, client=Mock(spec=PluginModelClient))
|
||||
empty_user_runtime = PluginModelRuntime(tenant_id="tenant", user_id="", client=Mock(spec=PluginModelClient))
|
||||
tenant_runtime = PluginModelRuntime(
|
||||
tenant_id="tenant", user_id=None, client=Mock(spec=PluginModelClient), plugin_service=PluginService
|
||||
)
|
||||
empty_user_runtime = PluginModelRuntime(
|
||||
tenant_id="tenant", user_id="", client=Mock(spec=PluginModelClient), plugin_service=PluginService
|
||||
)
|
||||
|
||||
tenant_key = tenant_runtime._get_schema_cache_key(
|
||||
provider="langgenius/openai/openai",
|
||||
@ -683,7 +836,7 @@ def test_get_provider_schema_supports_short_alias_and_rejects_invalid_provider()
|
||||
),
|
||||
)
|
||||
]
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService)
|
||||
|
||||
assert runtime._get_provider_schema("openai").provider == "langgenius/openai/openai"
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@ This test suite covers:
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
@ -197,6 +198,55 @@ class TestAppModelValidation:
|
||||
# Assert
|
||||
assert result == AppMode.CHAT
|
||||
|
||||
def test_deleted_tools_checks_plugin_builtin_providers_through_core_plugin_service(self):
|
||||
"""Plugin-backed built-in tools are checked through core PluginService."""
|
||||
# Arrange
|
||||
app = App(
|
||||
tenant_id="tenant-1",
|
||||
name="Test App",
|
||||
mode=AppMode.CHAT,
|
||||
enable_site=True,
|
||||
enable_api=False,
|
||||
created_by=str(uuid4()),
|
||||
)
|
||||
app_model_config = AppModelConfig(
|
||||
app_id=str(uuid4()),
|
||||
agent_mode=json.dumps(
|
||||
{
|
||||
"enabled": True,
|
||||
"strategy": "function_call",
|
||||
"tools": [
|
||||
{
|
||||
"provider_type": "builtin",
|
||||
"provider_id": "langgenius/openai/openai",
|
||||
"tool_name": "chat",
|
||||
"tool_parameters": {},
|
||||
}
|
||||
],
|
||||
"prompt": None,
|
||||
}
|
||||
),
|
||||
)
|
||||
session_context = MagicMock()
|
||||
session_context.__enter__.return_value = MagicMock()
|
||||
session_factory = SimpleNamespace(begin=MagicMock(return_value=session_context))
|
||||
|
||||
# Act
|
||||
with (
|
||||
patch.object(App, "app_model_config", new_callable=lambda: property(lambda self: app_model_config)),
|
||||
patch("models.model.db", SimpleNamespace(engine=object())),
|
||||
patch("models.model.sessionmaker", return_value=session_factory),
|
||||
patch("core.tools.tool_manager.ToolManager.get_hardcoded_provider", side_effect=Exception),
|
||||
patch("core.plugin.plugin_service.PluginService.check_tools_existence", return_value=[False]) as exists,
|
||||
):
|
||||
result = app.deleted_tools
|
||||
|
||||
# Assert
|
||||
assert result == [{"type": "builtin", "tool_name": "chat", "provider_id": "langgenius/openai/openai"}]
|
||||
exists.assert_called_once()
|
||||
assert exists.call_args.args[0] == "tenant-1"
|
||||
assert [str(provider_id) for provider_id in exists.call_args.args[1]] == ["langgenius/openai/openai"]
|
||||
|
||||
|
||||
class TestAppModelConfig:
|
||||
"""Test suite for AppModelConfig model."""
|
||||
|
||||
@ -24,7 +24,7 @@ def make_features(
|
||||
def mock_installer(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Patch PluginInstaller at the service import site."""
|
||||
mock = MagicMock()
|
||||
monkeypatch.setattr("services.plugin.plugin_service.PluginInstaller", lambda: mock)
|
||||
monkeypatch.setattr("core.plugin.plugin_service.PluginInstaller", lambda: mock)
|
||||
return mock
|
||||
|
||||
|
||||
@ -34,6 +34,6 @@ def mock_features():
|
||||
from unittest.mock import patch
|
||||
|
||||
features = make_features()
|
||||
with patch("services.plugin.plugin_service.FeatureService") as mock_fs:
|
||||
with patch("core.plugin.plugin_service.FeatureService") as mock_fs:
|
||||
mock_fs.get_system_features.return_value = features
|
||||
yield features
|
||||
|
||||
@ -61,6 +61,7 @@ class TestHandlePluginInstanceInstall:
|
||||
patch(f"{MIGRATION_MODULE}.dify_config") as mock_cfg,
|
||||
patch(f"{MIGRATION_MODULE}.marketplace") as mock_marketplace,
|
||||
patch(f"{MIGRATION_MODULE}.PluginInstaller") as mock_installer_cls,
|
||||
patch(f"{MIGRATION_MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
||||
):
|
||||
mock_cfg.MARKETPLACE_ENABLED = True
|
||||
mock_marketplace.download_plugin_pkg.return_value = b"pkg_data"
|
||||
@ -73,4 +74,31 @@ class TestHandlePluginInstanceInstall:
|
||||
)
|
||||
|
||||
mock_marketplace.download_plugin_pkg.assert_called_once()
|
||||
invalidate_cache.assert_called_once_with("tenant1")
|
||||
assert "success" in result or "failed" in result
|
||||
|
||||
def test_install_plugins_invalidates_cache_after_direct_tenant_install(self, tmp_path) -> None:
|
||||
extracted_plugins = tmp_path / "plugins.jsonl"
|
||||
output_file = tmp_path / "output.json"
|
||||
extracted_plugins.write_text('{"tenant_id":"tenant1","plugins":["langgenius/openai"]}\n')
|
||||
|
||||
with (
|
||||
patch(
|
||||
f"{MIGRATION_MODULE}.PluginMigration.extract_unique_plugins",
|
||||
return_value={
|
||||
"plugins": {"langgenius/openai": "langgenius/openai:1.0.0@abc"},
|
||||
"plugin_not_exist": [],
|
||||
},
|
||||
),
|
||||
patch(f"{MIGRATION_MODULE}.PluginMigration.handle_plugin_instance_install", return_value={}),
|
||||
patch(f"{MIGRATION_MODULE}.PluginInstaller") as mock_installer_cls,
|
||||
patch(f"{MIGRATION_MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
||||
):
|
||||
mock_installer = MagicMock()
|
||||
mock_installer.list_plugins.return_value = []
|
||||
mock_installer_cls.return_value = mock_installer
|
||||
|
||||
PluginMigration.install_plugins(str(extracted_plugins), str(output_file), workers=1)
|
||||
|
||||
mock_installer.install_from_identifiers.assert_called_once()
|
||||
invalidate_cache.assert_called_once_with("tenant1")
|
||||
|
||||
@ -1,6 +1,71 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
import datetime
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
MODULE = "services.plugin.plugin_service"
|
||||
from pydantic import TypeAdapter
|
||||
from redis import RedisError
|
||||
|
||||
from core.plugin.entities.plugin_daemon import PluginInstallTask, PluginInstallTaskStatus, PluginModelProviderEntity
|
||||
from graphon.model_runtime.entities.common_entities import I18nObject
|
||||
from graphon.model_runtime.entities.provider_entities import ConfigurateMethod, ProviderEntity
|
||||
|
||||
MODULE = "core.plugin.plugin_service"
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self) -> None:
|
||||
self.execute = Mock()
|
||||
self.scalars = Mock(return_value=SimpleNamespace(all=Mock(return_value=[])))
|
||||
|
||||
def __enter__(self) -> "_FakeSession":
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, traceback) -> None:
|
||||
return None
|
||||
|
||||
def begin(self) -> "_FakeSession":
|
||||
return self
|
||||
|
||||
|
||||
def _build_provider_entity(provider: str = "openai") -> ProviderEntity:
|
||||
return ProviderEntity(
|
||||
provider=f"langgenius/{provider}/{provider}",
|
||||
label=I18nObject(en_US=provider.title()),
|
||||
supported_model_types=[],
|
||||
configurate_methods=[ConfigurateMethod.PREDEFINED_MODEL],
|
||||
)
|
||||
|
||||
|
||||
def _build_plugin_model_provider(*, tenant_id: str = "tenant-1", provider: str = "openai") -> PluginModelProviderEntity:
|
||||
return PluginModelProviderEntity(
|
||||
id=uuid.uuid4().hex,
|
||||
created_at=datetime.datetime.now(),
|
||||
updated_at=datetime.datetime.now(),
|
||||
provider=provider,
|
||||
tenant_id=tenant_id,
|
||||
plugin_unique_identifier=f"langgenius/{provider}/{provider}",
|
||||
plugin_id=f"langgenius/{provider}",
|
||||
declaration=ProviderEntity(
|
||||
provider=provider,
|
||||
label=I18nObject(en_US=provider.title()),
|
||||
supported_model_types=[],
|
||||
configurate_methods=[ConfigurateMethod.PREDEFINED_MODEL],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _build_install_task(*, task_id: str = "task-1", status: PluginInstallTaskStatus) -> PluginInstallTask:
|
||||
now = datetime.datetime.now()
|
||||
return PluginInstallTask(
|
||||
id=task_id,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
status=status,
|
||||
total_plugins=1,
|
||||
completed_plugins=1 if status != PluginInstallTaskStatus.Pending else 0,
|
||||
plugins=[],
|
||||
)
|
||||
|
||||
|
||||
class TestFetchLatestPluginVersion:
|
||||
@ -14,7 +79,7 @@ class TestFetchLatestPluginVersion:
|
||||
mock_cfg.MARKETPLACE_ENABLED = False
|
||||
mock_redis.get.return_value = None # all cache misses
|
||||
|
||||
from services.plugin.plugin_service import PluginService
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.fetch_latest_plugin_version(["langgenius/openai", "langgenius/anthropic"])
|
||||
|
||||
@ -40,7 +105,7 @@ class TestFetchLatestPluginVersion:
|
||||
mock_redis.get.return_value = None
|
||||
mock_marketplace.batch_fetch_plugin_manifests.return_value = [manifest]
|
||||
|
||||
from services.plugin.plugin_service import PluginService
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.fetch_latest_plugin_version(["langgenius/openai"])
|
||||
|
||||
@ -48,3 +113,322 @@ class TestFetchLatestPluginVersion:
|
||||
mock_marketplace.batch_fetch_plugin_manifests.assert_called_once()
|
||||
assert result["langgenius/openai"] is not None
|
||||
assert result["langgenius/openai"].version == "1.0.0"
|
||||
|
||||
|
||||
class TestPluginModelProviderCache:
|
||||
def test_fetch_plugin_model_providers_returns_cached_provider_without_calling_daemon(self) -> None:
|
||||
"""A valid tenant cache entry is reused across runtime calls without plugin daemon access."""
|
||||
cached_provider = _build_provider_entity()
|
||||
cached_payload = TypeAdapter(list[ProviderEntity]).dump_json([cached_provider]).decode("utf-8")
|
||||
|
||||
with patch(f"{MODULE}.redis_client") as redis_client:
|
||||
redis_client.get.return_value = cached_payload
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
client = Mock()
|
||||
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
||||
|
||||
assert [provider.provider for provider in result] == ["langgenius/openai/openai"]
|
||||
client.fetch_model_providers.assert_not_called()
|
||||
redis_client.setex.assert_not_called()
|
||||
|
||||
def test_fetch_plugin_model_providers_deletes_invalid_cache_and_refetches(self) -> None:
|
||||
"""Invalid cache payloads are tenant-scoped invalidated before falling back to the daemon."""
|
||||
with (
|
||||
patch(f"{MODULE}.redis_client") as redis_client,
|
||||
patch(f"{MODULE}.dify_config") as mock_config,
|
||||
):
|
||||
redis_client.get.return_value = "not-json"
|
||||
mock_config.PLUGIN_MODEL_PROVIDERS_CACHE_TTL = 86400
|
||||
client = Mock()
|
||||
client.fetch_model_providers.return_value = [_build_plugin_model_provider()]
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
||||
|
||||
cache_key = "plugin_model_providers:tenant_id:tenant-1"
|
||||
redis_client.delete.assert_called_once_with(cache_key)
|
||||
redis_client.setex.assert_called_once()
|
||||
assert redis_client.setex.call_args.args[0] == cache_key
|
||||
assert redis_client.setex.call_args.args[1] == 86400
|
||||
assert [provider.provider for provider in result] == ["langgenius/openai/openai"]
|
||||
|
||||
def test_fetch_plugin_model_providers_refetches_when_cache_read_fails(self) -> None:
|
||||
"""Redis read failures do not block provider discovery for the tenant."""
|
||||
with patch(f"{MODULE}.redis_client") as redis_client:
|
||||
redis_client.get.side_effect = RedisError("redis unavailable")
|
||||
client = Mock()
|
||||
client.fetch_model_providers.return_value = [_build_plugin_model_provider()]
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
||||
|
||||
client.fetch_model_providers.assert_called_once_with("tenant-1")
|
||||
assert [provider.provider for provider in result] == ["langgenius/openai/openai"]
|
||||
|
||||
def test_fetch_plugin_model_providers_returns_fresh_result_when_cache_write_fails(self) -> None:
|
||||
"""Redis write failures are non-fatal after fresh provider data has been fetched."""
|
||||
with patch(f"{MODULE}.redis_client") as redis_client:
|
||||
redis_client.get.return_value = None
|
||||
redis_client.setex.side_effect = RedisError("redis unavailable")
|
||||
client = Mock()
|
||||
client.fetch_model_providers.return_value = [_build_plugin_model_provider()]
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
||||
|
||||
client.fetch_model_providers.assert_called_once_with("tenant-1")
|
||||
assert [provider.provider for provider in result] == ["langgenius/openai/openai"]
|
||||
|
||||
def test_fetch_plugin_model_providers_creates_default_client_on_cache_miss(self) -> None:
|
||||
"""The service owns plugin daemon access when no runtime-provided client is injected."""
|
||||
with (
|
||||
patch(f"{MODULE}.redis_client") as redis_client,
|
||||
patch(f"{MODULE}.PluginModelClient") as client_cls,
|
||||
):
|
||||
redis_client.get.return_value = None
|
||||
client = client_cls.return_value
|
||||
client.fetch_model_providers.return_value = [_build_plugin_model_provider()]
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1")
|
||||
|
||||
client_cls.assert_called_once_with()
|
||||
client.fetch_model_providers.assert_called_once_with("tenant-1")
|
||||
assert [provider.provider for provider in result] == ["langgenius/openai/openai"]
|
||||
|
||||
def test_invalidate_plugin_model_providers_cache_uses_tenant_cache_key(self) -> None:
|
||||
with patch(f"{MODULE}.redis_client") as redis_client:
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
PluginService.invalidate_plugin_model_providers_cache("tenant-1")
|
||||
|
||||
redis_client.delete.assert_called_once_with("plugin_model_providers:tenant_id:tenant-1")
|
||||
|
||||
def test_invalidate_plugin_model_providers_cache_ignores_redis_delete_failure(self) -> None:
|
||||
with patch(f"{MODULE}.redis_client") as redis_client:
|
||||
redis_client.delete.side_effect = RedisError("redis unavailable")
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
PluginService.invalidate_plugin_model_providers_cache("tenant-1")
|
||||
|
||||
redis_client.delete.assert_called_once_with("plugin_model_providers:tenant_id:tenant-1")
|
||||
|
||||
|
||||
class TestPluginModelProviderCacheInvalidation:
|
||||
def test_fetch_install_task_invalidates_model_provider_cache_when_finished(self) -> None:
|
||||
"""Finished plugin install tasks invalidate tenant provider cache."""
|
||||
task = _build_install_task(status=PluginInstallTaskStatus.Success)
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
||||
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
||||
):
|
||||
installer_cls.return_value.fetch_plugin_installation_task.return_value = task
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.fetch_install_task("tenant-1", "task-1")
|
||||
|
||||
assert result is task
|
||||
invalidate_cache.assert_called_once_with("tenant-1")
|
||||
|
||||
def test_fetch_install_tasks_invalidates_model_provider_cache_for_finished_tasks(self) -> None:
|
||||
"""Finished tasks from task list polling also invalidate tenant provider cache."""
|
||||
task = _build_install_task(status=PluginInstallTaskStatus.Success)
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
||||
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
||||
):
|
||||
installer_cls.return_value.fetch_plugin_installation_tasks.return_value = [task]
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.fetch_install_tasks("tenant-1", 1, 256)
|
||||
|
||||
assert result == [task]
|
||||
invalidate_cache.assert_called_once_with("tenant-1")
|
||||
|
||||
def test_fetch_install_tasks_ignores_running_model_provider_cache_tasks(self) -> None:
|
||||
"""Running plugin install tasks do not invalidate provider cache until they reach a terminal state."""
|
||||
task = _build_install_task(status=PluginInstallTaskStatus.Running)
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
||||
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
||||
):
|
||||
installer_cls.return_value.fetch_plugin_installation_tasks.return_value = [task]
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.fetch_install_tasks("tenant-1", 1, 256)
|
||||
|
||||
assert result == [task]
|
||||
invalidate_cache.assert_not_called()
|
||||
|
||||
def test_upgrade_plugin_with_marketplace_invalidates_model_provider_cache_for_tenant(self) -> None:
|
||||
"""Marketplace upgrades invalidate only the mutated tenant provider cache."""
|
||||
with (
|
||||
patch(f"{MODULE}.dify_config") as mock_config,
|
||||
patch(f"{MODULE}.FeatureService") as feature_service,
|
||||
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
||||
patch(f"{MODULE}.marketplace") as marketplace,
|
||||
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
||||
):
|
||||
mock_config.MARKETPLACE_ENABLED = True
|
||||
feature_service.get_system_features.return_value = SimpleNamespace(
|
||||
plugin_installation_permission=SimpleNamespace(restrict_to_marketplace_only=False)
|
||||
)
|
||||
installer = installer_cls.return_value
|
||||
installer.fetch_plugin_manifest.return_value = MagicMock()
|
||||
installer.upgrade_plugin.return_value = "task-id"
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.upgrade_plugin_with_marketplace("tenant-1", "old-uid", "new-uid")
|
||||
|
||||
assert result == "task-id"
|
||||
marketplace.record_install_plugin_event.assert_called_once_with("new-uid")
|
||||
invalidate_cache.assert_called_once_with("tenant-1")
|
||||
|
||||
def test_install_from_local_pkg_invalidates_model_provider_cache_for_tenant(self) -> None:
|
||||
"""Starting a plugin install invalidates only the mutated tenant provider cache."""
|
||||
with (
|
||||
patch(f"{MODULE}.PluginService._check_marketplace_only_permission"),
|
||||
patch(f"{MODULE}.PluginService._check_plugin_installation_scope"),
|
||||
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
||||
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
||||
):
|
||||
installer = installer_cls.return_value
|
||||
decode_response = MagicMock()
|
||||
decode_response.verification = None
|
||||
installer.decode_plugin_from_identifier.return_value = decode_response
|
||||
installer.install_from_identifiers.return_value = "task-id"
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.install_from_local_pkg("tenant-1", ["langgenius/openai:1.0.0"])
|
||||
|
||||
assert result == "task-id"
|
||||
invalidate_cache.assert_called_once_with("tenant-1")
|
||||
|
||||
def test_upgrade_plugin_with_github_invalidates_model_provider_cache_for_tenant(self) -> None:
|
||||
"""Starting a plugin upgrade invalidates only the mutated tenant provider cache."""
|
||||
with (
|
||||
patch(f"{MODULE}.PluginService._check_marketplace_only_permission"),
|
||||
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
||||
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
||||
):
|
||||
installer = installer_cls.return_value
|
||||
installer.upgrade_plugin.return_value = "task-id"
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.upgrade_plugin_with_github(
|
||||
"tenant-1", "old-uid", "new-uid", "langgenius/openai", "1.0.0", "openai.difypkg"
|
||||
)
|
||||
|
||||
assert result == "task-id"
|
||||
invalidate_cache.assert_called_once_with("tenant-1")
|
||||
|
||||
def test_install_from_github_invalidates_model_provider_cache_for_tenant(self) -> None:
|
||||
"""GitHub installs invalidate only the mutated tenant provider cache."""
|
||||
with (
|
||||
patch(f"{MODULE}.PluginService._check_marketplace_only_permission"),
|
||||
patch(f"{MODULE}.PluginService._check_plugin_installation_scope"),
|
||||
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
||||
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
||||
):
|
||||
installer = installer_cls.return_value
|
||||
decode_response = MagicMock()
|
||||
decode_response.verification = None
|
||||
installer.decode_plugin_from_identifier.return_value = decode_response
|
||||
installer.install_from_identifiers.return_value = "task-id"
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.install_from_github(
|
||||
"tenant-1", "langgenius/openai:1.0.0", "langgenius/openai", "1.0.0", "openai.difypkg"
|
||||
)
|
||||
|
||||
assert result == "task-id"
|
||||
invalidate_cache.assert_called_once_with("tenant-1")
|
||||
|
||||
def test_install_from_marketplace_pkg_invalidates_model_provider_cache_for_tenant(self) -> None:
|
||||
"""Marketplace package installs invalidate only the mutated tenant provider cache."""
|
||||
with (
|
||||
patch(f"{MODULE}.dify_config") as mock_config,
|
||||
patch(f"{MODULE}.FeatureService") as feature_service,
|
||||
patch(f"{MODULE}.PluginService._check_plugin_installation_scope"),
|
||||
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
||||
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
||||
):
|
||||
mock_config.MARKETPLACE_ENABLED = True
|
||||
feature_service.get_system_features.return_value = SimpleNamespace(
|
||||
plugin_installation_permission=SimpleNamespace(restrict_to_marketplace_only=False)
|
||||
)
|
||||
installer = installer_cls.return_value
|
||||
installer.fetch_plugin_manifest.return_value = MagicMock()
|
||||
decode_response = MagicMock()
|
||||
decode_response.verification = None
|
||||
installer.decode_plugin_from_identifier.return_value = decode_response
|
||||
installer.install_from_identifiers.return_value = "task-id"
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.install_from_marketplace_pkg("tenant-1", ["langgenius/openai:1.0.0"])
|
||||
|
||||
assert result == "task-id"
|
||||
invalidate_cache.assert_called_once_with("tenant-1")
|
||||
|
||||
def test_uninstall_invalidates_model_provider_cache_for_tenant(self) -> None:
|
||||
"""Successful uninstall invalidates only the mutated tenant provider cache."""
|
||||
with (
|
||||
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
||||
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
||||
):
|
||||
installer = installer_cls.return_value
|
||||
installer.list_plugins.return_value = []
|
||||
installer.uninstall.return_value = True
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.uninstall("tenant-1", "installation-1")
|
||||
|
||||
assert result is True
|
||||
invalidate_cache.assert_called_once_with("tenant-1")
|
||||
|
||||
def test_uninstall_existing_plugin_invalidates_cache_after_credential_cleanup(self) -> None:
|
||||
"""Successful uninstall with plugin metadata also invalidates the mutated tenant provider cache."""
|
||||
plugin = SimpleNamespace(
|
||||
installation_id="installation-1",
|
||||
plugin_id="langgenius/openai",
|
||||
plugin_unique_identifier="langgenius/openai:1.0.0",
|
||||
)
|
||||
session = _FakeSession()
|
||||
with (
|
||||
patch(f"{MODULE}.db", SimpleNamespace(engine=object())),
|
||||
patch(f"{MODULE}.dify_config") as mock_config,
|
||||
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
||||
patch(f"{MODULE}.Session", return_value=session),
|
||||
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
||||
):
|
||||
mock_config.ENTERPRISE_ENABLED = False
|
||||
installer = installer_cls.return_value
|
||||
installer.list_plugins.return_value = [plugin]
|
||||
installer.uninstall.return_value = True
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.uninstall("tenant-1", "installation-1")
|
||||
|
||||
assert result is True
|
||||
installer.uninstall.assert_called_once_with("tenant-1", "installation-1")
|
||||
invalidate_cache.assert_called_once_with("tenant-1")
|
||||
|
||||
@ -60,6 +60,7 @@ SSRF_PROXY_HTTPS_URL=http://ssrf_proxy:3128
|
||||
PGDATA=/var/lib/postgresql/data/pgdata
|
||||
PLUGIN_MAX_PACKAGE_SIZE=52428800
|
||||
PLUGIN_MODEL_SCHEMA_CACHE_TTL=3600
|
||||
PLUGIN_MODEL_PROVIDERS_CACHE_TTL=86400
|
||||
ENDPOINT_URL_TEMPLATE=http://localhost/e/{hook_id}
|
||||
LOG_LEVEL=INFO
|
||||
LOG_OUTPUT_FORMAT=text
|
||||
|
||||
@ -1571,19 +1571,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/pagination/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"unicorn/prefer-number-properties": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/pagination/type.ts": {
|
||||
"ts/no-empty-object-type": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/prompt-editor/index.stories.tsx": {
|
||||
"no-console": {
|
||||
"count": 1
|
||||
|
||||
@ -40,16 +40,16 @@ Importing from `@langgenius/dify-ui` (no subpath) is intentionally not supported
|
||||
|
||||
## Primitives
|
||||
|
||||
| Category | Subpath | Notes |
|
||||
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
|
||||
| Actions | `./button` | Design-system CTA primitive with `cva` variants. |
|
||||
| Feedback | `./meter`, `./toast` | Meter is inline status; Toast owns the `z-60` layer. |
|
||||
| Form | `./form`, `./field`, `./fieldset`, `./input`, `./checkbox`, `./checkbox-group`, `./radio`, `./radio-group`, `./number-field`, `./select`, `./slider`, `./switch` | Native form boundary, field semantics, and controls. |
|
||||
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
|
||||
| Media | `./avatar` | Avatar root, image, and fallback primitives. |
|
||||
| Navigation | `./tabs`, `./toggle-group` | Tabs for panels; ToggleGroup for segmented modes. |
|
||||
| Overlay / menu | `./alert-dialog`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./preview-card`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
|
||||
| Search / pickers | `./autocomplete`, `./combobox`, `./select` | Search input, searchable picker, and closed picker. |
|
||||
| Category | Subpath | Notes |
|
||||
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| Actions | `./button` | Design-system CTA primitive with `cva` variants. |
|
||||
| Feedback | `./meter`, `./toast` | Meter is inline status; Toast owns the `z-60` layer. |
|
||||
| Form | `./form`, `./field`, `./fieldset`, `./input`, `./checkbox`, `./checkbox-group`, `./radio`, `./radio-group`, `./number-field`, `./select`, `./slider`, `./switch` | Native form boundary, field semantics, and controls. |
|
||||
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
|
||||
| Media | `./avatar` | Avatar root, image, and fallback primitives. |
|
||||
| Navigation | `./pagination`, `./tabs`, `./toggle-group` | Pagination for page navigation; Tabs for panels; ToggleGroup for segmented modes. |
|
||||
| Overlay / menu | `./alert-dialog`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./preview-card`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
|
||||
| Search / pickers | `./autocomplete`, `./combobox`, `./select` | Search input, searchable picker, and closed picker. |
|
||||
|
||||
Utilities:
|
||||
|
||||
|
||||
@ -77,6 +77,10 @@
|
||||
"types": "./src/number-field/index.tsx",
|
||||
"import": "./src/number-field/index.tsx"
|
||||
},
|
||||
"./pagination": {
|
||||
"types": "./src/pagination/index.tsx",
|
||||
"import": "./src/pagination/index.tsx"
|
||||
},
|
||||
"./radio": {
|
||||
"types": "./src/radio/index.tsx",
|
||||
"import": "./src/radio/index.tsx"
|
||||
|
||||
293
packages/dify-ui/src/pagination/__tests__/index.spec.tsx
Normal file
293
packages/dify-ui/src/pagination/__tests__/index.spec.tsx
Normal file
@ -0,0 +1,293 @@
|
||||
import { render } from 'vitest-browser-react'
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationNavigation,
|
||||
PaginationNext,
|
||||
PaginationPage,
|
||||
PaginationPageJump,
|
||||
PaginationPageList,
|
||||
PaginationPageSize,
|
||||
PaginationPrevious,
|
||||
PaginationRoot,
|
||||
PaginationSkeleton,
|
||||
} from '../index'
|
||||
|
||||
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
|
||||
|
||||
async function renderPagination({
|
||||
page = 2,
|
||||
totalPages = 200,
|
||||
onPageChange = vi.fn(),
|
||||
pageSize = 25,
|
||||
onPageSizeChange = vi.fn(),
|
||||
}: {
|
||||
page?: number
|
||||
totalPages?: number
|
||||
onPageChange?: (page: number) => void
|
||||
pageSize?: number
|
||||
onPageSizeChange?: (pageSize: number) => void
|
||||
} = {}) {
|
||||
const screen = await render(
|
||||
<PaginationRoot
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={onPageChange}
|
||||
data-testid="pagination"
|
||||
>
|
||||
<PaginationContent data-testid="content">
|
||||
<PaginationNavigation data-testid="controls">
|
||||
<PaginationPrevious />
|
||||
<PaginationPageJump />
|
||||
<PaginationNext />
|
||||
</PaginationNavigation>
|
||||
<PaginationPageList data-testid="pages" />
|
||||
<PaginationPageSize
|
||||
value={pageSize}
|
||||
options={[10, 25, 50]}
|
||||
onValueChange={onPageSizeChange}
|
||||
/>
|
||||
</PaginationContent>
|
||||
</PaginationRoot>,
|
||||
)
|
||||
|
||||
return {
|
||||
screen,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
}
|
||||
}
|
||||
|
||||
describe('Pagination primitive', () => {
|
||||
it('renders the Figma-aligned pagination structure with semantic navigation', async () => {
|
||||
const { screen } = await renderPagination()
|
||||
|
||||
await expect.element(screen.getByRole('navigation', { name: 'Pagination' })).toHaveAttribute('data-page', '2')
|
||||
await expect.element(screen.getByTestId('content')).toHaveClass('grid', 'grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)]')
|
||||
await expect.element(screen.getByTestId('controls')).toHaveClass('justify-self-start', 'rounded-[10px]', 'bg-background-section-burn')
|
||||
await expect.element(screen.getByRole('list')).toHaveClass('col-start-2', 'justify-self-center')
|
||||
expect(screen.getByRole('group', { name: 'Items per page' }).element().parentElement).toHaveClass('col-start-3', 'justify-self-end')
|
||||
await expect.element(screen.getByRole('button', { name: 'Previous page' })).toBeInTheDocument()
|
||||
await expect.element(screen.getByRole('button', { name: 'Next page' })).toBeInTheDocument()
|
||||
await expect.element(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' })).toHaveTextContent('2/200')
|
||||
await expect.element(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' })).toHaveClass('h-7', 'px-2')
|
||||
expect(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' }).element()).not.toHaveClass('min-w-14')
|
||||
await expect.element(screen.getByRole('button', { name: 'Page 2, current page' })).toHaveAttribute('aria-current', 'page')
|
||||
await expect.element(screen.getByRole('button', { name: 'Page 2, current page' })).toHaveClass('bg-components-button-tertiary-bg')
|
||||
await expect.element(screen.getByText('…')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses one-based page changes for previous, next, and page buttons', async () => {
|
||||
const { screen, onPageChange } = await renderPagination({ page: 4 })
|
||||
|
||||
asHTMLElement(screen.getByRole('button', { name: 'Previous page' }).element()).click()
|
||||
asHTMLElement(screen.getByRole('button', { name: 'Next page' }).element()).click()
|
||||
asHTMLElement(screen.getByRole('button', { name: 'Go to page 6' }).element()).click()
|
||||
|
||||
expect(onPageChange).toHaveBeenNthCalledWith(1, 3)
|
||||
expect(onPageChange).toHaveBeenNthCalledWith(2, 5)
|
||||
expect(onPageChange).toHaveBeenNthCalledWith(3, 6)
|
||||
})
|
||||
|
||||
it('disables previous at the first page', async () => {
|
||||
const { screen } = await renderPagination({ page: 1, totalPages: 10 })
|
||||
|
||||
await expect.element(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('disables next at the last page', async () => {
|
||||
const { screen } = await renderPagination({ page: 10, totalPages: 10 })
|
||||
|
||||
await expect.element(screen.getByRole('button', { name: 'Next page' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('clamps invalid root page values without exposing invalid state', async () => {
|
||||
const { screen } = await renderPagination({ page: 999, totalPages: 10 })
|
||||
|
||||
await expect.element(screen.getByRole('navigation', { name: 'Pagination' })).toHaveAttribute('data-page', '10')
|
||||
await expect.element(screen.getByRole('button', { name: 'Page 10, current page' })).toHaveAttribute('aria-current', 'page')
|
||||
})
|
||||
|
||||
it('switches the page summary into a selected labelled number field', async () => {
|
||||
const { screen } = await renderPagination()
|
||||
|
||||
asHTMLElement(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' }).element()).click()
|
||||
|
||||
await expect.element(screen.getByRole('textbox', { name: 'Page number' })).toBeInTheDocument()
|
||||
const input = asHTMLElement(screen.getByRole('textbox', { name: 'Page number' }).element()) as HTMLInputElement
|
||||
|
||||
await expect.element(screen.getByRole('textbox', { name: 'Page number' })).toHaveValue('2')
|
||||
await expect.element(screen.getByRole('textbox', { name: 'Page number' })).toHaveClass('text-center', 'tabular-nums')
|
||||
expect(input.parentElement?.parentElement?.parentElement).toHaveAttribute('data-page-summary', '2/200')
|
||||
await vi.waitFor(() => {
|
||||
expect(input.selectionStart).toBe(0)
|
||||
expect(input.selectionEnd).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('returns to the summary button when the page input loses focus', async () => {
|
||||
const { screen } = await renderPagination()
|
||||
|
||||
asHTMLElement(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' }).element()).click()
|
||||
await expect.element(screen.getByRole('textbox', { name: 'Page number' })).toBeInTheDocument()
|
||||
asHTMLElement(screen.getByRole('textbox', { name: 'Page number' }).element()).blur()
|
||||
|
||||
await expect.element(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('commits the page input editing mode with Enter', async () => {
|
||||
const { screen } = await renderPagination()
|
||||
|
||||
asHTMLElement(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' }).element()).click()
|
||||
await expect.element(screen.getByRole('textbox', { name: 'Page number' })).toBeInTheDocument()
|
||||
const input = asHTMLElement(screen.getByRole('textbox', { name: 'Page number' }).element()) as HTMLInputElement
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.activeElement).toBe(input)
|
||||
})
|
||||
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}))
|
||||
|
||||
await expect.element(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('cancels the page input editing mode with Escape', async () => {
|
||||
const { screen, onPageChange } = await renderPagination()
|
||||
|
||||
asHTMLElement(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' }).element()).click()
|
||||
await expect.element(screen.getByRole('textbox', { name: 'Page number' })).toBeInTheDocument()
|
||||
const input = asHTMLElement(screen.getByRole('textbox', { name: 'Page number' }).element()) as HTMLInputElement
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.activeElement).toBe(input)
|
||||
})
|
||||
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}))
|
||||
|
||||
const summaryButton = screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' })
|
||||
await expect.element(summaryButton).toBeInTheDocument()
|
||||
await vi.waitFor(() => {
|
||||
expect(document.activeElement).toBe(summaryButton.element())
|
||||
})
|
||||
expect(onPageChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses Base UI ToggleGroup semantics for page size', async () => {
|
||||
const { screen, onPageSizeChange } = await renderPagination()
|
||||
|
||||
await expect.element(screen.getByRole('group', { name: 'Items per page' })).toHaveClass('bg-components-segmented-control-bg-normal')
|
||||
await expect.element(screen.getByText('Items per page')).toHaveClass('opacity-0', 'group-hover/page-size:opacity-100', 'group-focus-within/page-size:opacity-100')
|
||||
await expect.element(screen.getByRole('button', { name: '25' })).toHaveAttribute('aria-pressed', 'true')
|
||||
await expect.element(screen.getByRole('button', { name: '25' })).toHaveClass('data-pressed:text-text-primary')
|
||||
|
||||
asHTMLElement(screen.getByRole('button', { name: '50' }).element()).click()
|
||||
|
||||
expect(onPageSizeChange).toHaveBeenCalledWith(50)
|
||||
})
|
||||
|
||||
it('renders the complete pagination bar with optional page size controls', async () => {
|
||||
const onPageSizeChange = vi.fn()
|
||||
const screen = await render(
|
||||
<Pagination
|
||||
page={2}
|
||||
totalPages={10}
|
||||
onPageChange={vi.fn()}
|
||||
pageSize={{
|
||||
value: 25,
|
||||
options: [10, 25, 50],
|
||||
onValueChange: onPageSizeChange,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByRole('button', { name: 'Edit page number, current page 2 of 10' })).toBeInTheDocument()
|
||||
await expect.element(screen.getByRole('group', { name: 'Items per page' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses a localized action label for editing the page number', async () => {
|
||||
const screen = await render(
|
||||
<Pagination
|
||||
page={2}
|
||||
totalPages={10}
|
||||
onPageChange={vi.fn()}
|
||||
labels={{
|
||||
editPageNumber: (page, totalPages) => `Change page, current page ${page} of ${totalPages}`,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByRole('button', { name: 'Change page, current page 2 of 10' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps facade page numbers centered when page size controls are omitted', async () => {
|
||||
const screen = await render(
|
||||
<Pagination
|
||||
page={2}
|
||||
totalPages={10}
|
||||
onPageChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByRole('navigation', { name: 'Pagination' })).toBeInTheDocument()
|
||||
expect(screen.container.querySelector('nav[aria-label="Pagination"] > div')).toHaveClass('grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)]')
|
||||
await expect.element(screen.getByRole('list')).toHaveClass('col-start-2', 'justify-self-center')
|
||||
})
|
||||
|
||||
it('does not expose invalid page controls when there are no pages', async () => {
|
||||
const screen = await render(
|
||||
<Pagination
|
||||
page={1}
|
||||
totalPages={0}
|
||||
onPageChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.container.querySelector('nav[aria-label="Pagination"]')).not.toBeInTheDocument()
|
||||
expect(screen.container.querySelector('button[aria-label*="current page 1 of 0"]')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('omits compound page jump and page list content for empty pagination state', async () => {
|
||||
const { screen } = await renderPagination({ page: 1, totalPages: 0 })
|
||||
|
||||
await expect.element(screen.getByRole('navigation', { name: 'Pagination' })).toHaveAttribute('data-page', '1')
|
||||
expect(screen.container.querySelector('button[aria-label*="current page 1 of 0"]')).not.toBeInTheDocument()
|
||||
expect(screen.container.querySelector('button[aria-label="Previous page"]')).not.toBeInTheDocument()
|
||||
expect(screen.container.querySelector('button[aria-label="Next page"]')).not.toBeInTheDocument()
|
||||
expect(screen.container.querySelector('ol')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('allows custom page rendering while keeping the shared context', async () => {
|
||||
const onPageChange = vi.fn()
|
||||
const screen = await render(
|
||||
<PaginationRoot page={3} totalPages={5} onPageChange={onPageChange}>
|
||||
<ol>
|
||||
<li>
|
||||
<PaginationPage page={4} className="custom-page">
|
||||
Four
|
||||
</PaginationPage>
|
||||
</li>
|
||||
</ol>
|
||||
</PaginationRoot>,
|
||||
)
|
||||
|
||||
asHTMLElement(screen.getByRole('button', { name: 'Go to page 4' }).element()).click()
|
||||
|
||||
await expect.element(screen.getByRole('button', { name: 'Go to page 4' })).toHaveClass('custom-page')
|
||||
expect(onPageChange).toHaveBeenCalledWith(4)
|
||||
})
|
||||
|
||||
it('renders a non-interactive loading skeleton', async () => {
|
||||
const screen = await render(<PaginationSkeleton data-testid="skeleton" />)
|
||||
|
||||
await expect.element(screen.getByTestId('skeleton')).toHaveAttribute('aria-hidden', 'true')
|
||||
await expect.element(screen.getByTestId('skeleton')).toHaveClass('select-none')
|
||||
})
|
||||
})
|
||||
93
packages/dify-ui/src/pagination/index.stories.tsx
Normal file
93
packages/dify-ui/src/pagination/index.stories.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import type { ComponentProps } from 'react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Pagination,
|
||||
PaginationSkeleton,
|
||||
} from '.'
|
||||
|
||||
function PaginationExample({
|
||||
initialPage = 2,
|
||||
initialPageSize = 25,
|
||||
totalPages = 200,
|
||||
}: {
|
||||
initialPage?: number
|
||||
initialPageSize?: number
|
||||
totalPages?: number
|
||||
}) {
|
||||
const [page, setPage] = useState(initialPage)
|
||||
const [pageSize, setPageSize] = useState(initialPageSize)
|
||||
|
||||
return (
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
pageSize={{
|
||||
value: pageSize,
|
||||
options: [10, 25, 50],
|
||||
onValueChange: setPageSize,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationDemo(props: ComponentProps<typeof PaginationExample>) {
|
||||
return (
|
||||
<div className="w-236 max-w-full bg-components-panel-bg px-16 py-10">
|
||||
<PaginationExample {...props} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DesignSpecDemo() {
|
||||
return (
|
||||
<div className="flex w-236 max-w-full flex-col gap-6 bg-components-panel-bg px-16 py-10">
|
||||
<PaginationExample />
|
||||
<PaginationExample initialPage={2} initialPageSize={25} />
|
||||
<PaginationExample initialPage={2} initialPageSize={25} />
|
||||
<PaginationExample initialPage={2} initialPageSize={25} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/UI/Pagination',
|
||||
component: PaginationDemo,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Compound pagination primitive for list navigation. It combines semantic page buttons, a NumberField-backed page jump summary, and a ToggleGroup-backed page-size selector.',
|
||||
},
|
||||
},
|
||||
},
|
||||
args: {
|
||||
initialPage: 2,
|
||||
initialPageSize: 25,
|
||||
totalPages: 200,
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof PaginationDemo>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {
|
||||
render: () => <PaginationDemo />,
|
||||
}
|
||||
|
||||
export const DesignSpec: Story = {
|
||||
render: () => <DesignSpecDemo />,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Pagination rows with default, hover-like, focused, page-size, and skeleton examples.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Loading: Story = {
|
||||
render: () => <PaginationSkeleton />,
|
||||
}
|
||||
655
packages/dify-ui/src/pagination/index.tsx
Normal file
655
packages/dify-ui/src/pagination/index.tsx
Normal file
@ -0,0 +1,655 @@
|
||||
'use client'
|
||||
|
||||
import type { Button as BaseButtonNS } from '@base-ui/react/button'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Button as BaseButton } from '@base-ui/react/button'
|
||||
import { mergeProps } from '@base-ui/react/merge-props'
|
||||
import { useRender } from '@base-ui/react/use-render'
|
||||
import { createContext, useContext, useMemo, useRef, useState } from 'react'
|
||||
import { cn } from '../cn'
|
||||
import {
|
||||
NumberField,
|
||||
NumberFieldGroup,
|
||||
NumberFieldInput,
|
||||
} from '../number-field'
|
||||
import {
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from '../toggle-group'
|
||||
|
||||
type PageItem = number | 'ellipsis-start' | 'ellipsis-end'
|
||||
|
||||
type PaginationContextValue = {
|
||||
page: number
|
||||
totalPages: number
|
||||
hasPages: boolean
|
||||
disabled: boolean
|
||||
onPageChange: (page: number) => void
|
||||
items: PageItem[]
|
||||
}
|
||||
|
||||
const PaginationContext = createContext<PaginationContextValue | null>(null)
|
||||
|
||||
function usePaginationContext(component: string) {
|
||||
const context = useContext(PaginationContext)
|
||||
|
||||
if (!context)
|
||||
throw new Error(`${component} must be used inside PaginationRoot.`)
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function clampPage(page: number, totalPages: number) {
|
||||
if (!Number.isFinite(page))
|
||||
return 1
|
||||
|
||||
return Math.min(Math.max(Math.trunc(page), 1), Math.max(totalPages, 1))
|
||||
}
|
||||
|
||||
function range(start: number, end: number) {
|
||||
if (end < start)
|
||||
return []
|
||||
|
||||
return Array.from({ length: end - start + 1 }, (_, index) => start + index)
|
||||
}
|
||||
|
||||
type GetPageItemsOptions = {
|
||||
page: number
|
||||
totalPages: number
|
||||
siblingCount: number
|
||||
boundaryCount: number
|
||||
visiblePageCount: number
|
||||
}
|
||||
|
||||
function getPageItems({
|
||||
page,
|
||||
totalPages,
|
||||
siblingCount,
|
||||
boundaryCount,
|
||||
visiblePageCount,
|
||||
}: GetPageItemsOptions): PageItem[] {
|
||||
if (totalPages <= 0)
|
||||
return []
|
||||
|
||||
const normalizedPage = clampPage(page, totalPages)
|
||||
const normalizedBoundaryCount = Math.max(Math.trunc(boundaryCount), 1)
|
||||
const normalizedSiblingCount = Math.max(Math.trunc(siblingCount), 0)
|
||||
const windowSize = Math.max(
|
||||
Math.trunc(visiblePageCount),
|
||||
normalizedSiblingCount * 2 + 1,
|
||||
)
|
||||
|
||||
if (totalPages <= windowSize + normalizedBoundaryCount)
|
||||
return range(1, totalPages)
|
||||
|
||||
const nearStartEnd = windowSize
|
||||
const nearEndStart = totalPages - windowSize + 1
|
||||
const middleStart = Math.max(
|
||||
normalizedBoundaryCount + 1,
|
||||
normalizedPage - normalizedSiblingCount,
|
||||
)
|
||||
const middleEnd = Math.min(
|
||||
totalPages - normalizedBoundaryCount,
|
||||
normalizedPage + normalizedSiblingCount,
|
||||
)
|
||||
|
||||
const windowPages = normalizedPage <= nearStartEnd - normalizedSiblingCount
|
||||
? range(1, nearStartEnd)
|
||||
: normalizedPage >= nearEndStart + normalizedSiblingCount
|
||||
? range(nearEndStart, totalPages)
|
||||
: range(middleStart, middleEnd)
|
||||
|
||||
const pageSet = new Set([
|
||||
...range(1, normalizedBoundaryCount),
|
||||
...windowPages,
|
||||
...range(totalPages - normalizedBoundaryCount + 1, totalPages),
|
||||
])
|
||||
const pages = Array.from(pageSet)
|
||||
.filter(item => item >= 1 && item <= totalPages)
|
||||
.sort((a, b) => a - b)
|
||||
|
||||
return pages.reduce<PageItem[]>((items, item, index) => {
|
||||
const previous = pages[index - 1]
|
||||
|
||||
if (previous && item - previous === 2)
|
||||
items.push(previous + 1)
|
||||
else if (previous && item - previous > 2)
|
||||
items.push(item < normalizedPage ? 'ellipsis-start' : 'ellipsis-end')
|
||||
|
||||
items.push(item)
|
||||
return items
|
||||
}, [])
|
||||
}
|
||||
|
||||
type PaginationRootState = {
|
||||
page: number
|
||||
totalPages: number
|
||||
hasPages: boolean
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
export type PaginationRootProps = Omit<
|
||||
useRender.ComponentProps<'nav', PaginationRootState>,
|
||||
'onChange'
|
||||
> & {
|
||||
page: number
|
||||
totalPages: number
|
||||
onPageChange: (page: number) => void
|
||||
siblingCount?: number
|
||||
boundaryCount?: number
|
||||
visiblePageCount?: number
|
||||
}
|
||||
|
||||
export function PaginationRoot({
|
||||
page,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
siblingCount = 1,
|
||||
boundaryCount = 1,
|
||||
visiblePageCount = 8,
|
||||
render,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: PaginationRootProps) {
|
||||
const normalizedTotalPages = Math.max(Math.trunc(totalPages), 0)
|
||||
const normalizedPage = clampPage(page, normalizedTotalPages)
|
||||
const hasPages = normalizedTotalPages > 0
|
||||
const disabled = normalizedTotalPages <= 1
|
||||
const items = useMemo(() => getPageItems({
|
||||
page: normalizedPage,
|
||||
totalPages: normalizedTotalPages,
|
||||
siblingCount,
|
||||
boundaryCount,
|
||||
visiblePageCount,
|
||||
}), [
|
||||
boundaryCount,
|
||||
normalizedPage,
|
||||
normalizedTotalPages,
|
||||
siblingCount,
|
||||
visiblePageCount,
|
||||
])
|
||||
|
||||
const context = useMemo<PaginationContextValue>(() => ({
|
||||
page: normalizedPage,
|
||||
totalPages: normalizedTotalPages,
|
||||
hasPages,
|
||||
disabled,
|
||||
onPageChange: nextPage => onPageChange(clampPage(nextPage, normalizedTotalPages)),
|
||||
items,
|
||||
}), [disabled, hasPages, items, normalizedPage, normalizedTotalPages, onPageChange])
|
||||
|
||||
const defaultProps: useRender.ElementProps<'nav'> = {
|
||||
'aria-label': 'Pagination',
|
||||
'className': cn('flex w-full min-w-0 items-center justify-between px-6 py-3 select-none', className),
|
||||
'children': (
|
||||
<PaginationContext.Provider value={context}>
|
||||
{children}
|
||||
</PaginationContext.Provider>
|
||||
),
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: 'nav',
|
||||
render,
|
||||
state: {
|
||||
page: normalizedPage,
|
||||
totalPages: normalizedTotalPages,
|
||||
hasPages,
|
||||
disabled,
|
||||
},
|
||||
props: mergeProps<'nav'>(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
export type PaginationNavigationProps = useRender.ComponentProps<'div'>
|
||||
|
||||
export type PaginationContentProps = useRender.ComponentProps<'div'>
|
||||
|
||||
export function PaginationContent({
|
||||
render,
|
||||
className,
|
||||
...props
|
||||
}: PaginationContentProps) {
|
||||
const defaultProps: useRender.ElementProps<'div'> = {
|
||||
className: cn('grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-2', className),
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: 'div',
|
||||
render,
|
||||
props: mergeProps<'div'>(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
export function PaginationNavigation({
|
||||
render,
|
||||
className,
|
||||
...props
|
||||
}: PaginationNavigationProps) {
|
||||
const defaultProps: useRender.ElementProps<'div'> = {
|
||||
className: cn('flex shrink-0 items-center justify-self-start gap-0.5 rounded-[10px] bg-background-section-burn p-0.5', className),
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: 'div',
|
||||
render,
|
||||
props: mergeProps<'div'>(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
type PaginationButtonProps = Omit<BaseButtonNS.Props, 'children'> & {
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
const paginationArrowButtonClassName = [
|
||||
'inline-flex size-7 shrink-0 touch-manipulation items-center justify-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg text-components-button-secondary-text shadow-xs outline-hidden backdrop-blur-[10px] transition-[background-color,border-color,color,box-shadow]',
|
||||
'hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover',
|
||||
'focus-visible:ring-2 focus-visible:ring-components-input-border-hover',
|
||||
'disabled:cursor-not-allowed disabled:border-components-button-secondary-border-disabled disabled:bg-components-button-secondary-bg-disabled disabled:text-components-button-secondary-text-disabled disabled:shadow-none',
|
||||
'motion-reduce:transition-none',
|
||||
]
|
||||
|
||||
export function PaginationPrevious({
|
||||
className,
|
||||
children,
|
||||
'aria-label': ariaLabel,
|
||||
...props
|
||||
}: PaginationButtonProps) {
|
||||
const pagination = usePaginationContext('PaginationPrevious')
|
||||
|
||||
if (!pagination.hasPages)
|
||||
return null
|
||||
|
||||
const disabled = props.disabled || pagination.page <= 1 || pagination.disabled
|
||||
|
||||
return (
|
||||
<BaseButton
|
||||
{...props}
|
||||
type="button"
|
||||
aria-label={ariaLabel ?? 'Previous page'}
|
||||
className={cn(paginationArrowButtonClassName, className)}
|
||||
disabled={disabled}
|
||||
onClick={(event) => {
|
||||
props.onClick?.(event)
|
||||
|
||||
if (!event.defaultPrevented && !disabled)
|
||||
pagination.onPageChange(pagination.page - 1)
|
||||
}}
|
||||
>
|
||||
{children ?? <span className="i-ri-arrow-left-line size-4" aria-hidden="true" />}
|
||||
</BaseButton>
|
||||
)
|
||||
}
|
||||
|
||||
export function PaginationNext({
|
||||
className,
|
||||
children,
|
||||
'aria-label': ariaLabel,
|
||||
...props
|
||||
}: PaginationButtonProps) {
|
||||
const pagination = usePaginationContext('PaginationNext')
|
||||
|
||||
if (!pagination.hasPages)
|
||||
return null
|
||||
|
||||
const disabled = props.disabled || pagination.page >= pagination.totalPages || pagination.disabled
|
||||
|
||||
return (
|
||||
<BaseButton
|
||||
{...props}
|
||||
type="button"
|
||||
aria-label={ariaLabel ?? 'Next page'}
|
||||
className={cn(paginationArrowButtonClassName, className)}
|
||||
disabled={disabled}
|
||||
onClick={(event) => {
|
||||
props.onClick?.(event)
|
||||
|
||||
if (!event.defaultPrevented && !disabled)
|
||||
pagination.onPageChange(pagination.page + 1)
|
||||
}}
|
||||
>
|
||||
{children ?? <span className="i-ri-arrow-right-line size-4" aria-hidden="true" />}
|
||||
</BaseButton>
|
||||
)
|
||||
}
|
||||
|
||||
export type PaginationPageJumpProps = Omit<BaseButtonNS.Props, 'children'> & {
|
||||
inputLabel?: string
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export function PaginationPageJump({
|
||||
className,
|
||||
inputLabel = 'Page number',
|
||||
children,
|
||||
'aria-label': ariaLabel,
|
||||
...props
|
||||
}: PaginationPageJumpProps) {
|
||||
const pagination = usePaginationContext('PaginationPageJump')
|
||||
const [editing, setEditing] = useState(false)
|
||||
const summaryButtonRef = useRef<HTMLButtonElement | null>(null)
|
||||
|
||||
if (!pagination.hasPages)
|
||||
return null
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<span
|
||||
data-page-summary={`${pagination.page}/${pagination.totalPages}`}
|
||||
className="inline-grid h-7 system-xs-medium tabular-nums after:invisible after:col-start-1 after:row-start-1 after:py-1.5 after:pr-3 after:pl-2 after:content-[attr(data-page-summary)]"
|
||||
>
|
||||
<NumberField
|
||||
key={pagination.page}
|
||||
className="col-start-1 row-start-1 w-full"
|
||||
defaultValue={pagination.page}
|
||||
min={1}
|
||||
max={Math.max(pagination.totalPages, 1)}
|
||||
onValueCommitted={(value) => {
|
||||
if (value !== null)
|
||||
pagination.onPageChange(value)
|
||||
|
||||
setEditing(false)
|
||||
}}
|
||||
>
|
||||
<NumberFieldGroup
|
||||
className="h-7 w-full min-w-0 rounded-lg border-[0.5px] border-components-input-border-active bg-components-input-bg-active shadow-xs"
|
||||
>
|
||||
<NumberFieldInput
|
||||
aria-label={inputLabel}
|
||||
autoFocus
|
||||
className="px-2 py-1.5 text-center system-xs-medium tabular-nums"
|
||||
onBlur={() => requestAnimationFrame(() => setEditing(false))}
|
||||
onFocus={(event) => {
|
||||
const input = event.currentTarget
|
||||
requestAnimationFrame(() => input.select())
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
event.currentTarget.blur()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
setEditing(false)
|
||||
requestAnimationFrame(() => summaryButtonRef.current?.focus())
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</NumberFieldGroup>
|
||||
</NumberField>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseButton
|
||||
{...props}
|
||||
ref={summaryButtonRef}
|
||||
type="button"
|
||||
aria-label={ariaLabel ?? `Edit page number, current page ${pagination.page} of ${pagination.totalPages}`}
|
||||
className={cn(
|
||||
'inline-flex h-7 touch-manipulation items-center justify-center gap-0.5 rounded-lg px-2 py-1.5 system-xs-medium tabular-nums text-text-secondary outline-hidden transition-colors hover:cursor-text hover:bg-state-base-hover-alt focus-visible:ring-2 focus-visible:ring-components-input-border-hover motion-reduce:transition-none',
|
||||
className,
|
||||
)}
|
||||
onClick={(event) => {
|
||||
props.onClick?.(event)
|
||||
|
||||
if (!event.defaultPrevented)
|
||||
setEditing(true)
|
||||
}}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<span>{pagination.page}</span>
|
||||
<span className="text-text-quaternary">/</span>
|
||||
<span>{pagination.totalPages}</span>
|
||||
</>
|
||||
)}
|
||||
</BaseButton>
|
||||
)
|
||||
}
|
||||
|
||||
export type PaginationPageListProps = useRender.ComponentProps<'ol'>
|
||||
|
||||
export function PaginationPageList({
|
||||
render,
|
||||
className,
|
||||
...props
|
||||
}: PaginationPageListProps) {
|
||||
const pagination = usePaginationContext('PaginationPageList')
|
||||
|
||||
if (!pagination.hasPages)
|
||||
return null
|
||||
|
||||
const defaultProps: useRender.ElementProps<'ol'> = {
|
||||
className: cn('col-start-2 flex min-w-0 list-none items-center justify-self-center gap-0.5', className),
|
||||
children: pagination.items.map(item => (
|
||||
<li key={item}>
|
||||
{typeof item === 'number'
|
||||
? <PaginationPage page={item} />
|
||||
: <PaginationEllipsis />}
|
||||
</li>
|
||||
)),
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: 'ol',
|
||||
render,
|
||||
props: mergeProps<'ol'>(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
export type PaginationPageProps = Omit<BaseButtonNS.Props, 'children'> & {
|
||||
page: number
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export function PaginationPage({
|
||||
page,
|
||||
className,
|
||||
children,
|
||||
'aria-label': ariaLabel,
|
||||
...props
|
||||
}: PaginationPageProps) {
|
||||
const pagination = usePaginationContext('PaginationPage')
|
||||
const current = page === pagination.page
|
||||
|
||||
return (
|
||||
<BaseButton
|
||||
{...props}
|
||||
type="button"
|
||||
aria-current={current ? 'page' : undefined}
|
||||
aria-label={ariaLabel ?? (current ? `Page ${page}, current page` : `Go to page ${page}`)}
|
||||
className={cn(
|
||||
'inline-flex h-8 min-w-8 touch-manipulation items-center justify-center rounded-lg px-1 py-2 system-sm-medium tabular-nums text-text-tertiary outline-hidden transition-colors hover:bg-components-button-ghost-bg-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-components-input-border-hover',
|
||||
current && 'bg-components-button-tertiary-bg text-components-button-tertiary-text hover:bg-components-button-ghost-bg-hover',
|
||||
'motion-reduce:transition-none',
|
||||
className,
|
||||
)}
|
||||
onClick={(event) => {
|
||||
props.onClick?.(event)
|
||||
|
||||
if (!event.defaultPrevented)
|
||||
pagination.onPageChange(page)
|
||||
}}
|
||||
>
|
||||
{children ?? page}
|
||||
</BaseButton>
|
||||
)
|
||||
}
|
||||
|
||||
export type PaginationEllipsisProps = useRender.ComponentProps<'span'>
|
||||
|
||||
export function PaginationEllipsis({
|
||||
render,
|
||||
className,
|
||||
...props
|
||||
}: PaginationEllipsisProps) {
|
||||
const defaultProps: useRender.ElementProps<'span'> = {
|
||||
'aria-hidden': true,
|
||||
'className': cn('flex size-8 items-center justify-center px-1 py-2 system-sm-medium text-text-tertiary', className),
|
||||
'children': '…',
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: 'span',
|
||||
render,
|
||||
props: mergeProps<'span'>(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
export type PaginationPageSizeProps<Value extends number = number> = {
|
||||
'value': Value
|
||||
'options': readonly Value[]
|
||||
'onValueChange': (value: Value) => void
|
||||
'label'?: ReactNode
|
||||
'aria-label'?: string
|
||||
'className'?: string
|
||||
}
|
||||
|
||||
export function PaginationPageSize<Value extends number = number>({
|
||||
value,
|
||||
options,
|
||||
onValueChange,
|
||||
label = 'Items per page',
|
||||
'aria-label': ariaLabel = 'Items per page',
|
||||
className,
|
||||
}: PaginationPageSizeProps<Value>) {
|
||||
return (
|
||||
<div className={cn('group/page-size col-start-3 flex shrink-0 items-center justify-end justify-self-end gap-2', className)}>
|
||||
<div className="w-13 shrink-0 text-end system-2xs-regular-uppercase text-text-tertiary opacity-0 transition-opacity group-hover/page-size:opacity-100 group-focus-within/page-size:opacity-100 motion-reduce:transition-none">
|
||||
{label}
|
||||
</div>
|
||||
<ToggleGroup
|
||||
value={[String(value)]}
|
||||
aria-label={ariaLabel}
|
||||
onValueChange={(nextValue) => {
|
||||
const [selectedValue] = nextValue
|
||||
|
||||
if (!selectedValue)
|
||||
return
|
||||
|
||||
const selectedOption = options.find(option => String(option) === selectedValue)
|
||||
|
||||
if (selectedOption !== undefined)
|
||||
onValueChange(selectedOption)
|
||||
}}
|
||||
>
|
||||
{options.map(option => (
|
||||
<ToggleGroupItem
|
||||
key={option}
|
||||
value={String(option)}
|
||||
className="min-w-9 data-pressed:text-text-primary"
|
||||
>
|
||||
{option}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type PaginationLabels = {
|
||||
previous?: string
|
||||
next?: string
|
||||
editPageNumber?: (page: number, totalPages: number) => string
|
||||
pageNumberInput?: string
|
||||
}
|
||||
|
||||
export type PaginationPageSizeConfig<Value extends number = number> = {
|
||||
value: Value
|
||||
options: readonly Value[]
|
||||
onValueChange: (value: Value) => void
|
||||
label?: ReactNode
|
||||
ariaLabel?: string
|
||||
}
|
||||
|
||||
export type PaginationProps<Value extends number = number> = Omit<PaginationRootProps, 'children'> & {
|
||||
labels?: PaginationLabels
|
||||
pageSize?: PaginationPageSizeConfig<Value>
|
||||
}
|
||||
|
||||
export function Pagination<Value extends number = number>({
|
||||
labels,
|
||||
pageSize,
|
||||
page,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
...props
|
||||
}: PaginationProps<Value>) {
|
||||
const normalizedTotalPages = Math.max(Math.trunc(totalPages), 0)
|
||||
const normalizedPage = clampPage(page, normalizedTotalPages)
|
||||
const editPageNumber = labels?.editPageNumber?.(normalizedPage, normalizedTotalPages)
|
||||
|
||||
if (normalizedTotalPages <= 0)
|
||||
return null
|
||||
|
||||
return (
|
||||
<PaginationRoot
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={onPageChange}
|
||||
{...props}
|
||||
>
|
||||
<PaginationContent>
|
||||
<PaginationNavigation>
|
||||
<PaginationPrevious aria-label={labels?.previous} />
|
||||
<PaginationPageJump
|
||||
aria-label={editPageNumber}
|
||||
inputLabel={labels?.pageNumberInput}
|
||||
/>
|
||||
<PaginationNext aria-label={labels?.next} />
|
||||
</PaginationNavigation>
|
||||
<PaginationPageList />
|
||||
{pageSize && (
|
||||
<PaginationPageSize
|
||||
value={pageSize.value}
|
||||
options={pageSize.options}
|
||||
onValueChange={pageSize.onValueChange}
|
||||
label={pageSize.label}
|
||||
aria-label={pageSize.ariaLabel}
|
||||
/>
|
||||
)}
|
||||
</PaginationContent>
|
||||
</PaginationRoot>
|
||||
)
|
||||
}
|
||||
|
||||
export type PaginationSkeletonProps = useRender.ComponentProps<'div'>
|
||||
|
||||
export function PaginationSkeleton({
|
||||
render,
|
||||
className,
|
||||
...props
|
||||
}: PaginationSkeletonProps) {
|
||||
const defaultProps: useRender.ElementProps<'div'> = {
|
||||
'aria-hidden': true,
|
||||
'className': cn('flex w-full min-w-0 items-center justify-between px-6 py-3 select-none', className),
|
||||
'children': (
|
||||
<div className="grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-2">
|
||||
<div className="flex shrink-0 items-center justify-self-start gap-0.5 rounded-[10px] bg-background-section-burn p-0.5">
|
||||
<div className="size-7 animate-pulse rounded-lg bg-state-base-hover motion-reduce:animate-none" />
|
||||
<div className="h-7 min-w-14 animate-pulse rounded-lg bg-state-base-hover motion-reduce:animate-none" />
|
||||
<div className="size-7 animate-pulse rounded-lg bg-state-base-hover motion-reduce:animate-none" />
|
||||
</div>
|
||||
<div className="col-start-2 flex items-center justify-self-center gap-0.5">
|
||||
{range(1, 8).map(item => (
|
||||
<div key={item} className="h-8 min-w-8 animate-pulse rounded-lg bg-state-base-hover motion-reduce:animate-none" />
|
||||
))}
|
||||
</div>
|
||||
<div className="col-start-3 flex shrink-0 items-center justify-self-end">
|
||||
<div className="h-8 w-28 animate-pulse rounded-[10px] bg-state-base-hover motion-reduce:animate-none" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: 'div',
|
||||
render,
|
||||
props: mergeProps<'div'>(defaultProps, props),
|
||||
})
|
||||
}
|
||||
@ -10,7 +10,11 @@ export default defineConfig({
|
||||
tsconfigPaths: true,
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['@base-ui/react/form'],
|
||||
include: [
|
||||
'@base-ui/react/form',
|
||||
'@base-ui/react/merge-props',
|
||||
'@base-ui/react/use-render',
|
||||
],
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 2.25C3.41421 2.25 3.75 2.58579 3.75 3V15C3.75 15.4142 3.41421 15.75 3 15.75C2.58579 15.75 2.25 15.4142 2.25 15V3C2.25 2.58579 2.58579 2.25 3 2.25Z" fill="#676F83"/>
|
||||
<path d="M15 2.25C15.4142 2.25 15.75 2.58579 15.75 3V15C15.75 15.4142 15.4142 15.75 15 15.75C14.5858 15.75 14.25 15.4142 14.25 15V3C14.25 2.58579 14.5858 2.25 15 2.25Z" fill="#676F83"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.125 4.5C10.5392 4.5 10.875 4.83579 10.875 5.25V12.75C10.875 13.1642 10.5392 13.5 10.125 13.5H7.875C7.46079 13.5 7.125 13.1642 7.125 12.75V5.25C7.125 4.83579 7.46079 4.5 7.875 4.5H10.125ZM8.625 12H9.375V6H8.625V12Z" fill="#676F83"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 751 B |
@ -1,5 +0,0 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15 14.25C15.4142 14.25 15.75 14.5858 15.75 15C15.75 15.4142 15.4142 15.75 15 15.75H3C2.58579 15.75 2.25 15.4142 2.25 15C2.25 14.5858 2.58579 14.25 3 14.25H15Z" fill="#676F83"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.5 7.125C13.9142 7.125 14.25 7.46079 14.25 7.875V10.125C14.25 10.5392 13.9142 10.875 13.5 10.875H4.5C4.08579 10.875 3.75 10.5392 3.75 10.125V7.875C3.75 7.46079 4.08579 7.125 4.5 7.125H13.5ZM5.25 9.375H12.75V8.625H5.25V9.375Z" fill="#676F83"/>
|
||||
<path d="M15 2.25C15.4142 2.25 15.75 2.58579 15.75 3C15.75 3.41421 15.4142 3.75 15 3.75H3C2.58579 3.75 2.25 3.41421 2.25 3C2.25 2.58579 2.58579 2.25 3 2.25H15Z" fill="#676F83"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 763 B |
@ -1,3 +0,0 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 15V3.75V12.2625V10.6688V15ZM2.5 16.5C1.94772 16.5 1.5 16.0523 1.5 15.5V3.25C1.5 2.69771 1.94772 2.25 2.5 2.25H15.5C16.0523 2.25 16.5 2.69772 16.5 3.25V10.5H15V3.75H3V15H9V16.5H2.5ZM13.0125 17.25L10.35 14.5875L11.4188 13.5375L13.0125 15.1312L16.2 11.9438L17.25 13.0125L13.0125 17.25ZM7.5 9.75H13.5V8.25H7.5V9.75ZM7.5 6.75H13.5V5.25H7.5V6.75ZM4.5 9.75H6V8.25H4.5V9.75ZM4.5 6.75H6V5.25H4.5V6.75Z" fill="#495464"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 526 B |
@ -1,3 +0,0 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.33317 3.33333H7.33317V12.6667H5.33317V14H10.6665V12.6667H8.6665V3.33333H10.6665V2H5.33317V3.33333ZM1.33317 4.66667C0.964984 4.66667 0.666504 4.96515 0.666504 5.33333V10.6667C0.666504 11.0349 0.964984 11.3333 1.33317 11.3333H5.33317V10H1.99984V6H5.33317V4.66667H1.33317ZM10.6665 6H13.9998V10H10.6665V11.3333H14.6665C15.0347 11.3333 15.3332 11.0349 15.3332 10.6667V5.33333C15.3332 4.96515 15.0347 4.66667 14.6665 4.66667H10.6665V6Z" fill="#354052"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 563 B |
@ -513,27 +513,12 @@
|
||||
"width": 14,
|
||||
"height": 14
|
||||
},
|
||||
"line-others-dhs": {
|
||||
"body": "<g fill=\"currentColor\"><path d=\"M3 2.25a.75.75 0 0 1 .75.75v12a.75.75 0 0 1-1.5 0V3A.75.75 0 0 1 3 2.25m12 0a.75.75 0 0 1 .75.75v12a.75.75 0 0 1-1.5 0V3a.75.75 0 0 1 .75-.75\"/><path fill-rule=\"evenodd\" d=\"M10.125 4.5a.75.75 0 0 1 .75.75v7.5a.75.75 0 0 1-.75.75h-2.25a.75.75 0 0 1-.75-.75v-7.5a.75.75 0 0 1 .75-.75zm-1.5 7.5h.75V6h-.75z\" clip-rule=\"evenodd\"/></g>",
|
||||
"width": 18,
|
||||
"height": 18
|
||||
},
|
||||
"line-others-drag-handle": {
|
||||
"body": "<g fill=\"none\"><g id=\"Drag Handle\"><path id=\"drag-handle\" fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M6 5C6.55228 5 7 4.55228 7 4C7 3.44772 6.55228 3 6 3C5.44772 3 5 3.44772 5 4C5 4.55228 5.44772 5 6 5ZM6 9C6.55228 9 7 8.55228 7 8C7 7.44772 6.55228 7 6 7C5.44772 7 5 7.44772 5 8C5 8.55228 5.44772 9 6 9ZM11 4C11 4.55228 10.5523 5 10 5C9.44772 5 9 4.55228 9 4C9 3.44772 9.44772 3 10 3C10.5523 3 11 3.44772 11 4ZM10 9C10.5523 9 11 8.55228 11 8C11 7.44772 10.5523 7 10 7C9.44772 7 9 7.44772 9 8C9 8.55228 9.44772 9 10 9ZM7 12C7 12.5523 6.55228 13 6 13C5.44772 13 5 12.5523 5 12C5 11.4477 5.44772 11 6 11C6.55228 11 7 11.4477 7 12ZM10 13C10.5523 13 11 12.5523 11 12C11 11.4477 10.5523 11 10 11C9.44772 11 9 11.4477 9 12C9 12.5523 9.44772 13 10 13Z\" fill=\"currentColor\"/></g></g>"
|
||||
},
|
||||
"line-others-dvs": {
|
||||
"body": "<g fill=\"currentColor\"><path d=\"M15 14.25a.75.75 0 0 1 0 1.5H3a.75.75 0 0 1 0-1.5z\"/><path fill-rule=\"evenodd\" d=\"M13.5 7.125a.75.75 0 0 1 .75.75v2.25a.75.75 0 0 1-.75.75h-9a.75.75 0 0 1-.75-.75v-2.25a.75.75 0 0 1 .75-.75zm-8.25 2.25h7.5v-.75h-7.5z\" clip-rule=\"evenodd\"/><path d=\"M15 2.25a.75.75 0 0 1 0 1.5H3a.75.75 0 0 1 0-1.5z\"/></g>",
|
||||
"width": 18,
|
||||
"height": 18
|
||||
},
|
||||
"line-others-env": {
|
||||
"body": "<g fill=\"none\"><g id=\"env\"><g id=\"Vector\"><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M1.33325 3.33325C1.33325 2.22868 2.22868 1.33325 3.33325 1.33325H12.6666C13.7712 1.33325 14.6666 2.22869 14.6666 3.33325V3.66659C14.6666 4.03478 14.3681 4.33325 13.9999 4.33325C13.6317 4.33325 13.3333 4.03478 13.3333 3.66659V3.33325C13.3333 2.96506 13.0348 2.66659 12.6666 2.66659H3.33325C2.96506 2.66659 2.66659 2.96506 2.66659 3.33325V3.66659C2.66659 4.03478 2.36811 4.33325 1.99992 4.33325C1.63173 4.33325 1.33325 4.03478 1.33325 3.66659V3.33325Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M14.6666 12.6666C14.6666 13.7712 13.7712 14.6666 12.6666 14.6666L3.33325 14.6666C2.22866 14.6666 1.33325 13.7711 1.33325 12.6666L1.33325 12.3333C1.33325 11.9651 1.63173 11.6666 1.99992 11.6666C2.36811 11.6666 2.66659 11.9651 2.66659 12.3333V12.6666C2.66659 13.0348 2.96505 13.3333 3.33325 13.3333L12.6666 13.3333C13.0348 13.3333 13.3333 13.0348 13.3333 12.6666V12.3333C13.3333 11.9651 13.6317 11.6666 13.9999 11.6666C14.3681 11.6666 14.6666 11.9651 14.6666 12.3333V12.6666Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M1.33325 5.99992C1.33325 5.63173 1.63173 5.33325 1.99992 5.33325H4.33325C4.70144 5.33325 4.99992 5.63173 4.99992 5.99992C4.99992 6.36811 4.70144 6.66658 4.33325 6.66658H2.66659V7.33325H3.99992C4.36811 7.33325 4.66659 7.63173 4.66659 7.99992C4.66659 8.36811 4.36811 8.66658 3.99992 8.66658H2.66659V9.33325H4.33325C4.70144 9.33325 4.99992 9.63173 4.99992 9.99992C4.99992 10.3681 4.70144 10.6666 4.33325 10.6666H1.99992C1.63173 10.6666 1.33325 10.3681 1.33325 9.99992V5.99992Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M6.4734 5.36186C6.75457 5.27673 7.05833 5.38568 7.22129 5.63012L8.66659 7.79807V5.99992C8.66659 5.63173 8.96506 5.33325 9.33325 5.33325C9.70144 5.33325 9.99992 5.63173 9.99992 5.99992V9.99992C9.99992 10.2937 9.80761 10.5528 9.52644 10.638C9.24527 10.7231 8.94151 10.6142 8.77855 10.3697L7.33325 8.20177V9.99992C7.33325 10.3681 7.03478 10.6666 6.66659 10.6666C6.2984 10.6666 5.99992 10.3681 5.99992 9.99992V5.99992C5.99992 5.70614 6.19222 5.44699 6.4734 5.36186Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M11.0768 5.38453C11.4167 5.24292 11.807 5.40364 11.9486 5.74351L12.9999 8.26658L14.0512 5.74351C14.1928 5.40364 14.5831 5.24292 14.923 5.38453C15.2629 5.52614 15.4236 5.91646 15.282 6.25633L13.6153 10.2563C13.5118 10.5048 13.2691 10.6666 12.9999 10.6666C12.7308 10.6666 12.488 10.5048 12.3845 10.2563L10.7179 6.25633C10.5763 5.91646 10.737 5.52614 11.0768 5.38453Z\" fill=\"currentColor\"/></g></g></g>"
|
||||
},
|
||||
"line-others-evaluation": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M3 15V3.75v8.513v-1.594zm-.5 1.5a1 1 0 0 1-1-1V3.25a1 1 0 0 1 1-1h13a1 1 0 0 1 1 1v7.25H15V3.75H3V15h6v1.5zm10.513.75l-2.663-2.662l1.069-1.05l1.593 1.593l3.188-3.187l1.05 1.068zM7.5 9.75h6v-1.5h-6zm0-3h6v-1.5h-6zm-3 3H6v-1.5H4.5zm0-3H6v-1.5H4.5z\"/>",
|
||||
"width": 18,
|
||||
"height": 18
|
||||
},
|
||||
"line-others-global-variable": {
|
||||
"body": "<g fill=\"none\"><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M6.23814 1.33333H9.76188C10.4844 1.33332 11.0672 1.33332 11.5391 1.37187C12.025 1.41157 12.4518 1.49545 12.8466 1.69664C13.4739 2.01622 13.9838 2.52615 14.3034 3.15336C14.5046 3.54822 14.5884 3.97501 14.6281 4.46091C14.6667 4.93283 14.6667 5.51559 14.6667 6.23811V9.76188C14.6667 10.4844 14.6667 11.0672 14.6281 11.5391C14.5884 12.025 14.5046 12.4518 14.3034 12.8466C13.9838 13.4738 13.4739 13.9838 12.8466 14.3033C12.4518 14.5045 12.025 14.5884 11.5391 14.6281C11.0672 14.6667 10.4844 14.6667 9.7619 14.6667H6.23812C5.51561 14.6667 4.93284 14.6667 4.46093 14.6281C3.97503 14.5884 3.54824 14.5045 3.15338 14.3033C2.52617 13.9838 2.01623 13.4738 1.69666 12.8466C1.49546 12.4518 1.41159 12.025 1.37189 11.5391C1.33333 11.0672 1.33334 10.4844 1.33334 9.76187V6.23812C1.33334 5.5156 1.33333 4.93283 1.37189 4.46091C1.41159 3.97501 1.49546 3.54822 1.69666 3.15336C2.01623 2.52615 2.52617 2.01622 3.15338 1.69664C3.54824 1.49545 3.97503 1.41157 4.46093 1.37187C4.93285 1.33332 5.51561 1.33332 6.23814 1.33333ZM4.5695 2.70078C4.16606 2.73374 3.93427 2.79519 3.7587 2.88465C3.38237 3.0764 3.07641 3.38236 2.88466 3.75868C2.79521 3.93425 2.73376 4.16604 2.70079 4.56949C2.6672 4.98072 2.66668 5.50892 2.66668 6.26666V9.73333C2.66668 10.4911 2.6672 11.0193 2.70079 11.4305C2.73376 11.8339 2.79521 12.0657 2.88466 12.2413C3.07641 12.6176 3.38237 12.9236 3.7587 13.1153C3.93427 13.2048 4.16606 13.2662 4.5695 13.2992C4.98073 13.3328 5.50894 13.3333 6.26668 13.3333H9.73334C10.4911 13.3333 11.0193 13.3328 11.4305 13.2992C11.834 13.2662 12.0658 13.2048 12.2413 13.1153C12.6176 12.9236 12.9236 12.6176 13.1154 12.2413C13.2048 12.0657 13.2663 11.8339 13.2992 11.4305C13.3328 11.0193 13.3333 10.4911 13.3333 9.73333V6.26666C13.3333 5.50892 13.3328 4.98072 13.2992 4.56949C13.2663 4.16604 13.2048 3.93425 13.1154 3.75868C12.9236 3.38236 12.6176 3.0764 12.2413 2.88465C12.0658 2.79519 11.834 2.73374 11.4305 2.70078C11.0193 2.66718 10.4911 2.66666 9.73334 2.66666H6.26668C5.50894 2.66666 4.98073 2.66718 4.5695 2.70078ZM5.08339 5.33333C5.08339 4.96514 5.38187 4.66666 5.75006 4.66666H6.68433C7.324 4.66666 7.87606 5.09677 8.04724 5.70542L8.30138 6.60902L9.2915 5.43554C9.7018 4.94926 10.3035 4.66666 10.9399 4.66666H11C11.3682 4.66666 11.6667 4.96514 11.6667 5.33333C11.6667 5.70152 11.3682 5.99999 11 5.99999H10.9399C10.7005 5.99999 10.4702 6.10616 10.3106 6.29537L8.73751 8.15972L9.23641 9.93357C9.24921 9.97909 9.28574 10 9.31579 10H10.2501C10.6182 10 10.9167 10.2985 10.9167 10.6667C10.9167 11.0349 10.6182 11.3333 10.2501 11.3333H9.31579C8.67612 11.3333 8.12406 10.9032 7.95288 10.2946L7.69871 9.39088L6.70852 10.5644C6.29822 11.0507 5.6965 11.3333 5.06011 11.3333H5.00001C4.63182 11.3333 4.33334 11.0349 4.33334 10.6667C4.33334 10.2985 4.63182 10 5.00001 10H5.06011C5.29949 10 5.52982 9.89383 5.68946 9.70462L7.26258 7.84019L6.76371 6.06642C6.75091 6.0209 6.71438 5.99999 6.68433 5.99999H5.75006C5.38187 5.99999 5.08339 5.70152 5.08339 5.33333Z\" fill=\"currentColor\"/></g>"
|
||||
},
|
||||
@ -1040,11 +1025,6 @@
|
||||
"workflow-if-else": {
|
||||
"body": "<g fill=\"none\"><g id=\"icons/if-else\"><path id=\"Vector (Stroke)\" fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M8.16667 2.98975C7.80423 2.98975 7.51042 2.69593 7.51042 2.3335C7.51042 1.97106 7.80423 1.67725 8.16667 1.67725H11.0833C11.4458 1.67725 11.7396 1.97106 11.7396 2.3335V5.25016C11.7396 5.6126 11.4458 5.90641 11.0833 5.90641C10.7209 5.90641 10.4271 5.6126 10.4271 5.25016V3.91782L7.34474 7.00016L10.4271 10.0825V8.75016C10.4271 8.38773 10.7209 8.09391 11.0833 8.09391C11.4458 8.09391 11.7396 8.38773 11.7396 8.75016V11.6668C11.7396 12.0293 11.4458 12.3231 11.0833 12.3231H8.16667C7.80423 12.3231 7.51042 12.0293 7.51042 11.6668C7.51042 11.3044 7.80423 11.0106 8.16667 11.0106H9.49901L6.14484 7.65641H1.75C1.38756 7.65641 1.09375 7.3626 1.09375 7.00016C1.09375 6.63773 1.38756 6.34391 1.75 6.34391H6.14484L9.49901 2.98975H8.16667Z\" fill=\"currentColor\"/></g></g>"
|
||||
},
|
||||
"workflow-input-field": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M5.333 3.333h2v9.334h-2V14h5.333v-1.333h-2V3.333h2V2H5.333zm-4 1.334a.667.667 0 0 0-.666.666v5.334c0 .368.298.666.666.666h4V10H2V6h3.333V4.667zM10.667 6H14v4h-3.334v1.333h4a.667.667 0 0 0 .667-.666V5.333a.667.667 0 0 0-.667-.666h-4z\"/>",
|
||||
"width": 16,
|
||||
"height": 16
|
||||
},
|
||||
"workflow-iteration": {
|
||||
"body": "<g fill=\"none\"><g id=\"icons/iteration\"><path id=\"Vector\" d=\"M6.82849 0.754349C6.6007 0.526545 6.23133 0.526545 6.00354 0.754349C5.77573 0.982158 5.77573 1.3515 6.00354 1.57931L6.82849 0.754349ZM8.16602 2.91683L8.57849 3.32931C8.80628 3.1015 8.80628 2.73216 8.57849 2.50435L8.16602 2.91683ZM6.00354 4.25435C5.77573 4.48216 5.77573 4.8515 6.00354 5.07931C6.23133 5.30711 6.6007 5.30711 6.82849 5.07931L6.00354 4.25435ZM7.99516 9.74597C8.22295 9.51818 8.22295 9.14881 7.99516 8.92102C7.76737 8.69323 7.398 8.69323 7.17021 8.92102L7.99516 9.74597ZM5.83268 11.0835L5.4202 10.671C5.1924 10.8988 5.1924 11.2682 5.4202 11.496L5.83268 11.0835ZM7.17021 13.246C7.398 13.4738 7.76737 13.4738 7.99516 13.246C8.22295 13.0182 8.22295 12.6488 7.99516 12.421L7.17021 13.246ZM11.4993 3.73414C11.2738 3.50404 10.9045 3.5003 10.6744 3.72578C10.4443 3.95127 10.4405 4.32059 10.6661 4.55069L11.4993 3.73414ZM7.58268 3.50016C7.90486 3.50016 8.16602 3.23899 8.16602 2.91683C8.16602 2.59467 7.90486 2.3335 7.58268 2.3335L7.58268 3.50016ZM2.49938 10.2662C2.72486 10.4963 3.09419 10.5 3.32429 10.2745C3.55439 10.0491 3.55814 9.6797 3.33266 9.44964L2.49938 10.2662ZM6.00354 1.57931L7.75354 3.32931L8.57849 2.50435L6.82849 0.754349L6.00354 1.57931ZM7.75354 2.50435L6.00354 4.25435L6.82849 5.07931L8.57849 3.32931L7.75354 2.50435ZM7.17021 8.92102L5.4202 10.671L6.24516 11.496L7.99516 9.74597L7.17021 8.92102ZM5.4202 11.496L7.17021 13.246L7.99516 12.421L6.24516 10.671L5.4202 11.496ZM8.16602 10.5002L6.41602 10.5002V11.6668L8.16602 11.6668V10.5002ZM11.666 7.00016C11.666 8.93316 10.099 10.5002 8.16602 10.5002V11.6668C10.7434 11.6668 12.8327 9.57751 12.8327 7.00016H11.666ZM12.8327 7.00016C12.8327 5.72882 12.3235 4.57524 11.4993 3.73414L10.6661 4.55069C11.2852 5.18256 11.666 6.0463 11.666 7.00016H12.8327ZM5.83268 3.50016H7.58268L7.58268 2.3335H5.83268L5.83268 3.50016ZM2.33268 7.00016C2.33268 5.06717 3.89968 3.50016 5.83268 3.50016L5.83268 2.3335C3.25535 2.3335 1.16602 4.42283 1.16602 7.00016H2.33268ZM1.16602 7.00016C1.16602 8.27148 1.67517 9.42508 2.49938 10.2662L3.33266 9.44964C2.71348 8.81777 2.33268 7.95403 2.33268 7.00016H1.16602Z\" fill=\"currentColor\"/></g></g>"
|
||||
},
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"prefix": "custom-vender",
|
||||
"name": "Dify Custom Vender",
|
||||
"total": 281,
|
||||
"total": 277,
|
||||
"version": "0.0.0-private",
|
||||
"author": {
|
||||
"name": "LangGenius, Inc.",
|
||||
|
||||
@ -340,11 +340,16 @@ describe('App List Browsing Flow', () => {
|
||||
|
||||
// -- Tab navigation --
|
||||
describe('Tab Navigation', () => {
|
||||
it('should render the app type dropdown trigger', () => {
|
||||
it('should render all category tabs', () => {
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.studio.filters.types')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.advanced')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.chatbot')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.agent')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.completion')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -375,19 +380,21 @@ describe('App List Browsing Flow', () => {
|
||||
|
||||
// -- "Created by me" filter --
|
||||
describe('Created By Me Filter', () => {
|
||||
it('should not render a standalone "created by me" checkbox in the current header layout', () => {
|
||||
it('should render the "created by me" checkbox', () => {
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
renderList()
|
||||
|
||||
expect(screen.queryByText('app.showMyCreatedAppsOnly')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep the current layout stable without a "created by me" control', () => {
|
||||
it('should toggle the "created by me" filter on click', () => {
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.studio.filters.types')).toBeInTheDocument()
|
||||
expect(screen.queryByText('app.showMyCreatedAppsOnly')).not.toBeInTheDocument()
|
||||
const checkbox = screen.getByText('app.showMyCreatedAppsOnly')
|
||||
fireEvent.click(checkbox)
|
||||
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ import Loading from '@/app/components/base/loading'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { usePathname, useRouter } from '@/next/navigation'
|
||||
|
||||
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/snippets', '/explore', '/tools'] as const
|
||||
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/explore', '/tools'] as const
|
||||
|
||||
const isPathUnderRoute = (pathname: string, route: string) => pathname === route || pathname.startsWith(`${route}/`)
|
||||
|
||||
|
||||
@ -1,11 +0,0 @@
|
||||
import SnippetPage from '@/app/components/snippets'
|
||||
|
||||
const Page = async (props: {
|
||||
params: Promise<{ snippetId: string }>
|
||||
}) => {
|
||||
const { snippetId } = await props.params
|
||||
|
||||
return <SnippetPage snippetId={snippetId} />
|
||||
}
|
||||
|
||||
export default Page
|
||||
@ -1,21 +0,0 @@
|
||||
import Page from './page'
|
||||
|
||||
const mockRedirect = vi.fn()
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
redirect: (path: string) => mockRedirect(path),
|
||||
}))
|
||||
|
||||
describe('snippet detail redirect page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should redirect legacy snippet detail routes to orchestrate', async () => {
|
||||
await Page({
|
||||
params: Promise.resolve({ snippetId: 'snippet-1' }),
|
||||
})
|
||||
|
||||
expect(mockRedirect).toHaveBeenCalledWith('/snippets/snippet-1/orchestrate')
|
||||
})
|
||||
})
|
||||
@ -1,11 +0,0 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
const Page = async (props: {
|
||||
params: Promise<{ snippetId: string }>
|
||||
}) => {
|
||||
const { snippetId } = await props.params
|
||||
|
||||
redirect(`/snippets/${snippetId}/orchestrate`)
|
||||
}
|
||||
|
||||
export default Page
|
||||
@ -1,7 +0,0 @@
|
||||
import SnippetList from '@/app/components/snippet-list'
|
||||
|
||||
const SnippetsPage = () => {
|
||||
return <SnippetList />
|
||||
}
|
||||
|
||||
export default SnippetsPage
|
||||
@ -168,21 +168,6 @@ describe('AppDetailNav', () => {
|
||||
)
|
||||
expect(screen.queryByTestId('extra-info')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render custom header and navigation when provided', () => {
|
||||
render(
|
||||
<AppDetailNav
|
||||
navigation={navigation}
|
||||
renderHeader={mode => <div data-testid="custom-header" data-mode={mode} />}
|
||||
renderNavigation={mode => <div data-testid="custom-navigation" data-mode={mode} />}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('custom-header')).toHaveAttribute('data-mode', 'expand')
|
||||
expect(screen.getByTestId('custom-navigation')).toHaveAttribute('data-mode', 'expand')
|
||||
expect(screen.queryByTestId('app-info')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('nav-link-Overview')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Workflow canvas mode', () => {
|
||||
|
||||
@ -37,16 +37,12 @@ type IAppDetailNavProps = {
|
||||
disabled?: boolean
|
||||
}>
|
||||
extraInfo?: (modeState: string) => React.ReactNode
|
||||
renderHeader?: (modeState: string) => React.ReactNode
|
||||
renderNavigation?: (modeState: string) => React.ReactNode
|
||||
appInfoActions?: AppInfoActions
|
||||
}
|
||||
|
||||
const AppDetailNav = ({
|
||||
navigation,
|
||||
extraInfo,
|
||||
renderHeader,
|
||||
renderNavigation,
|
||||
iconType = 'app',
|
||||
appInfoActions,
|
||||
}: IAppDetailNavProps) => {
|
||||
@ -126,20 +122,18 @@ const AppDetailNav = ({
|
||||
expand ? 'p-2' : 'p-1',
|
||||
)}
|
||||
>
|
||||
{renderHeader
|
||||
? renderHeader(appSidebarExpand)
|
||||
: iconType === 'app' && (
|
||||
appInfoActions
|
||||
? (
|
||||
<AppInfoView
|
||||
expand={expand}
|
||||
actions={appInfoActions}
|
||||
renderDetail={false}
|
||||
/>
|
||||
)
|
||||
: <AppInfo expand={expand} />
|
||||
)}
|
||||
{!renderHeader && iconType !== 'app' && (
|
||||
{iconType === 'app' && (
|
||||
appInfoActions
|
||||
? (
|
||||
<AppInfoView
|
||||
expand={expand}
|
||||
actions={appInfoActions}
|
||||
renderDetail={false}
|
||||
/>
|
||||
)
|
||||
: <AppInfo expand={expand} />
|
||||
)}
|
||||
{iconType !== 'app' && (
|
||||
<DatasetInfo expand={expand} />
|
||||
)}
|
||||
</div>
|
||||
@ -168,20 +162,18 @@ const AppDetailNav = ({
|
||||
expand ? 'px-3 py-2' : 'p-3',
|
||||
)}
|
||||
>
|
||||
{renderNavigation
|
||||
? renderNavigation(appSidebarExpand)
|
||||
: navigation.map((item, index) => {
|
||||
return (
|
||||
<NavLink
|
||||
key={index}
|
||||
mode={appSidebarExpand}
|
||||
iconMap={{ selected: item.selectedIcon, normal: item.icon }}
|
||||
name={item.name}
|
||||
href={item.href}
|
||||
disabled={!!item.disabled}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{navigation.map((item, index) => {
|
||||
return (
|
||||
<NavLink
|
||||
key={index}
|
||||
mode={appSidebarExpand}
|
||||
iconMap={{ selected: item.selectedIcon, normal: item.icon }}
|
||||
name={item.name}
|
||||
href={item.href}
|
||||
disabled={!!item.disabled}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
{iconType !== 'app' && extraInfo && extraInfo(appSidebarExpand)}
|
||||
</div>
|
||||
|
||||
@ -262,20 +262,4 @@ describe('NavLink Animation and Layout Issues', () => {
|
||||
expect(iconWrapper).toHaveClass('-ml-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Button Mode', () => {
|
||||
it('should render as an interactive button when href is omitted', () => {
|
||||
const onClick = vi.fn()
|
||||
|
||||
render(<NavLink {...mockProps} href={undefined} active={true} onClick={onClick} />)
|
||||
|
||||
const buttonElement = screen.getByText('Orchestrate').closest('button')
|
||||
expect(buttonElement).not.toBeNull()
|
||||
expect(buttonElement).toHaveClass('bg-components-menu-item-bg-active')
|
||||
expect(buttonElement).toHaveClass('text-text-accent-light-mode-only')
|
||||
|
||||
buttonElement?.click()
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -14,15 +14,13 @@ export type NavIcon = React.ComponentType<
|
||||
|
||||
export type NavLinkProps = {
|
||||
name: string
|
||||
href?: string
|
||||
href: string
|
||||
iconMap: {
|
||||
selected: NavIcon
|
||||
normal: NavIcon
|
||||
}
|
||||
mode?: string
|
||||
disabled?: boolean
|
||||
active?: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const NavLink = ({
|
||||
@ -31,8 +29,6 @@ const NavLink = ({
|
||||
iconMap,
|
||||
mode = 'expand',
|
||||
disabled = false,
|
||||
active,
|
||||
onClick,
|
||||
}: NavLinkProps) => {
|
||||
const segment = useSelectedLayoutSegment()
|
||||
const formattedSegment = (() => {
|
||||
@ -43,11 +39,8 @@ const NavLink = ({
|
||||
|
||||
return res
|
||||
})()
|
||||
const isActive = active ?? (href ? href.toLowerCase().split('/')?.pop() === formattedSegment : false)
|
||||
const isActive = href.toLowerCase().split('/')?.pop() === formattedSegment
|
||||
const NavIcon = isActive ? iconMap.selected : iconMap.normal
|
||||
const linkClassName = cn(isActive
|
||||
? 'border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only system-sm-semibold'
|
||||
: 'text-components-menu-item-text system-sm-medium hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pl-3 pr-1')
|
||||
|
||||
const renderIcon = () => (
|
||||
<div className={cn(mode !== 'expand' && '-ml-1')}>
|
||||
@ -77,32 +70,13 @@ const NavLink = ({
|
||||
)
|
||||
}
|
||||
|
||||
if (!href) {
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
className={linkClassName}
|
||||
title={mode === 'collapse' ? name : ''}
|
||||
onClick={onClick}
|
||||
>
|
||||
{renderIcon()}
|
||||
<span
|
||||
className={cn('overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out', mode === 'expand'
|
||||
? 'ml-2 max-w-none opacity-100'
|
||||
: 'ml-0 max-w-0 opacity-0')}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={name}
|
||||
href={href}
|
||||
className={linkClassName}
|
||||
className={cn(isActive
|
||||
? 'border-t-[0.75px] border-r-[0.25px] border-b-[0.25px] border-l-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active system-sm-semibold text-text-accent-light-mode-only'
|
||||
: 'system-sm-medium text-components-menu-item-text hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pr-1 pl-3')}
|
||||
title={mode === 'collapse' ? name : ''}
|
||||
>
|
||||
{renderIcon()}
|
||||
|
||||
@ -1,270 +0,0 @@
|
||||
import type { CreateSnippetDialogPayload } from '@/app/components/snippets/create-snippet-dialog'
|
||||
import type { SnippetDetail } from '@/models/snippet'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import SnippetInfoDropdown from '../dropdown'
|
||||
|
||||
const mockReplace = vi.fn()
|
||||
const mockDownloadBlob = vi.fn()
|
||||
const mockToastSuccess = vi.fn()
|
||||
const mockToastError = vi.fn()
|
||||
const mockUpdateMutate = vi.fn()
|
||||
const mockExportMutateAsync = vi.fn()
|
||||
const mockDeleteMutate = vi.fn()
|
||||
let mockDropdownOpen = false
|
||||
let mockDropdownOnOpenChange: ((open: boolean) => void) | undefined
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
replace: mockReplace,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/download', () => ({
|
||||
downloadBlob: (args: { data: Blob, fileName: string }) => mockDownloadBlob(args),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: {
|
||||
success: (...args: unknown[]) => mockToastSuccess(...args),
|
||||
error: (...args: unknown[]) => mockToastError(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/dropdown-menu', () => ({
|
||||
DropdownMenu: ({
|
||||
open,
|
||||
onOpenChange,
|
||||
children,
|
||||
}: {
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
children: React.ReactNode
|
||||
}) => {
|
||||
mockDropdownOpen = !!open
|
||||
mockDropdownOnOpenChange = onOpenChange
|
||||
return <div>{children}</div>
|
||||
},
|
||||
DropdownMenuTrigger: ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
onClick={() => mockDropdownOnOpenChange?.(!mockDropdownOpen)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
|
||||
mockDropdownOpen ? <div>{children}</div> : null
|
||||
),
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick?: () => void
|
||||
}) => (
|
||||
<button type="button" onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-snippets', () => ({
|
||||
useUpdateSnippetMutation: () => ({
|
||||
mutate: mockUpdateMutate,
|
||||
isPending: false,
|
||||
}),
|
||||
useExportSnippetMutation: () => ({
|
||||
mutateAsync: mockExportMutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
useDeleteSnippetMutation: () => ({
|
||||
mutate: mockDeleteMutate,
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
type MockCreateSnippetDialogProps = {
|
||||
isOpen: boolean
|
||||
title?: string
|
||||
confirmText?: string
|
||||
initialValue?: {
|
||||
name?: string
|
||||
description?: string
|
||||
}
|
||||
onClose: () => void
|
||||
onConfirm: (payload: CreateSnippetDialogPayload) => void
|
||||
}
|
||||
|
||||
vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({
|
||||
default: ({
|
||||
isOpen,
|
||||
title,
|
||||
confirmText,
|
||||
initialValue,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: MockCreateSnippetDialogProps) => {
|
||||
if (!isOpen)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div data-testid="create-snippet-dialog">
|
||||
<div>{title}</div>
|
||||
<div>{confirmText}</div>
|
||||
<div>{initialValue?.name}</div>
|
||||
<div>{initialValue?.description}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onConfirm({
|
||||
name: 'Updated snippet',
|
||||
description: 'Updated description',
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
})}
|
||||
>
|
||||
submit-edit
|
||||
</button>
|
||||
<button type="button" onClick={onClose}>close-edit</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
const mockSnippet: SnippetDetail = {
|
||||
id: 'snippet-1',
|
||||
name: 'Social Media Repurposer',
|
||||
description: 'Turn one blog post into multiple social media variations.',
|
||||
updatedAt: '2026-03-25 10:00',
|
||||
usage: '12',
|
||||
tags: [],
|
||||
status: undefined,
|
||||
}
|
||||
|
||||
describe('SnippetInfoDropdown', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDropdownOpen = false
|
||||
mockDropdownOnOpenChange = undefined
|
||||
})
|
||||
|
||||
// Rendering coverage for the menu trigger itself.
|
||||
describe('Rendering', () => {
|
||||
it('should render the dropdown trigger button', () => {
|
||||
render(<SnippetInfoDropdown snippet={mockSnippet} />)
|
||||
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edit flow should seed the dialog with current snippet info and submit updates.
|
||||
describe('Edit Snippet', () => {
|
||||
it('should open the edit dialog and submit snippet updates', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockUpdateMutate.mockImplementation((_variables: unknown, options?: { onSuccess?: () => void }) => {
|
||||
options?.onSuccess?.()
|
||||
})
|
||||
|
||||
render(<SnippetInfoDropdown snippet={mockSnippet} />)
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('snippet.menu.editInfo'))
|
||||
|
||||
expect(screen.getByTestId('create-snippet-dialog')).toBeInTheDocument()
|
||||
expect(screen.getByText('snippet.editDialogTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.operation.save')).toBeInTheDocument()
|
||||
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
|
||||
expect(screen.getByText(mockSnippet.description)).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'submit-edit' }))
|
||||
|
||||
expect(mockUpdateMutate).toHaveBeenCalledWith({
|
||||
params: { snippetId: mockSnippet.id },
|
||||
body: {
|
||||
name: 'Updated snippet',
|
||||
description: 'Updated description',
|
||||
},
|
||||
}, expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
onError: expect.any(Function),
|
||||
}))
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('snippet.editDone')
|
||||
})
|
||||
})
|
||||
|
||||
// Export should call the export hook and download the returned YAML blob.
|
||||
describe('Export Snippet', () => {
|
||||
it('should export and download the snippet yaml', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockExportMutateAsync.mockResolvedValue('yaml: content')
|
||||
|
||||
render(<SnippetInfoDropdown snippet={mockSnippet} />)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('snippet.menu.exportSnippet'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockExportMutateAsync).toHaveBeenCalledWith({ snippetId: mockSnippet.id })
|
||||
})
|
||||
|
||||
expect(mockDownloadBlob).toHaveBeenCalledWith({
|
||||
data: expect.any(Blob),
|
||||
fileName: `${mockSnippet.name}.yml`,
|
||||
})
|
||||
})
|
||||
|
||||
it('should show an error toast when export fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockExportMutateAsync.mockRejectedValue(new Error('export failed'))
|
||||
|
||||
render(<SnippetInfoDropdown snippet={mockSnippet} />)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('snippet.menu.exportSnippet'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastError).toHaveBeenCalledWith('snippet.exportFailed')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Delete should require confirmation and redirect after a successful mutation.
|
||||
describe('Delete Snippet', () => {
|
||||
it('should confirm deletion and redirect to the snippets list', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockDeleteMutate.mockImplementation((_variables: unknown, options?: { onSuccess?: () => void }) => {
|
||||
options?.onSuccess?.()
|
||||
})
|
||||
|
||||
render(<SnippetInfoDropdown snippet={mockSnippet} />)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('snippet.menu.deleteSnippet'))
|
||||
|
||||
expect(screen.getByText('snippet.deleteConfirmTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('snippet.deleteConfirmContent')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'snippet.menu.deleteSnippet' }))
|
||||
|
||||
expect(mockDeleteMutate).toHaveBeenCalledWith({
|
||||
params: { snippetId: mockSnippet.id },
|
||||
}, expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
onError: expect.any(Function),
|
||||
}))
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('snippet.deleted')
|
||||
expect(mockReplace).toHaveBeenCalledWith('/snippets')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,60 +0,0 @@
|
||||
import type { SnippetDetail } from '@/models/snippet'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import SnippetInfo from '..'
|
||||
|
||||
vi.mock('../dropdown', () => ({
|
||||
default: () => <div data-testid="snippet-info-dropdown" />,
|
||||
}))
|
||||
|
||||
const mockSnippet: SnippetDetail = {
|
||||
id: 'snippet-1',
|
||||
name: 'Social Media Repurposer',
|
||||
description: 'Turn one blog post into multiple social media variations.',
|
||||
updatedAt: '2026-03-25 10:00',
|
||||
usage: '12',
|
||||
tags: [],
|
||||
status: undefined,
|
||||
}
|
||||
|
||||
describe('SnippetInfo', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests for the collapsed and expanded sidebar header states.
|
||||
describe('Rendering', () => {
|
||||
it('should render the expanded snippet details and dropdown when expand is true', () => {
|
||||
render(<SnippetInfo expand={true} snippet={mockSnippet} />)
|
||||
|
||||
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
|
||||
expect(screen.getByText('snippet.typeLabel')).toBeInTheDocument()
|
||||
expect(screen.getByText(mockSnippet.description)).toBeInTheDocument()
|
||||
expect(screen.getByTestId('snippet-info-dropdown')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide the expanded-only content when expand is false', () => {
|
||||
render(<SnippetInfo expand={false} snippet={mockSnippet} />)
|
||||
|
||||
expect(screen.queryByText(mockSnippet.name)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('snippet.typeLabel')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(mockSnippet.description)).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('snippet-info-dropdown')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases around optional snippet fields should not break the header layout.
|
||||
describe('Edge Cases', () => {
|
||||
it('should omit the description block when the snippet has no description', () => {
|
||||
render(
|
||||
<SnippetInfo
|
||||
expand={true}
|
||||
snippet={{ ...mockSnippet, description: '' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
|
||||
expect(screen.queryByText(mockSnippet.description)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,177 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { SnippetDetail } from '@/models/snippet'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { useDeleteSnippetMutation, useExportSnippetMutation, useUpdateSnippetMutation } from '@/service/use-snippets'
|
||||
|
||||
import { downloadBlob } from '@/utils/download'
|
||||
|
||||
type SnippetInfoDropdownProps = {
|
||||
snippet: SnippetDetail
|
||||
}
|
||||
|
||||
const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
const { replace } = useRouter()
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false)
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false)
|
||||
const updateSnippetMutation = useUpdateSnippetMutation()
|
||||
const exportSnippetMutation = useExportSnippetMutation()
|
||||
const deleteSnippetMutation = useDeleteSnippetMutation()
|
||||
|
||||
const initialValue = React.useMemo(() => ({
|
||||
name: snippet.name,
|
||||
description: snippet.description,
|
||||
}), [snippet.description, snippet.name])
|
||||
|
||||
const handleOpenEditDialog = React.useCallback(() => {
|
||||
setOpen(false)
|
||||
setIsEditDialogOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleExportSnippet = React.useCallback(async () => {
|
||||
setOpen(false)
|
||||
try {
|
||||
const data = await exportSnippetMutation.mutateAsync({ snippetId: snippet.id })
|
||||
const file = new Blob([data], { type: 'application/yaml' })
|
||||
downloadBlob({ data: file, fileName: `${snippet.name}.yml` })
|
||||
}
|
||||
catch {
|
||||
toast.error(t('exportFailed'))
|
||||
}
|
||||
}, [exportSnippetMutation, snippet.id, snippet.name, t])
|
||||
|
||||
const handleEditSnippet = React.useCallback(async ({ name, description }: {
|
||||
name: string
|
||||
description: string
|
||||
}) => {
|
||||
updateSnippetMutation.mutate({
|
||||
params: { snippetId: snippet.id },
|
||||
body: {
|
||||
name,
|
||||
description: description || undefined,
|
||||
},
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success(t('editDone'))
|
||||
setIsEditDialogOpen(false)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : t('editFailed'))
|
||||
},
|
||||
})
|
||||
}, [snippet.id, t, updateSnippetMutation])
|
||||
|
||||
const handleDeleteSnippet = React.useCallback(() => {
|
||||
deleteSnippetMutation.mutate({
|
||||
params: { snippetId: snippet.id },
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success(t('deleted'))
|
||||
setIsDeleteDialogOpen(false)
|
||||
replace('/snippets')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : t('deleteFailed'))
|
||||
},
|
||||
})
|
||||
}, [deleteSnippetMutation, replace, snippet.id, t])
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger
|
||||
className={cn('action-btn action-btn-m size-6 rounded-md text-text-tertiary', open && 'bg-state-base-hover text-text-secondary')}
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-fill size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
popupClassName="w-[180px] p-1"
|
||||
>
|
||||
<DropdownMenuItem className="mx-0 gap-2" onClick={handleOpenEditDialog}>
|
||||
<span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span className="grow">{t('menu.editInfo')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="mx-0 gap-2" onClick={handleExportSnippet}>
|
||||
<span aria-hidden className="i-ri-download-2-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span className="grow">{t('menu.exportSnippet')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="my-1! bg-divider-subtle" />
|
||||
<DropdownMenuItem
|
||||
className="mx-0 gap-2"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
setIsDeleteDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
|
||||
<span className="grow">{t('menu.deleteSnippet')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{isEditDialogOpen && (
|
||||
<CreateSnippetDialog
|
||||
isOpen={isEditDialogOpen}
|
||||
initialValue={initialValue}
|
||||
title={t('editDialogTitle')}
|
||||
confirmText={t('operation.save', { ns: 'common' })}
|
||||
isSubmitting={updateSnippetMutation.isPending}
|
||||
onClose={() => setIsEditDialogOpen(false)}
|
||||
onConfirm={handleEditSnippet}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent className="w-100">
|
||||
<div className="space-y-2 p-6">
|
||||
<AlertDialogTitle className="title-md-semi-bold text-text-primary">
|
||||
{t('deleteConfirmTitle')}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="system-sm-regular text-text-tertiary">
|
||||
{t('deleteConfirmContent')}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogActions className="pt-0">
|
||||
<AlertDialogCancelButton>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton
|
||||
loading={deleteSnippetMutation.isPending}
|
||||
onClick={handleDeleteSnippet}
|
||||
>
|
||||
{t('menu.deleteSnippet')}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(SnippetInfoDropdown)
|
||||
@ -1,46 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { SnippetDetail } from '@/models/snippet'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import SnippetInfoDropdown from './dropdown'
|
||||
|
||||
type SnippetInfoProps = {
|
||||
expand: boolean
|
||||
snippet: SnippetDetail
|
||||
}
|
||||
|
||||
const SnippetInfo = ({
|
||||
expand,
|
||||
snippet,
|
||||
}: SnippetInfoProps) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
|
||||
if (!expand)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col px-2 pt-2 pb-1">
|
||||
<div className="flex flex-col gap-2 rounded-xl p-2">
|
||||
<div className="flex items-center justify-end">
|
||||
<SnippetInfoDropdown snippet={snippet} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate system-md-semibold text-text-secondary">
|
||||
{snippet.name}
|
||||
</div>
|
||||
<div className="pt-1 system-2xs-medium-uppercase text-text-tertiary">
|
||||
{t('typeLabel')}
|
||||
</div>
|
||||
</div>
|
||||
{snippet.description && (
|
||||
<p className="line-clamp-3 system-xs-regular break-words text-text-tertiary">
|
||||
{snippet.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(SnippetInfo)
|
||||
@ -5,6 +5,7 @@ import type { AnnotationItem, AnnotationItemBasic } from './type'
|
||||
import type { AnnotationReplyConfig } from '@/models/debug'
|
||||
import type { App } from '@/types/app'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Pagination } from '@langgenius/dify-ui/pagination'
|
||||
import { Switch } from '@langgenius/dify-ui/switch'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { RiEqualizer2Line } from '@remixicon/react'
|
||||
@ -16,7 +17,6 @@ import ActionButton from '@/app/components/base/action-button'
|
||||
import ConfigParamModal from '@/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal'
|
||||
import { MessageFast } from '@/app/components/base/icons/src/vender/solid/communication'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import Pagination from '@/app/components/base/pagination'
|
||||
import AnnotationFullModal from '@/app/components/billing/annotation-full/modal'
|
||||
import { APP_PAGE_LIMIT } from '@/config'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
@ -49,6 +49,7 @@ const Annotation: FC<Props> = (props) => {
|
||||
const [limit, setLimit] = useState(APP_PAGE_LIMIT)
|
||||
const [list, setList] = useState<AnnotationItem[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const totalPages = total ? Math.max(Math.ceil(total / limit), 1) : 1
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [controlUpdateList, setControlUpdateList] = useState(() => Date.now())
|
||||
const [currItem, setCurrItem] = useState<AnnotationItem | null>(null)
|
||||
@ -217,11 +218,22 @@ const Annotation: FC<Props> = (props) => {
|
||||
{(total && total > APP_PAGE_LIMIT)
|
||||
? (
|
||||
<Pagination
|
||||
current={currPage}
|
||||
onChange={setCurrPage}
|
||||
total={total}
|
||||
limit={limit}
|
||||
onLimitChange={setLimit}
|
||||
page={currPage + 1}
|
||||
totalPages={totalPages}
|
||||
onPageChange={page => setCurrPage(page - 1)}
|
||||
labels={{
|
||||
previous: t('pagination.previous', { ns: 'common' }),
|
||||
next: t('pagination.next', { ns: 'common' }),
|
||||
editPageNumber: (page, totalPages) => t('pagination.editPageNumber', { ns: 'common', page, totalPages }),
|
||||
pageNumberInput: t('pagination.pageNumber', { ns: 'common' }),
|
||||
}}
|
||||
pageSize={{
|
||||
value: limit,
|
||||
options: [10, 25, 50],
|
||||
onValueChange: setLimit,
|
||||
label: t('pagination.perPage', { ns: 'common' }),
|
||||
ariaLabel: t('pagination.perPage', { ns: 'common' }),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
|
||||
@ -20,12 +20,12 @@ import {
|
||||
DrawerTitle,
|
||||
DrawerViewport,
|
||||
} from '@langgenius/dify-ui/drawer'
|
||||
import { Pagination } from '@langgenius/dify-ui/pagination'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import { MessageCheckRemove } from '@/app/components/base/icons/src/vender/line/communication'
|
||||
import Pagination from '@/app/components/base/pagination'
|
||||
import TabSlider from '@/app/components/base/tab-slider-plain'
|
||||
import { APP_PAGE_LIMIT } from '@/config'
|
||||
import useTimestamp from '@/hooks/use-timestamp'
|
||||
@ -62,6 +62,7 @@ const ViewAnnotationModal: FC<Props> = ({
|
||||
const { formatTime } = useTimestamp()
|
||||
const [currPage, setCurrPage] = React.useState<number>(0)
|
||||
const [total, setTotal] = useState(0)
|
||||
const totalPages = total ? Math.max(Math.ceil(total / APP_PAGE_LIMIT), 1) : 1
|
||||
const [hitHistoryList, setHitHistoryList] = useState<HitHistoryItem[]>([])
|
||||
|
||||
// Update local state when item prop changes (e.g., when modal is reopened with updated data)
|
||||
@ -197,10 +198,15 @@ const ViewAnnotationModal: FC<Props> = ({
|
||||
{(total && total > APP_PAGE_LIMIT)
|
||||
? (
|
||||
<Pagination
|
||||
className="px-0"
|
||||
current={currPage}
|
||||
onChange={setCurrPage}
|
||||
total={total}
|
||||
page={currPage + 1}
|
||||
totalPages={totalPages}
|
||||
onPageChange={page => setCurrPage(page - 1)}
|
||||
labels={{
|
||||
previous: t('pagination.previous', { ns: 'common' }),
|
||||
next: t('pagination.next', { ns: 'common' }),
|
||||
editPageNumber: (page, totalPages) => t('pagination.editPageNumber', { ns: 'common', page, totalPages }),
|
||||
pageNumberInput: t('pagination.pageNumber', { ns: 'common' }),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { AppPublisherProps, AppPublisherPublishParams } from '@/app/components/app/app-publisher'
|
||||
import type { Features, FileUpload } from '@/app/components/base/features/types'
|
||||
import type { ModelConfig } from '@/models/debug'
|
||||
import type { AppPublisherProps } from '@/app/components/app/app-publisher'
|
||||
import type { ModelAndParameter } from '@/app/components/app/configuration/debug/types'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import type { PublishWorkflowParams } from '@/types/workflow'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
@ -20,15 +21,9 @@ import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { Resolution } from '@/types/app'
|
||||
|
||||
type PublishedModelConfig = ModelConfig & {
|
||||
resetAppConfig?: () => void
|
||||
}
|
||||
|
||||
type Props = Omit<AppPublisherProps, 'onPublish'> & {
|
||||
onPublish?: (params?: AppPublisherPublishParams, features?: Features) => Promise<unknown> | unknown
|
||||
publishedConfig: {
|
||||
modelConfig: PublishedModelConfig
|
||||
}
|
||||
onPublish?: (params?: ModelAndParameter | PublishWorkflowParams, features?: any) => Promise<any> | any
|
||||
publishedConfig?: any
|
||||
resetAppConfig?: () => void
|
||||
}
|
||||
|
||||
@ -76,7 +71,7 @@ const FeaturesWrappedAppPublisher = (props: Props) => {
|
||||
setRestoreConfirmOpen(false)
|
||||
}, [featuresStore, props])
|
||||
|
||||
const handlePublish = useCallback((params?: AppPublisherPublishParams) => {
|
||||
const handlePublish = useCallback((params?: ModelAndParameter | PublishWorkflowParams) => {
|
||||
return props.onPublish?.(params, features)
|
||||
}, [features, props])
|
||||
|
||||
|
||||
@ -86,10 +86,8 @@ export type AppPublisherProps = {
|
||||
|
||||
const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P']
|
||||
|
||||
export type AppPublisherPublishParams = ModelAndParameter | PublishWorkflowParams
|
||||
|
||||
type AppPublisherPublishHandler
|
||||
= | ((params?: AppPublisherPublishParams) => Promise<unknown> | unknown)
|
||||
= | ((params?: ModelAndParameter | PublishWorkflowParams) => Promise<unknown> | unknown)
|
||||
| ((params?: unknown) => Promise<unknown> | unknown)
|
||||
|
||||
type AppPublisherRestoreHandler = () => Promise<unknown> | unknown
|
||||
|
||||
@ -96,7 +96,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
|
||||
const [model, setModel] = React.useState<Model>(localModel || {
|
||||
name: '',
|
||||
provider: '',
|
||||
mode: mode as unknown as ModelModeType,
|
||||
mode: mode as unknown as ModelModeType.chat,
|
||||
completion_params: {} as CompletionParams,
|
||||
})
|
||||
const {
|
||||
|
||||
@ -78,7 +78,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
|
||||
const [model, setModel] = React.useState<Model>(localModel || {
|
||||
name: '',
|
||||
provider: '',
|
||||
mode: mode as unknown as ModelModeType,
|
||||
mode: mode as unknown as ModelModeType.chat,
|
||||
completion_params: defaultCompletionParams,
|
||||
})
|
||||
const {
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { AppPublisherPublishParams } from '@/app/components/app/app-publisher'
|
||||
import type AppPublisher from '@/app/components/app/app-publisher/features-wrapper'
|
||||
import type { ModelAndParameter } from '@/app/components/app/configuration/debug/types'
|
||||
import type { Features as FeaturesData, OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
@ -22,6 +21,7 @@ import type {
|
||||
TextToSpeechConfig,
|
||||
} from '@/models/debug'
|
||||
import type { VisionSettings } from '@/types/app'
|
||||
import type { PublishWorkflowParams } from '@/types/workflow'
|
||||
import { useBoolean, useGetState } from 'ahooks'
|
||||
import { clone } from 'es-toolkit/object'
|
||||
import { produce } from 'immer'
|
||||
@ -481,7 +481,7 @@ export const useConfiguration = (): ConfigurationViewModel => {
|
||||
resolvedModelModeType,
|
||||
])
|
||||
|
||||
const onPublish = useCallback(async (params?: AppPublisherPublishParams, features?: FeaturesData) => {
|
||||
const onPublish = useCallback(async (params?: ModelAndParameter | PublishWorkflowParams, features?: FeaturesData) => {
|
||||
const modelAndParameter = params && 'model' in params && 'provider' in params && 'parameters' in params
|
||||
? params
|
||||
: undefined
|
||||
|
||||
@ -337,40 +337,29 @@ function AppTypeCard({ icon, title, description, active, onClick }: AppTypeCardP
|
||||
|
||||
function AppPreview({ mode }: { mode: AppModeEnum }) {
|
||||
const { t } = useTranslation()
|
||||
const previewInfo = (() => {
|
||||
switch (mode) {
|
||||
case AppModeEnum.CHAT:
|
||||
return {
|
||||
title: t('types.chatbot', { ns: 'app' }),
|
||||
description: t('newApp.chatbotUserDescription', { ns: 'app' }),
|
||||
}
|
||||
case AppModeEnum.ADVANCED_CHAT:
|
||||
return {
|
||||
title: t('types.advanced', { ns: 'app' }),
|
||||
description: t('newApp.advancedUserDescription', { ns: 'app' }),
|
||||
}
|
||||
case AppModeEnum.AGENT_CHAT:
|
||||
return {
|
||||
title: t('types.agent', { ns: 'app' }),
|
||||
description: t('newApp.agentUserDescription', { ns: 'app' }),
|
||||
}
|
||||
case AppModeEnum.COMPLETION:
|
||||
return {
|
||||
title: t('newApp.completeApp', { ns: 'app' }),
|
||||
description: t('newApp.completionUserDescription', { ns: 'app' }),
|
||||
}
|
||||
case AppModeEnum.WORKFLOW:
|
||||
return {
|
||||
title: t('types.workflow', { ns: 'app' }),
|
||||
description: t('newApp.workflowUserDescription', { ns: 'app' }),
|
||||
}
|
||||
default:
|
||||
return {
|
||||
title: t('types.workflow', { ns: 'app' }),
|
||||
description: t('newApp.workflowUserDescription', { ns: 'app' }),
|
||||
}
|
||||
}
|
||||
})()
|
||||
const modeToPreviewInfoMap = {
|
||||
[AppModeEnum.CHAT]: {
|
||||
title: t('types.chatbot', { ns: 'app' }),
|
||||
description: t('newApp.chatbotUserDescription', { ns: 'app' }),
|
||||
},
|
||||
[AppModeEnum.ADVANCED_CHAT]: {
|
||||
title: t('types.advanced', { ns: 'app' }),
|
||||
description: t('newApp.advancedUserDescription', { ns: 'app' }),
|
||||
},
|
||||
[AppModeEnum.AGENT_CHAT]: {
|
||||
title: t('types.agent', { ns: 'app' }),
|
||||
description: t('newApp.agentUserDescription', { ns: 'app' }),
|
||||
},
|
||||
[AppModeEnum.COMPLETION]: {
|
||||
title: t('newApp.completeApp', { ns: 'app' }),
|
||||
description: t('newApp.completionUserDescription', { ns: 'app' }),
|
||||
},
|
||||
[AppModeEnum.WORKFLOW]: {
|
||||
title: t('types.workflow', { ns: 'app' }),
|
||||
description: t('newApp.workflowUserDescription', { ns: 'app' }),
|
||||
},
|
||||
}
|
||||
const previewInfo = modeToPreviewInfoMap[mode]
|
||||
return (
|
||||
<div className="px-8 py-4">
|
||||
<h4 className="system-sm-semibold-uppercase text-text-secondary">{previewInfo.title}</h4>
|
||||
|
||||
@ -67,9 +67,11 @@ vi.mock('@/app/components/base/loading', () => ({
|
||||
default: () => <div>loading-logs</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/pagination', () => ({
|
||||
default: ({ onChange }: { onChange: (page: number) => void }) => (
|
||||
<button onClick={() => onChange(1)}>go-to-page-2</button>
|
||||
vi.mock('@langgenius/dify-ui/pagination', () => ({
|
||||
Pagination: ({ onPageChange }: { onPageChange: (page: number) => void }) => (
|
||||
<div>
|
||||
<button onClick={() => onPageChange(2)}>go-to-page-2</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { App } from '@/types/app'
|
||||
import { Pagination } from '@langgenius/dify-ui/pagination'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import dayjs from 'dayjs'
|
||||
import { omit } from 'es-toolkit/object'
|
||||
@ -8,7 +9,6 @@ import * as React from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import Pagination from '@/app/components/base/pagination'
|
||||
import { APP_PAGE_LIMIT } from '@/config'
|
||||
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { useChatConversations, useCompletionConversations } from '@/service/use-log'
|
||||
@ -98,6 +98,7 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
|
||||
})
|
||||
|
||||
const total = isChatMode ? chatConversations?.total : completionConversations?.total
|
||||
const totalPages = total ? Math.max(Math.ceil(total / limit), 1) : 1
|
||||
|
||||
const handleQueryParamsChange = useCallback((next: QueryParam) => {
|
||||
setCurrPage(0)
|
||||
@ -130,11 +131,22 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
|
||||
{(total && total > APP_PAGE_LIMIT)
|
||||
? (
|
||||
<Pagination
|
||||
current={currPage}
|
||||
onChange={handlePageChange}
|
||||
total={total}
|
||||
limit={limit}
|
||||
onLimitChange={setLimit}
|
||||
page={currPage + 1}
|
||||
totalPages={totalPages}
|
||||
onPageChange={page => handlePageChange(page - 1)}
|
||||
labels={{
|
||||
previous: t('pagination.previous', { ns: 'common' }),
|
||||
next: t('pagination.next', { ns: 'common' }),
|
||||
editPageNumber: (page, totalPages) => t('pagination.editPageNumber', { ns: 'common', page, totalPages }),
|
||||
pageNumberInput: t('pagination.pageNumber', { ns: 'common' }),
|
||||
}}
|
||||
pageSize={{
|
||||
value: limit,
|
||||
options: [10, 25, 50],
|
||||
onValueChange: setLimit,
|
||||
label: t('pagination.perPage', { ns: 'common' }),
|
||||
ariaLabel: t('pagination.perPage', { ns: 'common' }),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { App } from '@/types/app'
|
||||
import { Pagination } from '@langgenius/dify-ui/pagination'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import dayjs from 'dayjs'
|
||||
import timezone from 'dayjs/plugin/timezone'
|
||||
@ -11,7 +12,6 @@ import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import EmptyElement from '@/app/components/app/log/empty-element'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import Pagination from '@/app/components/base/pagination'
|
||||
import { APP_PAGE_LIMIT } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useWorkflowLogs } from '@/service/use-log'
|
||||
@ -59,6 +59,7 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
|
||||
params: query,
|
||||
})
|
||||
const total = workflowLogs?.total
|
||||
const totalPages = total ? Math.max(Math.ceil(total / limit), 1) : 1
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
@ -76,11 +77,22 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
|
||||
{(total && total > APP_PAGE_LIMIT)
|
||||
? (
|
||||
<Pagination
|
||||
current={currPage}
|
||||
onChange={setCurrPage}
|
||||
total={total}
|
||||
limit={limit}
|
||||
onLimitChange={setLimit}
|
||||
page={currPage + 1}
|
||||
totalPages={totalPages}
|
||||
onPageChange={page => setCurrPage(page - 1)}
|
||||
labels={{
|
||||
previous: t('pagination.previous', { ns: 'common' }),
|
||||
next: t('pagination.next', { ns: 'common' }),
|
||||
editPageNumber: (page, totalPages) => t('pagination.editPageNumber', { ns: 'common', page, totalPages }),
|
||||
pageNumberInput: t('pagination.pageNumber', { ns: 'common' }),
|
||||
}}
|
||||
pageSize={{
|
||||
value: limit,
|
||||
options: [10, 25, 50],
|
||||
onValueChange: setLimit,
|
||||
label: t('pagination.perPage', { ns: 'common' }),
|
||||
ariaLabel: t('pagination.perPage', { ns: 'common' }),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
|
||||
@ -2,8 +2,6 @@ import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import Empty from '../empty'
|
||||
|
||||
const defaultMessage = 'workflow.tabs.noSnippetsFound'
|
||||
|
||||
describe('Empty', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@ -11,32 +9,32 @@ describe('Empty', () => {
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Empty message={defaultMessage} />)
|
||||
expect(screen.getByText(defaultMessage)).toBeInTheDocument()
|
||||
render(<Empty />)
|
||||
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render 36 placeholder cards', () => {
|
||||
const { container } = render(<Empty message={defaultMessage} />)
|
||||
const { container } = render(<Empty />)
|
||||
const placeholderCards = container.querySelectorAll('.bg-background-default-lighter')
|
||||
expect(placeholderCards).toHaveLength(36)
|
||||
})
|
||||
|
||||
it('should display the provided message', () => {
|
||||
render(<Empty message="app.newApp.noAppsFound" />)
|
||||
it('should display the no apps found message', () => {
|
||||
render(<Empty />)
|
||||
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should have correct container styling for overlay', () => {
|
||||
const { container } = render(<Empty message={defaultMessage} />)
|
||||
const { container } = render(<Empty />)
|
||||
const overlay = container.querySelector('.pointer-events-none')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
expect(overlay).toHaveClass('absolute', 'inset-0', 'z-20')
|
||||
})
|
||||
|
||||
it('should have correct styling for placeholder cards', () => {
|
||||
const { container } = render(<Empty message={defaultMessage} />)
|
||||
const { container } = render(<Empty />)
|
||||
const card = container.querySelector('.bg-background-default-lighter')
|
||||
expect(card).toHaveClass('inline-flex', 'h-[160px]', 'rounded-xl')
|
||||
})
|
||||
@ -44,10 +42,10 @@ describe('Empty', () => {
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle multiple renders without issues', () => {
|
||||
const { rerender } = render(<Empty message={defaultMessage} />)
|
||||
expect(screen.getByText(defaultMessage)).toBeInTheDocument()
|
||||
const { rerender } = render(<Empty />)
|
||||
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
|
||||
|
||||
rerender(<Empty message="app.newApp.noAppsFound" />)
|
||||
rerender(<Empty />)
|
||||
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -45,19 +45,18 @@ vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor(),
|
||||
isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator(),
|
||||
userProfile: { id: 'creator-1' },
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockSetKeywords = vi.fn()
|
||||
const mockSetTagIDs = vi.fn()
|
||||
const mockSetCreatorID = vi.fn()
|
||||
const mockSetIsCreatedByMe = vi.fn()
|
||||
const mockSetCategory = vi.fn()
|
||||
const mockQueryState = {
|
||||
category: 'all',
|
||||
tagIDs: [] as string[],
|
||||
keywords: '',
|
||||
creatorID: '',
|
||||
isCreatedByMe: false,
|
||||
}
|
||||
vi.mock('../hooks/use-apps-query-state', () => ({
|
||||
isAppListCategory: (value: string) => value === 'all' || Object.values(AppModeEnum).includes(value as AppModeEnum),
|
||||
@ -66,18 +65,7 @@ vi.mock('../hooks/use-apps-query-state', () => ({
|
||||
setCategory: mockSetCategory,
|
||||
setKeywords: mockSetKeywords,
|
||||
setTagIDs: mockSetTagIDs,
|
||||
setCreatorID: mockSetCreatorID,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useMembers: () => ({
|
||||
data: {
|
||||
accounts: [
|
||||
{ id: 'creator-1', name: 'Alice', avatar_url: null, status: 'active' },
|
||||
{ id: 'creator-2', name: 'Bob', avatar_url: null, status: 'active' },
|
||||
],
|
||||
},
|
||||
setIsCreatedByMe: mockSetIsCreatedByMe,
|
||||
}),
|
||||
}))
|
||||
|
||||
@ -202,9 +190,9 @@ vi.mock('../app-card', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../new-app-card', () => ({
|
||||
default: ({ ref: _ref }: { ref?: React.Ref<HTMLDivElement> }) => {
|
||||
return React.createElement('div', { 'data-testid': 'new-app-card', 'role': 'button', 'ref': _ref }, 'New App Card')
|
||||
},
|
||||
default: React.forwardRef((_props: unknown, _ref: React.ForwardedRef<unknown>) => {
|
||||
return React.createElement('div', { 'data-testid': 'new-app-card', 'role': 'button' }, 'New App Card')
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../empty', () => ({
|
||||
@ -241,15 +229,11 @@ beforeAll(() => {
|
||||
|
||||
// Render helper wrapping with shared nuqs testing helper plus a seeded
|
||||
// systemFeatures cache so List can resolve its useSuspenseQuery.
|
||||
const renderList = (searchParams = '', pageType: 'apps' | 'snippets' = 'apps') => {
|
||||
const renderList = (searchParams = '') => {
|
||||
const { wrapper: SystemFeaturesWrapper } = createSystemFeaturesWrapper({
|
||||
systemFeatures: { branding: { enabled: false } },
|
||||
})
|
||||
return renderWithNuqs(<SystemFeaturesWrapper><List pageType={pageType} /></SystemFeaturesWrapper>, { searchParams })
|
||||
}
|
||||
|
||||
const openTypeFilter = () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /^app\.(studio\.filters\.types|types\.)/ }))
|
||||
return renderWithNuqs(<SystemFeaturesWrapper><List /></SystemFeaturesWrapper>, { searchParams })
|
||||
}
|
||||
|
||||
type AppListInfiniteOptions = {
|
||||
@ -271,7 +255,7 @@ describe('List', () => {
|
||||
mockQueryState.category = 'all'
|
||||
mockQueryState.tagIDs = []
|
||||
mockQueryState.keywords = ''
|
||||
mockQueryState.creatorID = ''
|
||||
mockQueryState.isCreatedByMe = false
|
||||
mockUseWorkflowOnlineUsers.mockClear()
|
||||
intersectionCallback = null
|
||||
localStorage.clear()
|
||||
@ -280,12 +264,11 @@ describe('List', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
renderList()
|
||||
expect(screen.getByText('app.studio.filters.types'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render app type dropdown with all app types', () => {
|
||||
it('should render tab slider with all app types', () => {
|
||||
renderList()
|
||||
openTypeFilter()
|
||||
|
||||
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.workflow'))!.toBeInTheDocument()
|
||||
@ -305,21 +288,9 @@ describe('List', () => {
|
||||
expect(screen.getByText('common.tag.placeholder'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render creators filter', () => {
|
||||
it('should render created by me checkbox', () => {
|
||||
renderList()
|
||||
expect(screen.getByText('app.studio.filters.allCreators'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render link to snippets on apps page', () => {
|
||||
renderList()
|
||||
|
||||
expect(screen.getByRole('link', { name: 'app.studio.viewSnippets' })).toHaveAttribute('href', '/snippets')
|
||||
})
|
||||
|
||||
it('should not render link to snippets on snippets page', () => {
|
||||
renderList('', 'snippets')
|
||||
|
||||
expect(screen.queryByRole('link', { name: 'app.studio.viewSnippets' })).not.toBeInTheDocument()
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render app cards when apps exist', () => {
|
||||
@ -354,22 +325,20 @@ describe('List', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Type Filter', () => {
|
||||
it('should update category when workflow type is selected', () => {
|
||||
describe('Tab Navigation', () => {
|
||||
it('should update category when workflow tab is clicked', () => {
|
||||
renderList()
|
||||
openTypeFilter()
|
||||
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: 'app.types.workflow' }))
|
||||
fireEvent.click(screen.getByText('app.types.workflow'))
|
||||
|
||||
expect(mockSetCategory).toHaveBeenCalledWith(AppModeEnum.WORKFLOW)
|
||||
})
|
||||
|
||||
it('should update category when all type is selected', () => {
|
||||
it('should update category when all tab is clicked', () => {
|
||||
mockQueryState.category = AppModeEnum.WORKFLOW
|
||||
renderList()
|
||||
openTypeFilter()
|
||||
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: 'app.types.all' }))
|
||||
fireEvent.click(screen.getByText('app.types.all'))
|
||||
|
||||
expect(mockSetCategory).toHaveBeenCalledWith('all')
|
||||
})
|
||||
@ -395,7 +364,10 @@ describe('List', () => {
|
||||
|
||||
renderList()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' }))
|
||||
const clearButton = document.querySelector('.group')
|
||||
expect(clearButton)!.toBeInTheDocument()
|
||||
if (clearButton)
|
||||
fireEvent.click(clearButton)
|
||||
|
||||
expect(mockSetKeywords).toHaveBeenCalledWith('')
|
||||
})
|
||||
@ -405,7 +377,7 @@ describe('List', () => {
|
||||
it('should build paged query input from active filters', () => {
|
||||
mockQueryState.tagIDs = ['tag-1']
|
||||
mockQueryState.keywords = 'sales'
|
||||
mockQueryState.creatorID = 'creator-1'
|
||||
mockQueryState.isCreatedByMe = true
|
||||
mockQueryState.category = AppModeEnum.WORKFLOW
|
||||
|
||||
renderList()
|
||||
@ -418,7 +390,7 @@ describe('List', () => {
|
||||
limit: 30,
|
||||
name: 'sales',
|
||||
tag_ids: ['tag-1'],
|
||||
creator_id: 'creator-1',
|
||||
is_created_by_me: true,
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
},
|
||||
})
|
||||
@ -434,19 +406,19 @@ describe('List', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Creators Filter', () => {
|
||||
it('should render creators filter with correct label', () => {
|
||||
describe('Created By Me Filter', () => {
|
||||
it('should render checkbox with correct label', () => {
|
||||
renderList()
|
||||
expect(screen.getByText('app.studio.filters.allCreators'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle creator selection as a single creator filter', () => {
|
||||
it('should handle checkbox change', () => {
|
||||
renderList()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'app.studio.filters.allCreators' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Bob/ }))
|
||||
const checkbox = screen.getByRole('checkbox', { name: 'app.showMyCreatedAppsOnly' })
|
||||
fireEvent.click(checkbox)
|
||||
|
||||
expect(mockSetCreatorID).toHaveBeenCalledWith('creator-2')
|
||||
expect(mockSetIsCreatedByMe).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
|
||||
@ -492,11 +464,11 @@ describe('List', () => {
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle multiple renders without issues', () => {
|
||||
const { unmount } = renderList()
|
||||
expect(screen.getByText('app.studio.filters.types'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
|
||||
|
||||
unmount()
|
||||
renderList()
|
||||
expect(screen.getByText('app.studio.filters.types'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render app cards correctly', () => {
|
||||
@ -509,10 +481,9 @@ describe('List', () => {
|
||||
it('should render with all filter options visible', () => {
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.studio.filters.types'))!.toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.studio.filters.allCreators'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('common.tag.placeholder'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly'))!.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -529,10 +500,9 @@ describe('List', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('App Type Dropdown', () => {
|
||||
it('should render all app type options', () => {
|
||||
describe('App Type Tabs', () => {
|
||||
it('should render all app type tabs', () => {
|
||||
renderList()
|
||||
openTypeFilter()
|
||||
|
||||
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.workflow'))!.toBeInTheDocument()
|
||||
@ -542,7 +512,9 @@ describe('List', () => {
|
||||
expect(screen.getByText('app.types.completion'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update category for each app type option click', () => {
|
||||
it('should update category for each app type tab click', () => {
|
||||
renderList()
|
||||
|
||||
const appTypeTexts = [
|
||||
{ mode: AppModeEnum.WORKFLOW, text: 'app.types.workflow' },
|
||||
{ mode: AppModeEnum.ADVANCED_CHAT, text: 'app.types.advanced' },
|
||||
@ -553,11 +525,8 @@ describe('List', () => {
|
||||
|
||||
for (const { mode, text } of appTypeTexts) {
|
||||
mockSetCategory.mockClear()
|
||||
const { unmount } = renderList()
|
||||
openTypeFilter()
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: text }))
|
||||
fireEvent.click(screen.getByText(text))
|
||||
expect(mockSetCategory).toHaveBeenCalledWith(mode)
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,16 +0,0 @@
|
||||
import { parseAsStringLiteral } from 'nuqs'
|
||||
import { AppModes } from '@/types/app'
|
||||
|
||||
const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const
|
||||
type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number]
|
||||
export type { AppListCategory }
|
||||
|
||||
const appListCategorySet = new Set<string>(APP_LIST_CATEGORY_VALUES)
|
||||
|
||||
export const isAppListCategory = (value: string): value is AppListCategory => {
|
||||
return appListCategorySet.has(value)
|
||||
}
|
||||
|
||||
export const parseAsAppListCategory = parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
|
||||
.withDefault('all')
|
||||
.withOptions({ history: 'push' })
|
||||
@ -1,76 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { AppListCategory } from './app-type-filter-shared'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuRadioItemIndicator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { isAppListCategory } from './app-type-filter-shared'
|
||||
|
||||
const chipClassName = 'flex h-8 items-center rounded-lg border-[0.5px] px-2 text-[13px] leading-4 transition-colors'
|
||||
|
||||
type AppTypeFilterProps = {
|
||||
value: AppListCategory
|
||||
onChange: (value: AppListCategory) => void
|
||||
}
|
||||
|
||||
export function AppTypeFilter({
|
||||
value,
|
||||
onChange,
|
||||
}: AppTypeFilterProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const options = useMemo(() => ([
|
||||
{ value: 'all', text: t('types.all', { ns: 'app' }), iconClassName: 'i-ri-apps-2-line' },
|
||||
{ value: AppModeEnum.WORKFLOW, text: t('types.workflow', { ns: 'app' }), iconClassName: 'i-ri-exchange-2-line' },
|
||||
{ value: AppModeEnum.ADVANCED_CHAT, text: t('types.advanced', { ns: 'app' }), iconClassName: 'i-ri-message-3-line' },
|
||||
{ value: AppModeEnum.CHAT, text: t('types.chatbot', { ns: 'app' }), iconClassName: 'i-ri-message-3-line' },
|
||||
{ value: AppModeEnum.AGENT_CHAT, text: t('types.agent', { ns: 'app' }), iconClassName: 'i-ri-robot-3-line' },
|
||||
{ value: AppModeEnum.COMPLETION, text: t('types.completion', { ns: 'app' }), iconClassName: 'i-ri-file-4-line' },
|
||||
]), [t])
|
||||
|
||||
const activeOption = options.find(option => option.value === value)
|
||||
const isSelected = value !== 'all'
|
||||
const triggerLabel = isSelected ? activeOption?.text : t('studio.filters.types', { ns: 'app' })
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
chipClassName,
|
||||
isSelected
|
||||
? 'border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs hover:bg-state-base-hover'
|
||||
: 'border-transparent bg-components-input-bg-normal text-text-tertiary hover:bg-components-input-bg-hover',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className={cn('h-4 w-4 shrink-0 text-text-tertiary', activeOption?.iconClassName ?? 'i-ri-apps-2-line')} />
|
||||
<span className="px-1 text-text-tertiary">{triggerLabel}</span>
|
||||
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent placement="bottom-start" popupClassName="w-[220px]">
|
||||
<DropdownMenuRadioGroup value={value} onValueChange={nextValue => isAppListCategory(nextValue) && onChange(nextValue)}>
|
||||
{options.map(option => (
|
||||
<DropdownMenuRadioItem key={option.value} value={option.value}>
|
||||
<span aria-hidden className={cn('h-4 w-4 shrink-0 text-text-tertiary', option.iconClassName)} />
|
||||
<span>{option.text}</span>
|
||||
<DropdownMenuRadioItemIndicator />
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@ -1,230 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Avatar } from '@langgenius/dify-ui/avatar'
|
||||
import { Checkbox } from '@langgenius/dify-ui/checkbox'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useMembers } from '@/service/use-common'
|
||||
|
||||
type CreatorsFilterProps = {
|
||||
value: string[]
|
||||
onChange: (value: string[]) => void
|
||||
}
|
||||
|
||||
type CreatorOption = {
|
||||
id: string
|
||||
name: string
|
||||
avatarUrl: string | null
|
||||
isYou: boolean
|
||||
}
|
||||
|
||||
const baseChipClassName = 'flex h-8 items-center rounded-lg border-[0.5px] px-2 text-[13px] leading-4 transition-colors'
|
||||
|
||||
const CreatorsFilter = ({
|
||||
value,
|
||||
onChange,
|
||||
}: CreatorsFilterProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { userProfile } = useAppContext()
|
||||
const { data: membersData } = useMembers()
|
||||
const [keywords, setKeywords] = useState('')
|
||||
|
||||
const creatorOptions = useMemo<CreatorOption[]>(() => {
|
||||
const currentUserId = userProfile?.id
|
||||
const members = membersData?.accounts ?? []
|
||||
|
||||
return [...members]
|
||||
.filter(member => member.status !== 'pending')
|
||||
.sort((left, right) => {
|
||||
if (left.id === currentUserId)
|
||||
return -1
|
||||
if (right.id === currentUserId)
|
||||
return 1
|
||||
return left.name.localeCompare(right.name)
|
||||
})
|
||||
.map(member => ({
|
||||
id: member.id,
|
||||
name: member.name,
|
||||
avatarUrl: member.avatar_url,
|
||||
isYou: member.id === currentUserId,
|
||||
}))
|
||||
}, [membersData?.accounts, userProfile?.id])
|
||||
|
||||
const filteredCreators = useMemo(() => {
|
||||
const normalizedKeywords = keywords.trim().toLowerCase()
|
||||
if (!normalizedKeywords)
|
||||
return creatorOptions
|
||||
|
||||
return creatorOptions.filter((creator) => {
|
||||
const keyword = normalizedKeywords
|
||||
return creator.name.toLowerCase().includes(keyword)
|
||||
})
|
||||
}, [creatorOptions, keywords])
|
||||
|
||||
const selectedCreators = useMemo(() => {
|
||||
const creatorMap = new Map(creatorOptions.map(creator => [creator.id, creator]))
|
||||
return value
|
||||
.map(id => creatorMap.get(id))
|
||||
.filter((creator): creator is CreatorOption => Boolean(creator))
|
||||
}, [creatorOptions, value])
|
||||
|
||||
const toggleCreator = useCallback((creatorId: string) => {
|
||||
if (value.includes(creatorId)) {
|
||||
onChange(value.filter(id => id !== creatorId))
|
||||
return
|
||||
}
|
||||
|
||||
onChange([...value, creatorId])
|
||||
}, [onChange, value])
|
||||
|
||||
const resetCreators = useCallback(() => {
|
||||
onChange([])
|
||||
setKeywords('')
|
||||
}, [onChange])
|
||||
|
||||
const selectedCount = value.length
|
||||
const selectedAvatarCreators = selectedCreators.slice(0, 3)
|
||||
const isSelected = selectedCount > 0
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
baseChipClassName,
|
||||
isSelected
|
||||
? 'border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs hover:bg-state-base-hover'
|
||||
: 'border-transparent bg-components-input-bg-normal text-text-tertiary hover:bg-components-input-bg-hover',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-user-shared-line h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
{!isSelected && (
|
||||
<>
|
||||
<span className="px-1 text-text-tertiary">{t('studio.filters.allCreators', { ns: 'app' })}</span>
|
||||
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
</>
|
||||
)}
|
||||
{isSelected && (
|
||||
<>
|
||||
<span className="px-1 text-text-tertiary">{t('studio.filters.creators', { ns: 'app' })}</span>
|
||||
<span className="flex items-center pr-1">
|
||||
{selectedAvatarCreators.map((creator, index) => (
|
||||
<Avatar
|
||||
key={creator.id}
|
||||
avatar={creator.avatarUrl}
|
||||
name={creator.name}
|
||||
size="xs"
|
||||
className={cn(
|
||||
'border border-components-panel-bg',
|
||||
index > 0 && '-ml-1',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
<span className="text-xs leading-4 font-medium text-text-tertiary">{`+${selectedCount}`}</span>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('studio.filters.reset', { ns: 'app' })}
|
||||
className="ml-1 flex h-4 w-4 shrink-0 items-center justify-center rounded-xs text-text-quaternary hover:text-text-tertiary"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
resetCreators()
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ')
|
||||
return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
resetCreators()
|
||||
}}
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-circle-fill h-3.5 w-3.5" />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent placement="bottom-start" popupClassName="w-[280px] p-0">
|
||||
<div className="flex items-center gap-1 p-2 pb-1">
|
||||
<div className="relative min-w-0 grow">
|
||||
<span aria-hidden className="pointer-events-none absolute top-1/2 left-2 i-ri-search-line size-4 -translate-y-1/2 text-components-input-text-placeholder" />
|
||||
<Input
|
||||
className={cn('pl-6.5', keywords && 'pr-6.5')}
|
||||
value={keywords}
|
||||
onChange={e => setKeywords(e.target.value)}
|
||||
placeholder={t('studio.filters.searchCreators', { ns: 'app' })}
|
||||
/>
|
||||
{!!keywords && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('operation.clear', { ns: 'common' })}
|
||||
className="absolute top-1/2 right-2 flex size-4 -translate-y-1/2 items-center justify-center text-components-input-text-placeholder hover:text-components-input-text-filled"
|
||||
onClick={() => setKeywords('')}
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-circle-fill size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isSelected && (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-sm px-2 py-1 text-xs font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
|
||||
onClick={resetCreators}
|
||||
>
|
||||
{t('studio.filters.reset', { ns: 'app' })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-60 overflow-y-auto px-1 pb-1">
|
||||
{filteredCreators.map((creator) => {
|
||||
const checked = value.includes(creator.id)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={creator.id}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-1 rounded-md px-2 py-1.5 hover:bg-state-base-hover"
|
||||
onClick={() => toggleCreator(creator.id)}
|
||||
>
|
||||
<Checkbox
|
||||
id={creator.id}
|
||||
checked={checked}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<div className="flex min-w-0 grow items-center gap-2 px-1">
|
||||
<Avatar
|
||||
avatar={creator.avatarUrl}
|
||||
name={creator.name}
|
||||
size="xs"
|
||||
className="border-[0.5px] border-divider-regular"
|
||||
/>
|
||||
<div className="flex min-w-0 grow items-center justify-between gap-2">
|
||||
<span className="truncate text-sm text-text-secondary">{creator.name}</span>
|
||||
{creator.isYou && (
|
||||
<span className="shrink-0 text-sm text-text-quaternary">{t('studio.filters.you', { ns: 'app' })}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreatorsFilter
|
||||
@ -17,11 +17,7 @@ const DefaultCards = React.memo(() => {
|
||||
)
|
||||
})
|
||||
|
||||
type EmptyProps = {
|
||||
message?: string
|
||||
}
|
||||
|
||||
const Empty = ({ message }: EmptyProps) => {
|
||||
const Empty = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
@ -29,7 +25,7 @@ const Empty = ({ message }: EmptyProps) => {
|
||||
<DefaultCards />
|
||||
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-linear-to-t from-background-body to-transparent">
|
||||
<span className="system-md-medium text-text-tertiary">
|
||||
{message ?? t('newApp.noAppsFound', { ns: 'app' })}
|
||||
{t('newApp.noAppsFound', { ns: 'app' })}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@ -5,7 +5,6 @@ import { APP_LIST_SEARCH_DEBOUNCE_MS } from '../../constants'
|
||||
import { useAppsQueryState } from '../use-apps-query-state'
|
||||
|
||||
const renderWithAdapter = (searchParams = '') => {
|
||||
// eslint-disable-next-line react/use-state -- testing a custom URL query hook, not React.useState
|
||||
return renderHookWithNuqs(() => useAppsQueryState(), { searchParams })
|
||||
}
|
||||
|
||||
@ -21,24 +20,24 @@ describe('useAppsQueryState', () => {
|
||||
category: 'all',
|
||||
tagIDs: [],
|
||||
keywords: '',
|
||||
creatorID: '',
|
||||
isCreatedByMe: false,
|
||||
})
|
||||
expect(typeof result.current.setCategory).toBe('function')
|
||||
expect(typeof result.current.setKeywords).toBe('function')
|
||||
expect(typeof result.current.setTagIDs).toBe('function')
|
||||
expect(typeof result.current.setCreatorID).toBe('function')
|
||||
expect(typeof result.current.setIsCreatedByMe).toBe('function')
|
||||
})
|
||||
|
||||
it('should parse app list filters from URL', () => {
|
||||
const { result } = renderWithAdapter(
|
||||
'?category=workflow&tagIDs=tag1;tag2&keywords=search+term&creatorID=creator-1',
|
||||
'?category=workflow&tagIDs=tag1;tag2&keywords=search+term&isCreatedByMe=true',
|
||||
)
|
||||
|
||||
expect(result.current.query).toEqual({
|
||||
category: AppModeEnum.WORKFLOW,
|
||||
tagIDs: ['tag1', 'tag2'],
|
||||
keywords: 'search term',
|
||||
creatorID: 'creator-1',
|
||||
isCreatedByMe: true,
|
||||
})
|
||||
})
|
||||
|
||||
@ -145,30 +144,30 @@ describe('useAppsQueryState', () => {
|
||||
expect(update.searchParams.has('tagIDs')).toBe(false)
|
||||
})
|
||||
|
||||
it('should update creator ID URL state', async () => {
|
||||
it('should update created-by-me URL state', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.setCreatorID('creator-1')
|
||||
result.current.setIsCreatedByMe(true)
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls.at(-1)![0]
|
||||
expect(result.current.query.creatorID).toBe('creator-1')
|
||||
expect(update.searchParams.get('creatorID')).toBe('creator-1')
|
||||
expect(result.current.query.isCreatedByMe).toBe(true)
|
||||
expect(update.searchParams.get('isCreatedByMe')).toBe('true')
|
||||
expect(update.options.history).toBe('push')
|
||||
})
|
||||
|
||||
it('should remove creatorID from URL when cleared', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter('?creatorID=creator-1')
|
||||
it('should remove isCreatedByMe from URL when disabled', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter('?isCreatedByMe=true')
|
||||
|
||||
act(() => {
|
||||
result.current.setCreatorID('')
|
||||
result.current.setIsCreatedByMe(false)
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls.at(-1)![0]
|
||||
expect(result.current.query.creatorID).toBe('')
|
||||
expect(update.searchParams.has('creatorID')).toBe(false)
|
||||
expect(result.current.query.isCreatedByMe).toBe(false)
|
||||
expect(update.searchParams.has('isCreatedByMe')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,19 +1,29 @@
|
||||
import type { AppListCategory } from '../app-type-filter-shared'
|
||||
import { debounce, parseAsArrayOf, parseAsString, useQueryStates } from 'nuqs'
|
||||
import { debounce, parseAsArrayOf, parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryStates } from 'nuqs'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { parseAsAppListCategory } from '../app-type-filter-shared'
|
||||
import { AppModes } from '@/types/app'
|
||||
import { APP_LIST_SEARCH_DEBOUNCE_MS } from '../constants'
|
||||
|
||||
const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const
|
||||
export type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number]
|
||||
|
||||
const appListCategorySet = new Set<string>(APP_LIST_CATEGORY_VALUES)
|
||||
|
||||
export const isAppListCategory = (value: string): value is AppListCategory => {
|
||||
return appListCategorySet.has(value)
|
||||
}
|
||||
|
||||
const appListQueryParsers = {
|
||||
category: parseAsAppListCategory,
|
||||
category: parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
|
||||
.withDefault('all')
|
||||
.withOptions({ history: 'push' }),
|
||||
tagIDs: parseAsArrayOf(parseAsString, ';')
|
||||
.withDefault([])
|
||||
.withOptions({ history: 'push' }),
|
||||
keywords: parseAsString.withDefault('').withOptions({
|
||||
limitUrlUpdates: debounce(APP_LIST_SEARCH_DEBOUNCE_MS),
|
||||
}),
|
||||
creatorID: parseAsString
|
||||
.withDefault('')
|
||||
isCreatedByMe: parseAsBoolean
|
||||
.withDefault(false)
|
||||
.withOptions({ history: 'push' }),
|
||||
}
|
||||
|
||||
@ -32,8 +42,8 @@ export function useAppsQueryState() {
|
||||
setQuery({ tagIDs })
|
||||
}, [setQuery])
|
||||
|
||||
const setCreatorID = useCallback((creatorID: string) => {
|
||||
setQuery({ creatorID })
|
||||
const setIsCreatedByMe = useCallback((isCreatedByMe: boolean) => {
|
||||
setQuery({ isCreatedByMe })
|
||||
}, [setQuery])
|
||||
|
||||
return useMemo(() => ({
|
||||
@ -41,6 +51,6 @@ export function useAppsQueryState() {
|
||||
setCategory,
|
||||
setKeywords,
|
||||
setTagIDs,
|
||||
setCreatorID,
|
||||
}), [query, setCategory, setKeywords, setTagIDs, setCreatorID])
|
||||
setIsCreatedByMe,
|
||||
}), [query, setCategory, setKeywords, setTagIDs, setIsCreatedByMe])
|
||||
}
|
||||
|
||||
@ -15,29 +15,19 @@ import { fetchAppDetail } from '@/service/explore'
|
||||
import { trackCreateApp } from '@/utils/create-app-tracking'
|
||||
import List from './list'
|
||||
|
||||
export type StudioPageType = 'apps' | 'snippets'
|
||||
|
||||
type AppsProps = {
|
||||
pageType?: StudioPageType
|
||||
}
|
||||
|
||||
const DSLConfirmModal = dynamic(() => import('../app/create-from-dsl-modal/dsl-confirm-modal'), { ssr: false })
|
||||
const CreateAppModal = dynamic(() => import('../explore/create-app-modal'), { ssr: false })
|
||||
const TryApp = dynamic(() => import('../explore/try-app'), { ssr: false })
|
||||
const ImportFromMarketplaceTemplateModal = dynamic(() => import('./import-from-marketplace-template-modal'), { ssr: false })
|
||||
|
||||
const Apps = ({
|
||||
pageType = 'apps',
|
||||
}: AppsProps) => {
|
||||
const Apps = () => {
|
||||
const { t } = useTranslation()
|
||||
const searchParams = useSearchParams()
|
||||
const { replace } = useRouter()
|
||||
const templateId = searchParams.get('template-id')
|
||||
const templateDismissedRef = useRef(false)
|
||||
|
||||
useDocumentTitle(pageType === 'apps'
|
||||
? t('menus.apps', { ns: 'common' })
|
||||
: t('tabs.snippets', { ns: 'workflow' }))
|
||||
useDocumentTitle(t('menus.apps', { ns: 'common' }))
|
||||
useEducationInit()
|
||||
|
||||
const [currentTryAppParams, setCurrentTryAppParams] = useState<TryAppSelection | undefined>(undefined)
|
||||
@ -175,7 +165,7 @@ const Apps = ({
|
||||
}}
|
||||
>
|
||||
<div className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
|
||||
<List controlRefreshList={controlRefreshList} pageType={pageType} />
|
||||
<List controlRefreshList={controlRefreshList} />
|
||||
{isShowTryAppPanel && (
|
||||
<TryApp
|
||||
appId={currentTryAppParams?.appId || ''}
|
||||
|
||||
@ -1,31 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import type { StudioPageType } from '.'
|
||||
import type { AppListQuery } from '@/contract/console/apps'
|
||||
import { Checkbox } from '@langgenius/dify-ui/checkbox'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import { keepPreviousData, useInfiniteQuery, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import TabSliderNew from '@/app/components/base/tab-slider-new'
|
||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { TagFilter } from '@/features/tag-management/components/tag-filter'
|
||||
import { CheckModal } from '@/hooks/use-pay'
|
||||
import dynamic from '@/next/dynamic'
|
||||
import Link from '@/next/link'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import AppCard from './app-card'
|
||||
import { AppCardSkeleton } from './app-card-skeleton'
|
||||
import { AppTypeFilter } from './app-type-filter'
|
||||
import { APP_LIST_SEARCH_DEBOUNCE_MS } from './constants'
|
||||
import CreatorsFilter from './creators-filter'
|
||||
import Empty from './empty'
|
||||
import Footer from './footer'
|
||||
import { useAppsQueryState } from './hooks/use-apps-query-state'
|
||||
import { isAppListCategory, useAppsQueryState } from './hooks/use-apps-query-state'
|
||||
import { useDSLDragDrop } from './hooks/use-dsl-drag-drop'
|
||||
import { useWorkflowOnlineUsers } from './hooks/use-workflow-online-users'
|
||||
import NewAppCard from './new-app-card'
|
||||
@ -39,11 +37,9 @@ const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-fro
|
||||
|
||||
type Props = {
|
||||
controlRefreshList?: number
|
||||
pageType?: StudioPageType
|
||||
}
|
||||
const List: FC<Props> = ({
|
||||
controlRefreshList = 0,
|
||||
pageType = 'apps',
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
@ -51,11 +47,11 @@ const List: FC<Props> = ({
|
||||
|
||||
// eslint-disable-next-line react/use-state -- custom URL query hook, not React.useState
|
||||
const {
|
||||
query: { category, tagIDs, keywords, creatorID },
|
||||
query: { category, tagIDs, keywords, isCreatedByMe },
|
||||
setCategory,
|
||||
setKeywords,
|
||||
setTagIDs,
|
||||
setCreatorID,
|
||||
setIsCreatedByMe,
|
||||
} = useAppsQueryState()
|
||||
const debouncedKeywords = useDebounce(keywords, { wait: APP_LIST_SEARCH_DEBOUNCE_MS })
|
||||
const newAppCardRef = useRef<HTMLDivElement>(null)
|
||||
@ -80,9 +76,9 @@ const List: FC<Props> = ({
|
||||
limit: 30,
|
||||
name: debouncedKeywords,
|
||||
...(tagIDs.length ? { tag_ids: tagIDs } : {}),
|
||||
...(creatorID ? { creator_id: creatorID } : {}),
|
||||
...(isCreatedByMe ? { is_created_by_me: isCreatedByMe } : {}),
|
||||
...(category !== 'all' ? { mode: category } : {}),
|
||||
}), [category, creatorID, debouncedKeywords, tagIDs])
|
||||
}), [category, debouncedKeywords, isCreatedByMe, tagIDs])
|
||||
|
||||
const {
|
||||
data,
|
||||
@ -116,6 +112,14 @@ const List: FC<Props> = ({
|
||||
}, [controlRefreshList, refetch])
|
||||
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
const options = [
|
||||
{ value: 'all', text: t('types.all', { ns: 'app' }), icon: <span className="mr-1 i-ri-apps-2-line h-[14px] w-[14px]" /> },
|
||||
{ value: AppModeEnum.WORKFLOW, text: t('types.workflow', { ns: 'app' }), icon: <span className="mr-1 i-ri-exchange-2-line h-[14px] w-[14px]" /> },
|
||||
{ value: AppModeEnum.ADVANCED_CHAT, text: t('types.advanced', { ns: 'app' }), icon: <span className="mr-1 i-ri-message-3-line h-[14px] w-[14px]" /> },
|
||||
{ value: AppModeEnum.CHAT, text: t('types.chatbot', { ns: 'app' }), icon: <span className="mr-1 i-ri-message-3-line h-[14px] w-[14px]" /> },
|
||||
{ value: AppModeEnum.AGENT_CHAT, text: t('types.agent', { ns: 'app' }), icon: <span className="mr-1 i-ri-robot-3-line h-[14px] w-[14px]" /> },
|
||||
{ value: AppModeEnum.COMPLETION, text: t('types.completion', { ns: 'app' }), icon: <span className="mr-1 i-ri-file-4-line h-[14px] w-[14px]" /> },
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
if (localStorage.getItem(NEED_REFRESH_APP_LIST_KEY) === '1') {
|
||||
@ -154,9 +158,9 @@ const List: FC<Props> = ({
|
||||
return () => observer?.disconnect()
|
||||
}, [isLoading, isFetchingNextPage, fetchNextPage, error, hasNextPage, isCurrentWorkspaceDatasetOperator])
|
||||
|
||||
const handleCreatorsChange = useCallback((creatorIDs: string[]) => {
|
||||
setCreatorID(creatorIDs.at(-1) ?? '')
|
||||
}, [setCreatorID])
|
||||
const handleCreatedByMeChange = useCallback((checked: boolean) => {
|
||||
setIsCreatedByMe(checked)
|
||||
}, [setIsCreatedByMe])
|
||||
|
||||
const pages = useMemo(() => data?.pages ?? [], [data?.pages])
|
||||
const apps = useMemo(() => pages.flatMap(({ data: pageApps }) => pageApps), [pages])
|
||||
@ -189,45 +193,32 @@ const List: FC<Props> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-x-4 gap-y-2 bg-background-body px-12 pt-7 pb-5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<AppTypeFilter
|
||||
value={category}
|
||||
onChange={setCategory}
|
||||
/>
|
||||
<CreatorsFilter
|
||||
value={creatorID ? [creatorID] : []}
|
||||
onChange={handleCreatorsChange}
|
||||
/>
|
||||
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-y-2 bg-background-body px-12 pt-7 pb-5">
|
||||
<TabSliderNew
|
||||
value={category}
|
||||
onChange={(nextValue) => {
|
||||
if (isAppListCategory(nextValue))
|
||||
setCategory(nextValue)
|
||||
}}
|
||||
options={options}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="mr-2 flex h-7 items-center space-x-2">
|
||||
<Checkbox checked={isCreatedByMe} onCheckedChange={handleCreatedByMeChange} />
|
||||
<div className="text-sm font-normal text-text-secondary">
|
||||
{t('showMyCreatedAppsOnly', { ns: 'app' })}
|
||||
</div>
|
||||
</label>
|
||||
<TagFilter type="app" value={tagIDs} onChange={setTagIDs} onOpenTagManagement={() => setShowTagManagementModal(true)} />
|
||||
<div className="relative w-50">
|
||||
<span aria-hidden className="pointer-events-none absolute top-1/2 left-2 i-ri-search-line size-4 -translate-y-1/2 text-components-input-text-placeholder" />
|
||||
<Input
|
||||
className={cn('pl-6.5', keywords && 'pr-6.5')}
|
||||
value={keywords}
|
||||
onChange={e => setKeywords(e.target.value)}
|
||||
placeholder={t('operation.search', { ns: 'common' })}
|
||||
/>
|
||||
{!!keywords && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('operation.clear', { ns: 'common' })}
|
||||
className="absolute top-1/2 right-2 flex size-4 -translate-y-1/2 items-center justify-center text-components-input-text-placeholder hover:text-components-input-text-filled"
|
||||
onClick={() => setKeywords('')}
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-circle-fill size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
showLeftIcon
|
||||
showClearIcon
|
||||
wrapperClassName="w-[200px]"
|
||||
value={keywords}
|
||||
onChange={e => setKeywords(e.target.value)}
|
||||
onClear={() => setKeywords('')}
|
||||
/>
|
||||
</div>
|
||||
{pageType === 'apps' && (
|
||||
<Link
|
||||
href="/snippets"
|
||||
className="flex h-8 items-center rounded-lg px-3 text-sm font-semibold text-text-secondary hover:bg-state-base-hover hover:text-text-primary"
|
||||
>
|
||||
{t('studio.viewSnippets', { ns: 'app' })}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className={cn(
|
||||
'relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 2k:grid-cols-6 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5',
|
||||
@ -255,7 +246,7 @@ const List: FC<Props> = ({
|
||||
onOpenTagManagement={() => setShowTagManagementModal(true)}
|
||||
/>
|
||||
))
|
||||
: <Empty message={pageType === 'snippets' ? t('tabs.noSnippetsFound', { ns: 'workflow' }) : undefined} />}
|
||||
: <Empty />}
|
||||
{isFetchingNextPage && (
|
||||
<AppCardSkeleton count={3} />
|
||||
)}
|
||||
|
||||
@ -1,155 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import usePagination from '../hook'
|
||||
|
||||
const defaultProps = {
|
||||
currentPage: 0,
|
||||
setCurrentPage: vi.fn(),
|
||||
totalPages: 10,
|
||||
edgePageCount: 2,
|
||||
middlePagesSiblingCount: 1,
|
||||
truncableText: '...',
|
||||
truncableClassName: 'truncable',
|
||||
}
|
||||
|
||||
describe('usePagination', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('pages', () => {
|
||||
it('should generate correct pages array', () => {
|
||||
const { result } = renderHook(() => usePagination(defaultProps))
|
||||
expect(result.current.pages).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
|
||||
})
|
||||
|
||||
it('should generate empty pages for totalPages 0', () => {
|
||||
const { result } = renderHook(() => usePagination({ ...defaultProps, totalPages: 0 }))
|
||||
expect(result.current.pages).toEqual([])
|
||||
})
|
||||
|
||||
it('should generate single page', () => {
|
||||
const { result } = renderHook(() => usePagination({ ...defaultProps, totalPages: 1 }))
|
||||
expect(result.current.pages).toEqual([1])
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasPreviousPage / hasNextPage', () => {
|
||||
it('should have no previous page on first page', () => {
|
||||
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 1 }))
|
||||
expect(result.current.hasPreviousPage).toBe(false)
|
||||
})
|
||||
|
||||
it('should have previous page when not on first page', () => {
|
||||
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 3 }))
|
||||
expect(result.current.hasPreviousPage).toBe(true)
|
||||
})
|
||||
|
||||
it('should have next page when not on last page', () => {
|
||||
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 1 }))
|
||||
expect(result.current.hasNextPage).toBe(true)
|
||||
})
|
||||
|
||||
it('should have no next page on last page', () => {
|
||||
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 10 }))
|
||||
expect(result.current.hasNextPage).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('middlePages', () => {
|
||||
it('should return correct middle pages when at start', () => {
|
||||
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 0 }))
|
||||
// isReachedToFirst: currentPage(0) <= middlePagesSiblingCount(1), so slice(0, 3)
|
||||
expect(result.current.middlePages).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('should return correct middle pages when in the middle', () => {
|
||||
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 }))
|
||||
// Not at start or end, slice(5-1, 5+1+1) = slice(4, 7) = [5, 6, 7]
|
||||
expect(result.current.middlePages).toEqual([5, 6, 7])
|
||||
})
|
||||
|
||||
it('should return correct middle pages when at end', () => {
|
||||
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 9 }))
|
||||
// isReachedToLast: currentPage(9) + middlePagesSiblingCount(1) >= totalPages(10), so slice(-3)
|
||||
expect(result.current.middlePages).toEqual([8, 9, 10])
|
||||
})
|
||||
})
|
||||
|
||||
describe('previousPages and nextPages', () => {
|
||||
it('should return empty previousPages when at start', () => {
|
||||
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 0 }))
|
||||
expect(result.current.previousPages).toEqual([])
|
||||
})
|
||||
|
||||
it('should return previousPages when in the middle', () => {
|
||||
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 }))
|
||||
// edgePageCount=2, so first 2 pages filtered by not in middlePages
|
||||
expect(result.current.previousPages).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('should return empty nextPages when at end', () => {
|
||||
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 9 }))
|
||||
expect(result.current.nextPages).toEqual([])
|
||||
})
|
||||
|
||||
it('should return nextPages when in the middle', () => {
|
||||
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 }))
|
||||
// Last 2 pages: [9, 10], filtered by not in middlePages [5,6,7]
|
||||
expect(result.current.nextPages).toEqual([9, 10])
|
||||
})
|
||||
})
|
||||
|
||||
describe('truncation', () => {
|
||||
it('should be previous truncable when middle pages are far from edge', () => {
|
||||
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 }))
|
||||
// previousPages=[1,2], middlePages=[5,6,7], 5 > 2+1 = true
|
||||
expect(result.current.isPreviousTruncable).toBe(true)
|
||||
})
|
||||
|
||||
it('should not be previous truncable when pages are contiguous', () => {
|
||||
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 2 }))
|
||||
expect(result.current.isPreviousTruncable).toBe(false)
|
||||
})
|
||||
|
||||
it('should be next truncable when middle pages are far from end edge', () => {
|
||||
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 }))
|
||||
// middlePages=[5,6,7], nextPages=[9,10], 7+1 < 9 = true
|
||||
expect(result.current.isNextTruncable).toBe(true)
|
||||
})
|
||||
|
||||
it('should not be next truncable when pages are contiguous', () => {
|
||||
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 7 }))
|
||||
expect(result.current.isNextTruncable).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('passthrough values', () => {
|
||||
it('should pass through currentPage', () => {
|
||||
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 }))
|
||||
expect(result.current.currentPage).toBe(5)
|
||||
})
|
||||
|
||||
it('should pass through setCurrentPage', () => {
|
||||
const setCurrentPage = vi.fn()
|
||||
const { result } = renderHook(() => usePagination({ ...defaultProps, setCurrentPage }))
|
||||
result.current.setCurrentPage(3)
|
||||
expect(setCurrentPage).toHaveBeenCalledWith(3)
|
||||
})
|
||||
|
||||
it('should pass through truncableText', () => {
|
||||
const { result } = renderHook(() => usePagination({ ...defaultProps, truncableText: '…' }))
|
||||
expect(result.current.truncableText).toBe('…')
|
||||
})
|
||||
|
||||
it('should pass through truncableClassName', () => {
|
||||
const { result } = renderHook(() => usePagination({ ...defaultProps, truncableClassName: 'custom-trunc' }))
|
||||
expect(result.current.truncableClassName).toBe('custom-trunc')
|
||||
})
|
||||
|
||||
it('should use default truncableText', () => {
|
||||
const { currentPage, setCurrentPage, totalPages, edgePageCount, middlePagesSiblingCount } = defaultProps
|
||||
const { result } = renderHook(() => usePagination({ currentPage, setCurrentPage, totalPages, edgePageCount, middlePagesSiblingCount }))
|
||||
expect(result.current.truncableText).toBe('...')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,444 +0,0 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import CustomizedPagination from '../index'
|
||||
|
||||
describe('CustomizedPagination', () => {
|
||||
const defaultProps = {
|
||||
current: 0,
|
||||
onChange: vi.fn(),
|
||||
total: 100,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(<CustomizedPagination {...defaultProps} />)
|
||||
expect(container)!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display current page and total pages', () => {
|
||||
render(<CustomizedPagination {...defaultProps} current={0} total={100} limit={10} />)
|
||||
// current + 1 = 1, totalPages = 10
|
||||
// The page info display shows "1 / 10" and page buttons also show numbers
|
||||
// current + 1 = 1, totalPages = 10
|
||||
// The page info display shows "1 / 10" and page buttons also show numbers
|
||||
expect(screen.getByText('/'))!.toBeInTheDocument()
|
||||
expect(screen.getAllByText('1').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should render prev and next buttons', () => {
|
||||
render(<CustomizedPagination {...defaultProps} />)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('should render page number buttons', () => {
|
||||
render(<CustomizedPagination {...defaultProps} total={50} limit={10} />)
|
||||
// 5 pages total, should see page numbers
|
||||
// 5 pages total, should see page numbers
|
||||
expect(screen.getByText('2'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('3'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display slash separator between current page and total', () => {
|
||||
render(<CustomizedPagination {...defaultProps} />)
|
||||
expect(screen.getByText('/'))!.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(<CustomizedPagination {...defaultProps} className="my-custom" />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper)!.toHaveClass('my-custom')
|
||||
})
|
||||
|
||||
it('should default limit to 10', () => {
|
||||
render(<CustomizedPagination {...defaultProps} total={100} />)
|
||||
// totalPages = 100 / 10 = 10, displayed in the page info area
|
||||
expect(screen.getAllByText('10').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should calculate total pages based on custom limit', () => {
|
||||
render(<CustomizedPagination {...defaultProps} total={100} limit={25} />)
|
||||
// totalPages = 100 / 25 = 4, displayed in the page info area
|
||||
expect(screen.getAllByText('4').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should disable prev button on first page', () => {
|
||||
render(<CustomizedPagination {...defaultProps} current={0} />)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
// First button is prev
|
||||
// First button is prev
|
||||
expect(buttons[0])!.toBeDisabled()
|
||||
})
|
||||
|
||||
it('should disable next button on last page', () => {
|
||||
render(<CustomizedPagination {...defaultProps} current={9} total={100} limit={10} />)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
// Last button is next
|
||||
// Last button is next
|
||||
expect(buttons[buttons.length - 1])!.toBeDisabled()
|
||||
})
|
||||
|
||||
it('should not render limit selector when onLimitChange is not provided', () => {
|
||||
render(<CustomizedPagination {...defaultProps} />)
|
||||
expect(screen.queryByText(/common\.pagination\.perPage/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render limit selector when onLimitChange is provided', () => {
|
||||
const onLimitChange = vi.fn()
|
||||
render(<CustomizedPagination {...defaultProps} onLimitChange={onLimitChange} />)
|
||||
// Should show limit options 10, 25, 50
|
||||
// Should show limit options 10, 25, 50
|
||||
expect(screen.getByText('25'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('50'))!.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onChange when next button is clicked', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<CustomizedPagination {...defaultProps} current={0} onChange={onChange} />)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const nextButton = buttons[buttons.length - 1]
|
||||
fireEvent.click(nextButton!)
|
||||
expect(onChange).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it('should call onChange when prev button is clicked', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<CustomizedPagination {...defaultProps} current={5} onChange={onChange} />)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
fireEvent.click(buttons[0]!)
|
||||
expect(onChange).toHaveBeenCalledWith(4)
|
||||
})
|
||||
|
||||
it('should show input when page display is clicked', () => {
|
||||
render(<CustomizedPagination {...defaultProps} />)
|
||||
// Click the current page display (the div containing "1 / 10")
|
||||
fireEvent.click(screen.getByText('/'))
|
||||
// Input should appear
|
||||
// Input should appear
|
||||
expect(screen.getByRole('textbox'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should navigate to entered page on Enter key', () => {
|
||||
vi.useFakeTimers()
|
||||
const onChange = vi.fn()
|
||||
render(<CustomizedPagination {...defaultProps} current={0} onChange={onChange} />)
|
||||
fireEvent.click(screen.getByText('/'))
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: '5' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
expect(onChange).toHaveBeenCalledWith(4) // 0-indexed
|
||||
})
|
||||
|
||||
it('should cancel input on Escape key', () => {
|
||||
render(<CustomizedPagination {...defaultProps} current={0} />)
|
||||
fireEvent.click(screen.getByText('/'))
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('/'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should confirm input on blur-sm', () => {
|
||||
vi.useFakeTimers()
|
||||
const onChange = vi.fn()
|
||||
render(<CustomizedPagination {...defaultProps} current={0} onChange={onChange} />)
|
||||
fireEvent.click(screen.getByText('/'))
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: '3' } })
|
||||
fireEvent.blur(input)
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
expect(onChange).toHaveBeenCalledWith(2) // 0-indexed
|
||||
})
|
||||
|
||||
it('should clamp page to max when input exceeds total pages', () => {
|
||||
vi.useFakeTimers()
|
||||
const onChange = vi.fn()
|
||||
render(<CustomizedPagination {...defaultProps} current={0} total={100} limit={10} onChange={onChange} />)
|
||||
fireEvent.click(screen.getByText('/'))
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: '999' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
expect(onChange).toHaveBeenCalledWith(9) // last page (0-indexed)
|
||||
})
|
||||
|
||||
it('should clamp page to min when input is less than 1', () => {
|
||||
vi.useFakeTimers()
|
||||
const onChange = vi.fn()
|
||||
render(<CustomizedPagination {...defaultProps} current={5} onChange={onChange} />)
|
||||
fireEvent.click(screen.getByText('/'))
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: '0' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
expect(onChange).toHaveBeenCalledWith(0)
|
||||
})
|
||||
|
||||
it('should ignore non-numeric input and empty input', () => {
|
||||
render(<CustomizedPagination {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('/'))
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'abc' } })
|
||||
expect(input)!.toHaveValue('')
|
||||
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
expect(input)!.toHaveValue('')
|
||||
})
|
||||
|
||||
it('should show per page tip on hover and hide on leave', () => {
|
||||
const onLimitChange = vi.fn()
|
||||
render(<CustomizedPagination {...defaultProps} onLimitChange={onLimitChange} />)
|
||||
|
||||
const container = screen.getByText('25').closest('.bg-components-segmented-control-bg-normal')!
|
||||
|
||||
fireEvent.mouseEnter(container)
|
||||
// I18n mock returns ns.key
|
||||
// I18n mock returns ns.key
|
||||
expect(screen.getByText('common.pagination.perPage'))!.toBeInTheDocument()
|
||||
|
||||
fireEvent.mouseLeave(container)
|
||||
expect(screen.queryByText('common.pagination.perPage')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onLimitChange when limit option is clicked', () => {
|
||||
const onLimitChange = vi.fn()
|
||||
render(<CustomizedPagination {...defaultProps} onLimitChange={onLimitChange} />)
|
||||
fireEvent.click(screen.getByText('25'))
|
||||
expect(onLimitChange).toHaveBeenCalledWith(25)
|
||||
})
|
||||
|
||||
it('should call onLimitChange with 10 when 10 option is clicked', () => {
|
||||
const onLimitChange = vi.fn()
|
||||
render(<CustomizedPagination {...defaultProps} onLimitChange={onLimitChange} />)
|
||||
|
||||
const container = screen.getByText('25').closest('.bg-components-segmented-control-bg-normal')!
|
||||
const option10 = Array.from(container.children).find(el => el.textContent === '10')!
|
||||
|
||||
fireEvent.click(option10)
|
||||
expect(onLimitChange).toHaveBeenCalledWith(10)
|
||||
})
|
||||
|
||||
it('should call onLimitChange with 50 when 50 option is clicked', () => {
|
||||
const onLimitChange = vi.fn()
|
||||
render(<CustomizedPagination {...defaultProps} onLimitChange={onLimitChange} />)
|
||||
fireEvent.click(screen.getByText('50'))
|
||||
expect(onLimitChange).toHaveBeenCalledWith(50)
|
||||
})
|
||||
|
||||
it('should call onChange when a page button is clicked', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<CustomizedPagination {...defaultProps} current={0} total={50} limit={10} onChange={onChange} />)
|
||||
fireEvent.click(screen.getByText('3'))
|
||||
expect(onChange).toHaveBeenCalledWith(2) // 0-indexed
|
||||
})
|
||||
|
||||
it('should correctly select active limit style for 25 and 50', () => {
|
||||
// Test limit 25
|
||||
const { container: containerA } = render(<CustomizedPagination current={0} total={100} limit={25} onChange={vi.fn()} onLimitChange={vi.fn()} />)
|
||||
const wrapper25 = Array.from(containerA.querySelectorAll('div.system-sm-medium')).find(el => el.textContent === '25')!
|
||||
expect(wrapper25)!.toHaveClass('bg-components-segmented-control-item-active-bg')
|
||||
|
||||
// Test limit 50
|
||||
const { container: containerB } = render(<CustomizedPagination current={0} total={100} limit={50} onChange={vi.fn()} onLimitChange={vi.fn()} />)
|
||||
const wrapper50 = Array.from(containerB.querySelectorAll('div.system-sm-medium')).find(el => el.textContent === '50')!
|
||||
expect(wrapper50)!.toHaveClass('bg-components-segmented-control-item-active-bg')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle total of 0', () => {
|
||||
const { container } = render(<CustomizedPagination {...defaultProps} total={0} />)
|
||||
expect(container)!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle confirm when input value is unchanged (covers false branch of empty string check)', () => {
|
||||
vi.useFakeTimers()
|
||||
const onChange = vi.fn()
|
||||
render(<CustomizedPagination {...defaultProps} current={4} onChange={onChange} />)
|
||||
fireEvent.click(screen.getByText('/'))
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
// Blur without changing anything
|
||||
fireEvent.blur(input)
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
|
||||
// onChange should NOT be called
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should ignore other keys in handleInputKeyDown (covers false branch of Escape check)', () => {
|
||||
render(<CustomizedPagination {...defaultProps} current={4} />)
|
||||
fireEvent.click(screen.getByText('/'))
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.keyDown(input, { key: 'a' })
|
||||
expect(screen.getByRole('textbox'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should trigger handleInputConfirm with empty string specifically on keydown Enter', async () => {
|
||||
const { userEvent } = await import('@testing-library/user-event')
|
||||
const user = userEvent.setup()
|
||||
render(<CustomizedPagination {...defaultProps} current={4} />)
|
||||
fireEvent.click(screen.getByText('/'))
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
await user.clear(input)
|
||||
await user.type(input, '{Enter}')
|
||||
|
||||
// Wait for debounce 500ms
|
||||
await new Promise(r => setTimeout(r, 600))
|
||||
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should explicitly trigger Escape key logic in handleInputKeyDown', async () => {
|
||||
const { userEvent } = await import('@testing-library/user-event')
|
||||
const user = userEvent.setup()
|
||||
render(<CustomizedPagination {...defaultProps} current={4} />)
|
||||
fireEvent.click(screen.getByText('/'))
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
await user.type(input, '{Escape}')
|
||||
|
||||
// Wait for debounce 500ms
|
||||
await new Promise(r => setTimeout(r, 600))
|
||||
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle single page', () => {
|
||||
render(<CustomizedPagination {...defaultProps} total={5} limit={10} />)
|
||||
// totalPages = 1, both buttons should be disabled
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons[0])!.toBeDisabled()
|
||||
expect(buttons[buttons.length - 1])!.toBeDisabled()
|
||||
})
|
||||
|
||||
it('should restore input value when blurred with empty value', () => {
|
||||
render(<CustomizedPagination {...defaultProps} current={4} />)
|
||||
fireEvent.click(screen.getByText('/'))
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
fireEvent.blur(input)
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,549 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { Pagination } from '../pagination'
|
||||
|
||||
// Helper to render Pagination with common defaults
|
||||
function renderPagination({
|
||||
currentPage = 0,
|
||||
totalPages = 10,
|
||||
setCurrentPage = vi.fn(),
|
||||
edgePageCount = 2,
|
||||
middlePagesSiblingCount = 1,
|
||||
truncableText = '...',
|
||||
truncableClassName = 'truncable',
|
||||
children,
|
||||
}: {
|
||||
currentPage?: number
|
||||
totalPages?: number
|
||||
setCurrentPage?: (page: number) => void
|
||||
edgePageCount?: number
|
||||
middlePagesSiblingCount?: number
|
||||
truncableText?: string
|
||||
truncableClassName?: string
|
||||
children?: React.ReactNode
|
||||
} = {}) {
|
||||
return render(
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
setCurrentPage={setCurrentPage}
|
||||
edgePageCount={edgePageCount}
|
||||
middlePagesSiblingCount={middlePagesSiblingCount}
|
||||
truncableText={truncableText}
|
||||
truncableClassName={truncableClassName}
|
||||
>
|
||||
{children}
|
||||
</Pagination>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('Pagination', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = renderPagination()
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render children', () => {
|
||||
renderPagination({ children: <span>child content</span> })
|
||||
expect(screen.getByText(/child content/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply className to wrapper div', () => {
|
||||
const { container } = render(
|
||||
<Pagination
|
||||
currentPage={0}
|
||||
totalPages={5}
|
||||
setCurrentPage={vi.fn()}
|
||||
edgePageCount={2}
|
||||
middlePagesSiblingCount={1}
|
||||
className="my-pagination"
|
||||
>
|
||||
<span>test</span>
|
||||
</Pagination>,
|
||||
)
|
||||
expect(container.firstChild).toHaveClass('my-pagination')
|
||||
})
|
||||
|
||||
it('should apply data-testid when provided', () => {
|
||||
render(
|
||||
<Pagination
|
||||
currentPage={0}
|
||||
totalPages={5}
|
||||
setCurrentPage={vi.fn()}
|
||||
edgePageCount={2}
|
||||
middlePagesSiblingCount={1}
|
||||
dataTestId="my-pagination"
|
||||
>
|
||||
<span>test</span>
|
||||
</Pagination>,
|
||||
)
|
||||
expect(screen.getByTestId('my-pagination')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('PrevButton', () => {
|
||||
it('should render prev button', () => {
|
||||
renderPagination({
|
||||
currentPage: 3,
|
||||
children: <Pagination.PrevButton>Prev</Pagination.PrevButton>,
|
||||
})
|
||||
expect(screen.getByText(/prev/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call setCurrentPage with previous page when clicked', () => {
|
||||
const setCurrentPage = vi.fn()
|
||||
renderPagination({
|
||||
currentPage: 3,
|
||||
setCurrentPage,
|
||||
children: <Pagination.PrevButton>Prev</Pagination.PrevButton>,
|
||||
})
|
||||
fireEvent.click(screen.getByText(/prev/i))
|
||||
expect(setCurrentPage).toHaveBeenCalledWith(2)
|
||||
})
|
||||
|
||||
it('should not navigate below page 0', () => {
|
||||
const setCurrentPage = vi.fn()
|
||||
renderPagination({
|
||||
currentPage: 0,
|
||||
setCurrentPage,
|
||||
children: <Pagination.PrevButton>Prev</Pagination.PrevButton>,
|
||||
})
|
||||
fireEvent.click(screen.getByText(/prev/i))
|
||||
expect(setCurrentPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should be disabled on first page', () => {
|
||||
renderPagination({
|
||||
currentPage: 0,
|
||||
children: <Pagination.PrevButton>Prev</Pagination.PrevButton>,
|
||||
})
|
||||
expect(screen.getByText(/prev/i).closest('button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should navigate on Enter key press', () => {
|
||||
const setCurrentPage = vi.fn()
|
||||
renderPagination({
|
||||
currentPage: 3,
|
||||
setCurrentPage,
|
||||
children: <Pagination.PrevButton>Prev</Pagination.PrevButton>,
|
||||
})
|
||||
fireEvent.keyDown(screen.getByText(/prev/i).closest('button')!, { key: 'Enter', code: 'Enter', keyCode: 13, which: 13 })
|
||||
expect(setCurrentPage).toHaveBeenCalledWith(2)
|
||||
})
|
||||
|
||||
it('should not navigate on Enter when disabled', () => {
|
||||
const setCurrentPage = vi.fn()
|
||||
renderPagination({
|
||||
currentPage: 0,
|
||||
setCurrentPage,
|
||||
children: <Pagination.PrevButton>Prev</Pagination.PrevButton>,
|
||||
})
|
||||
fireEvent.keyDown(screen.getByText(/prev/i).closest('button')!, { key: 'Enter', code: 'Enter', keyCode: 13, which: 13 })
|
||||
expect(setCurrentPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render with custom as element', () => {
|
||||
renderPagination({
|
||||
currentPage: 3,
|
||||
children: <Pagination.PrevButton as={<div />}>Prev</Pagination.PrevButton>,
|
||||
})
|
||||
expect(screen.getByText(/prev/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply dataTestId', () => {
|
||||
renderPagination({
|
||||
currentPage: 3,
|
||||
children: <Pagination.PrevButton dataTestId="prev-btn">Prev</Pagination.PrevButton>,
|
||||
})
|
||||
expect(screen.getByTestId('prev-btn')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('NextButton', () => {
|
||||
it('should render next button', () => {
|
||||
renderPagination({
|
||||
currentPage: 0,
|
||||
children: <Pagination.NextButton>Next</Pagination.NextButton>,
|
||||
})
|
||||
expect(screen.getByText(/next/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call setCurrentPage with next page when clicked', () => {
|
||||
const setCurrentPage = vi.fn()
|
||||
renderPagination({
|
||||
currentPage: 0,
|
||||
totalPages: 10,
|
||||
setCurrentPage,
|
||||
children: <Pagination.NextButton>Next</Pagination.NextButton>,
|
||||
})
|
||||
fireEvent.click(screen.getByText(/next/i))
|
||||
expect(setCurrentPage).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it('should not navigate beyond last page', () => {
|
||||
const setCurrentPage = vi.fn()
|
||||
renderPagination({
|
||||
currentPage: 9,
|
||||
totalPages: 10,
|
||||
setCurrentPage,
|
||||
children: <Pagination.NextButton>Next</Pagination.NextButton>,
|
||||
})
|
||||
fireEvent.click(screen.getByText(/next/i))
|
||||
expect(setCurrentPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should be disabled on last page', () => {
|
||||
renderPagination({
|
||||
currentPage: 9,
|
||||
totalPages: 10,
|
||||
children: <Pagination.NextButton>Next</Pagination.NextButton>,
|
||||
})
|
||||
expect(screen.getByText(/next/i).closest('button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should navigate on Enter key press', () => {
|
||||
const setCurrentPage = vi.fn()
|
||||
renderPagination({
|
||||
currentPage: 0,
|
||||
totalPages: 10,
|
||||
setCurrentPage,
|
||||
children: <Pagination.NextButton>Next</Pagination.NextButton>,
|
||||
})
|
||||
fireEvent.keyDown(screen.getByText(/next/i).closest('button')!, { key: 'Enter', code: 'Enter', keyCode: 13, which: 13 })
|
||||
expect(setCurrentPage).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it('should not navigate on Enter when disabled', () => {
|
||||
const setCurrentPage = vi.fn()
|
||||
renderPagination({
|
||||
currentPage: 9,
|
||||
totalPages: 10,
|
||||
setCurrentPage,
|
||||
children: <Pagination.NextButton>Next</Pagination.NextButton>,
|
||||
})
|
||||
fireEvent.keyDown(screen.getByText(/next/i).closest('button')!, { key: 'Enter', code: 'Enter', keyCode: 13, which: 13 })
|
||||
expect(setCurrentPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should apply dataTestId', () => {
|
||||
renderPagination({
|
||||
currentPage: 0,
|
||||
children: <Pagination.NextButton dataTestId="next-btn">Next</Pagination.NextButton>,
|
||||
})
|
||||
expect(screen.getByTestId('next-btn')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('PageButton', () => {
|
||||
it('should render page number buttons', () => {
|
||||
renderPagination({
|
||||
currentPage: 0,
|
||||
totalPages: 5,
|
||||
children: (
|
||||
<Pagination.PageButton
|
||||
className="page-btn"
|
||||
activeClassName="active"
|
||||
inactiveClassName="inactive"
|
||||
/>
|
||||
),
|
||||
})
|
||||
expect(screen.getByText('1')).toBeInTheDocument()
|
||||
expect(screen.getByText('5')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply activeClassName to current page', () => {
|
||||
renderPagination({
|
||||
currentPage: 2,
|
||||
totalPages: 5,
|
||||
children: (
|
||||
<Pagination.PageButton
|
||||
className="page-btn"
|
||||
activeClassName="active"
|
||||
inactiveClassName="inactive"
|
||||
/>
|
||||
),
|
||||
})
|
||||
// current page is 2, so page 3 (1-indexed) should be active
|
||||
expect(screen.getByText('3').closest('a')).toHaveClass('active')
|
||||
})
|
||||
|
||||
it('should apply inactiveClassName to non-current pages', () => {
|
||||
renderPagination({
|
||||
currentPage: 2,
|
||||
totalPages: 5,
|
||||
children: (
|
||||
<Pagination.PageButton
|
||||
className="page-btn"
|
||||
activeClassName="active"
|
||||
inactiveClassName="inactive"
|
||||
/>
|
||||
),
|
||||
})
|
||||
expect(screen.getByText('1').closest('a')).toHaveClass('inactive')
|
||||
})
|
||||
|
||||
it('should call setCurrentPage when a page button is clicked', () => {
|
||||
const setCurrentPage = vi.fn()
|
||||
renderPagination({
|
||||
currentPage: 0,
|
||||
totalPages: 5,
|
||||
setCurrentPage,
|
||||
children: (
|
||||
<Pagination.PageButton
|
||||
className="page-btn"
|
||||
activeClassName="active"
|
||||
inactiveClassName="inactive"
|
||||
/>
|
||||
),
|
||||
})
|
||||
fireEvent.click(screen.getByText('3'))
|
||||
expect(setCurrentPage).toHaveBeenCalledWith(2) // 0-indexed
|
||||
})
|
||||
|
||||
it('should navigate on Enter key press on a page button', () => {
|
||||
const setCurrentPage = vi.fn()
|
||||
renderPagination({
|
||||
currentPage: 0,
|
||||
totalPages: 5,
|
||||
setCurrentPage,
|
||||
children: (
|
||||
<Pagination.PageButton
|
||||
className="page-btn"
|
||||
activeClassName="active"
|
||||
inactiveClassName="inactive"
|
||||
/>
|
||||
),
|
||||
})
|
||||
fireEvent.keyDown(screen.getByText('4').closest('a')!, { key: 'Enter', code: 'Enter', keyCode: 13, which: 13 })
|
||||
expect(setCurrentPage).toHaveBeenCalledWith(3) // 0-indexed
|
||||
})
|
||||
|
||||
it('should render truncable text when pages are truncated', () => {
|
||||
renderPagination({
|
||||
currentPage: 5,
|
||||
totalPages: 20,
|
||||
edgePageCount: 2,
|
||||
middlePagesSiblingCount: 1,
|
||||
truncableText: '...',
|
||||
children: (
|
||||
<Pagination.PageButton
|
||||
className="page-btn"
|
||||
activeClassName="active"
|
||||
inactiveClassName="inactive"
|
||||
/>
|
||||
),
|
||||
})
|
||||
// With 20 pages and current at 5, there should be truncation
|
||||
expect(screen.getAllByText('...').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle single page', () => {
|
||||
const setCurrentPage = vi.fn()
|
||||
renderPagination({
|
||||
currentPage: 0,
|
||||
totalPages: 1,
|
||||
setCurrentPage,
|
||||
children: (
|
||||
<>
|
||||
<Pagination.PrevButton>Prev</Pagination.PrevButton>
|
||||
<Pagination.PageButton className="page-btn" activeClassName="active" inactiveClassName="inactive" />
|
||||
<Pagination.NextButton>Next</Pagination.NextButton>
|
||||
</>
|
||||
),
|
||||
})
|
||||
expect(screen.getByText(/prev/i).closest('button')).toBeDisabled()
|
||||
expect(screen.getByText(/next/i).closest('button')).toBeDisabled()
|
||||
expect(screen.getByText('1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle zero total pages', () => {
|
||||
const { container } = renderPagination({
|
||||
currentPage: 0,
|
||||
totalPages: 0,
|
||||
children: (
|
||||
<Pagination.PageButton className="page-btn" activeClassName="active" inactiveClassName="inactive" />
|
||||
),
|
||||
})
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should cover undefined active/inactive dataTestIds', () => {
|
||||
// Re-render PageButton without active/inactive data test ids to hit the undefined branch in cn() fallback
|
||||
renderPagination({
|
||||
currentPage: 1,
|
||||
totalPages: 5,
|
||||
children: (
|
||||
<Pagination.PageButton
|
||||
className="page-btn"
|
||||
activeClassName="active"
|
||||
inactiveClassName="inactive"
|
||||
renderExtraProps={page => ({ 'aria-label': `Page ${page}` })}
|
||||
/>
|
||||
),
|
||||
})
|
||||
expect(screen.getByText('2')).toHaveAttribute('aria-label', 'Page 2')
|
||||
})
|
||||
|
||||
it('should cover nextPages when edge pages fall perfectly into middle Pages', () => {
|
||||
renderPagination({
|
||||
currentPage: 5,
|
||||
totalPages: 10,
|
||||
edgePageCount: 8, // Very large edge page count to hit the filter(!middlePages.includes) branches
|
||||
middlePagesSiblingCount: 1,
|
||||
children: (
|
||||
<Pagination.PageButton className="page-btn" activeClassName="active" inactiveClassName="inactive" />
|
||||
),
|
||||
})
|
||||
expect(screen.getByText('1')).toBeInTheDocument()
|
||||
expect(screen.getByText('10')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide truncation element if truncable is false', () => {
|
||||
renderPagination({
|
||||
currentPage: 2,
|
||||
totalPages: 5,
|
||||
edgePageCount: 1,
|
||||
middlePagesSiblingCount: 1,
|
||||
// When we are at page 2, middle pages are [2, 3, 4] (if 0-indexed, wait, currentPage is 0-indexed in hook?)
|
||||
// Let's just render the component which calls the internal TruncableElement, when previous/next are NOT truncable
|
||||
children: (
|
||||
<Pagination.PageButton className="page-btn" activeClassName="active" inactiveClassName="inactive" />
|
||||
),
|
||||
})
|
||||
// Truncation only happens if middlePages > previousPages.last + 1
|
||||
expect(screen.queryByText('...')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hit getAllPreviousPages with less than 1 element', () => {
|
||||
renderPagination({
|
||||
currentPage: 0,
|
||||
totalPages: 10,
|
||||
edgePageCount: 1,
|
||||
middlePagesSiblingCount: 0,
|
||||
children: <Pagination.PageButton className="btn" activeClassName="act" inactiveClassName="inact" />,
|
||||
})
|
||||
// With currentPage = 0, middlePages = [1], getAllPreviousPages() -> slice(0, 0) -> []
|
||||
expect(screen.getByText('1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fire previous() keyboard event even if it does nothing without crashing', () => {
|
||||
// Line 38: pagination.currentPage + 1 > 1 check is usually guarded by disabled, but we can verify it explicitly.
|
||||
const setCurrentPage = vi.fn()
|
||||
// Use a span so that 'disabled' attribute doesn't prevent fireEvent.click from firing
|
||||
renderPagination({
|
||||
currentPage: 0,
|
||||
setCurrentPage,
|
||||
children: <Pagination.PrevButton as={<span />}>Prev</Pagination.PrevButton>,
|
||||
})
|
||||
fireEvent.click(screen.getByText('Prev'))
|
||||
expect(setCurrentPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should fire next() even if it does nothing without crashing', () => {
|
||||
// Line 73: pagination.currentPage + 1 < pages.length verify
|
||||
const setCurrentPage = vi.fn()
|
||||
renderPagination({
|
||||
currentPage: 10,
|
||||
totalPages: 10,
|
||||
setCurrentPage,
|
||||
children: <Pagination.NextButton as={<span />}>Next</Pagination.NextButton>,
|
||||
})
|
||||
fireEvent.click(screen.getByText('Next'))
|
||||
expect(setCurrentPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should fall back to undefined when truncableClassName is empty', () => {
|
||||
// Line 115: `<li className={truncableClassName || undefined}>{truncableText}</li>`
|
||||
renderPagination({
|
||||
currentPage: 5,
|
||||
totalPages: 10,
|
||||
truncableClassName: '',
|
||||
children: (
|
||||
<Pagination.PageButton className="page-btn" activeClassName="active" inactiveClassName="inactive" />
|
||||
),
|
||||
})
|
||||
// Should not have a class attribute
|
||||
const truncableElements = screen.getAllByText('...')
|
||||
expect(truncableElements[0]).not.toHaveAttribute('class')
|
||||
})
|
||||
|
||||
it('should handle dataTestIdActive and dataTestIdInactive completely', () => {
|
||||
// Lines 137-144
|
||||
renderPagination({
|
||||
currentPage: 1, // 0-indexed, so page 2 is active
|
||||
totalPages: 5,
|
||||
children: (
|
||||
<Pagination.PageButton
|
||||
className="page-btn"
|
||||
activeClassName="active"
|
||||
inactiveClassName="inactive"
|
||||
dataTestIdActive="active-test-id"
|
||||
dataTestIdInactive="inactive-test-id"
|
||||
/>
|
||||
),
|
||||
})
|
||||
|
||||
const activeBtn = screen.getByTestId('active-test-id')
|
||||
expect(activeBtn).toHaveTextContent('2')
|
||||
|
||||
const inactiveBtn = screen.getByTestId('inactive-test-id-1') // page 1
|
||||
expect(inactiveBtn).toHaveTextContent('1')
|
||||
})
|
||||
|
||||
it('should hit getAllNextPages.length < 1 in hook', () => {
|
||||
renderPagination({
|
||||
currentPage: 2,
|
||||
totalPages: 3,
|
||||
edgePageCount: 1,
|
||||
middlePagesSiblingCount: 0,
|
||||
children: (
|
||||
<Pagination.PageButton className="page-btn" activeClassName="active" inactiveClassName="inactive" />
|
||||
),
|
||||
})
|
||||
// Current is 3 (index 2). middlePages = [3]. getAllNextPages = slice(3, 3) = []
|
||||
// This will trigger the `getAllNextPages.length < 1` branch
|
||||
expect(screen.getByText('3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle only dataTestIdInactive without dataTestIdActive', () => {
|
||||
renderPagination({
|
||||
currentPage: 1,
|
||||
totalPages: 3,
|
||||
children: (
|
||||
<Pagination.PageButton
|
||||
className="page-btn"
|
||||
activeClassName="active"
|
||||
inactiveClassName="inactive"
|
||||
dataTestIdInactive="inactive-test-id"
|
||||
/>
|
||||
),
|
||||
})
|
||||
// Missing dataTestIdActive branch coverage on line 144
|
||||
expect(screen.getByText('1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle only dataTestIdActive without dataTestIdInactive', () => {
|
||||
renderPagination({
|
||||
currentPage: 1, // page 2 is active
|
||||
totalPages: 3,
|
||||
children: (
|
||||
<Pagination.PageButton
|
||||
className="page-btn"
|
||||
activeClassName="active"
|
||||
inactiveClassName="inactive"
|
||||
dataTestIdActive="active-test-id"
|
||||
/>
|
||||
),
|
||||
})
|
||||
// This hits the branch where dataTestIdActive exists but not dataTestIdInactive
|
||||
expect(screen.getByTestId('active-test-id')).toHaveTextContent('2')
|
||||
expect(screen.queryByTestId('inactive-test-id-1')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,94 +0,0 @@
|
||||
import type { IPaginationProps, IUsePagination } from './type'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
const usePagination = ({
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
truncableText = '...',
|
||||
truncableClassName = '',
|
||||
totalPages,
|
||||
edgePageCount,
|
||||
middlePagesSiblingCount,
|
||||
}: IPaginationProps): IUsePagination => {
|
||||
const pages = React.useMemo(() => Array.from({ length: totalPages }, (_, i) => i + 1), [totalPages])
|
||||
|
||||
const hasPreviousPage = currentPage > 1
|
||||
const hasNextPage = currentPage < totalPages
|
||||
|
||||
const isReachedToFirst = currentPage <= middlePagesSiblingCount
|
||||
const isReachedToLast = currentPage + middlePagesSiblingCount >= totalPages
|
||||
|
||||
const middlePages = React.useMemo(() => {
|
||||
const middlePageCount = middlePagesSiblingCount * 2 + 1
|
||||
if (isReachedToFirst)
|
||||
return pages.slice(0, middlePageCount)
|
||||
|
||||
if (isReachedToLast)
|
||||
return pages.slice(-middlePageCount)
|
||||
|
||||
return pages.slice(
|
||||
currentPage - middlePagesSiblingCount,
|
||||
currentPage + middlePagesSiblingCount + 1,
|
||||
)
|
||||
}, [currentPage, isReachedToFirst, isReachedToLast, middlePagesSiblingCount, pages])
|
||||
|
||||
const getAllPreviousPages = useCallback(() => {
|
||||
return pages.slice(0, middlePages[0]! - 1)
|
||||
}, [middlePages, pages])
|
||||
|
||||
const previousPages = React.useMemo(() => {
|
||||
if (isReachedToFirst || getAllPreviousPages().length < 1)
|
||||
return []
|
||||
|
||||
return pages
|
||||
.slice(0, edgePageCount)
|
||||
.filter(p => !middlePages.includes(p))
|
||||
}, [edgePageCount, getAllPreviousPages, isReachedToFirst, middlePages, pages])
|
||||
|
||||
const getAllNextPages = React.useMemo(() => {
|
||||
return pages.slice(
|
||||
middlePages[middlePages.length - 1],
|
||||
pages[pages.length],
|
||||
)
|
||||
}, [pages, middlePages])
|
||||
|
||||
const nextPages = React.useMemo(() => {
|
||||
if (isReachedToLast)
|
||||
return []
|
||||
|
||||
if (getAllNextPages.length < 1)
|
||||
return []
|
||||
|
||||
return pages
|
||||
.slice(pages.length - edgePageCount, pages.length)
|
||||
.filter(p => !middlePages.includes(p))
|
||||
}, [edgePageCount, getAllNextPages.length, isReachedToLast, middlePages, pages])
|
||||
|
||||
const isPreviousTruncable = React.useMemo(() => {
|
||||
// Is truncable if first value of middlePage is larger than last value of previousPages
|
||||
return middlePages[0]! > previousPages[previousPages.length - 1]! + 1
|
||||
}, [previousPages, middlePages])
|
||||
|
||||
const isNextTruncable = React.useMemo(() => {
|
||||
// Is truncable if last value of middlePage is larger than first value of previousPages
|
||||
return middlePages[middlePages.length - 1]! + 1 < nextPages[0]!
|
||||
}, [nextPages, middlePages])
|
||||
|
||||
return {
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
truncableText,
|
||||
truncableClassName,
|
||||
pages,
|
||||
hasPreviousPage,
|
||||
hasNextPage,
|
||||
previousPages,
|
||||
isPreviousTruncable,
|
||||
middlePages,
|
||||
isNextTruncable,
|
||||
nextPages,
|
||||
}
|
||||
}
|
||||
|
||||
export default usePagination
|
||||
@ -1,81 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import { useMemo, useState } from 'react'
|
||||
import Pagination from '.'
|
||||
|
||||
const TOTAL_ITEMS = 120
|
||||
|
||||
const PaginationDemo = ({
|
||||
initialPage = 0,
|
||||
initialLimit = 10,
|
||||
}: {
|
||||
initialPage?: number
|
||||
initialLimit?: number
|
||||
}) => {
|
||||
const [current, setCurrent] = useState(initialPage)
|
||||
const [limit, setLimit] = useState(initialLimit)
|
||||
|
||||
const pageSummary = useMemo(() => {
|
||||
const start = current * limit + 1
|
||||
const end = Math.min((current + 1) * limit, TOTAL_ITEMS)
|
||||
return `${start}-${end} of ${TOTAL_ITEMS}`
|
||||
}, [current, limit])
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-3xl flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
|
||||
<div className="flex items-center justify-between text-xs tracking-[0.18em] text-text-tertiary uppercase">
|
||||
<span>Log pagination</span>
|
||||
<span className="rounded-md border border-divider-subtle bg-background-default px-2 py-1 font-medium text-text-secondary">
|
||||
{pageSummary}
|
||||
</span>
|
||||
</div>
|
||||
<Pagination
|
||||
current={current}
|
||||
total={TOTAL_ITEMS}
|
||||
limit={limit}
|
||||
onChange={setCurrent}
|
||||
onLimitChange={(nextLimit) => {
|
||||
setCurrent(0)
|
||||
setLimit(nextLimit)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Navigation/Pagination',
|
||||
component: PaginationDemo,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Paginate long lists with optional per-page selector. Demonstrates the inline page jump input and quick limit toggles.',
|
||||
},
|
||||
},
|
||||
},
|
||||
args: {
|
||||
initialPage: 0,
|
||||
initialLimit: 10,
|
||||
},
|
||||
argTypes: {
|
||||
initialPage: {
|
||||
control: { type: 'number', min: 0, max: 9, step: 1 },
|
||||
},
|
||||
initialLimit: {
|
||||
control: { type: 'radio' },
|
||||
options: [10, 25, 50],
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof PaginationDemo>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {}
|
||||
|
||||
export const StartAtMiddle: Story = {
|
||||
args: {
|
||||
initialPage: 4,
|
||||
},
|
||||
}
|
||||
@ -1,201 +0,0 @@
|
||||
import type { FC } from 'react'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RiArrowLeftLine, RiArrowRightLine } from '@remixicon/react'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { Pagination } from './pagination'
|
||||
|
||||
export type Props = {
|
||||
className?: string
|
||||
current: number
|
||||
onChange: (cur: number) => void
|
||||
total: number
|
||||
limit?: number
|
||||
onLimitChange?: (limit: number) => void
|
||||
}
|
||||
|
||||
const CustomizedPagination: FC<Props> = ({
|
||||
className,
|
||||
current,
|
||||
onChange,
|
||||
total,
|
||||
limit = 10,
|
||||
onLimitChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const totalPages = Math.ceil(total / limit)
|
||||
const inputRef = React.useRef<HTMLDivElement>(null)
|
||||
const [showInput, setShowInput] = React.useState(false)
|
||||
const [inputValue, setInputValue] = React.useState<string | number>(current + 1)
|
||||
const [showPerPageTip, setShowPerPageTip] = React.useState(false)
|
||||
|
||||
const { run: handlePaging } = useDebounceFn((value: string) => {
|
||||
if (Number.parseInt(value) > totalPages) {
|
||||
setInputValue(totalPages)
|
||||
onChange(totalPages - 1)
|
||||
setShowInput(false)
|
||||
return
|
||||
}
|
||||
if (Number.parseInt(value) < 1) {
|
||||
setInputValue(1)
|
||||
onChange(0)
|
||||
setShowInput(false)
|
||||
return
|
||||
}
|
||||
onChange(Number.parseInt(value) - 1)
|
||||
setInputValue(Number.parseInt(value))
|
||||
setShowInput(false)
|
||||
}, { wait: 500 })
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value
|
||||
if (!value)
|
||||
return setInputValue('')
|
||||
if (isNaN(Number.parseInt(value)))
|
||||
return setInputValue('')
|
||||
setInputValue(Number.parseInt(value))
|
||||
}
|
||||
|
||||
const handleInputConfirm = () => {
|
||||
if (inputValue !== '' && String(inputValue) !== String(current + 1)) {
|
||||
handlePaging(String(inputValue))
|
||||
return
|
||||
}
|
||||
|
||||
if (inputValue === '')
|
||||
setInputValue(current + 1)
|
||||
|
||||
setShowInput(false)
|
||||
}
|
||||
|
||||
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleInputConfirm()
|
||||
}
|
||||
else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
setInputValue(current + 1)
|
||||
setShowInput(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputBlur = () => {
|
||||
handleInputConfirm()
|
||||
}
|
||||
|
||||
return (
|
||||
<Pagination
|
||||
className={cn('flex w-full items-center px-6 py-3 select-none', className)}
|
||||
currentPage={current}
|
||||
edgePageCount={2}
|
||||
middlePagesSiblingCount={1}
|
||||
setCurrentPage={onChange}
|
||||
totalPages={totalPages}
|
||||
truncableClassName="flex items-center justify-center w-8 px-1 py-2 system-sm-medium text-text-tertiary"
|
||||
truncableText="..."
|
||||
>
|
||||
<div className="flex items-center gap-0.5 rounded-[10px] bg-background-section-burn p-0.5">
|
||||
<Pagination.PrevButton
|
||||
as={<div></div>}
|
||||
disabled={current === 0}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="size-7 px-1.5"
|
||||
disabled={current === 0}
|
||||
>
|
||||
<RiArrowLeftLine className="size-4" />
|
||||
</Button>
|
||||
</Pagination.PrevButton>
|
||||
{!showInput && (
|
||||
<div
|
||||
ref={inputRef}
|
||||
className="flex items-center gap-0.5 rounded-lg px-2 py-1.5 hover:cursor-text hover:bg-state-base-hover-alt"
|
||||
onClick={() => setShowInput(true)}
|
||||
>
|
||||
<div className="system-xs-medium text-text-secondary">{current + 1}</div>
|
||||
<div className="system-xs-medium text-text-quaternary">/</div>
|
||||
<div className="system-xs-medium text-text-secondary">{totalPages}</div>
|
||||
</div>
|
||||
)}
|
||||
{showInput && (
|
||||
<Input
|
||||
styleCss={{
|
||||
height: '28px',
|
||||
width: `${inputRef.current?.clientWidth}px`,
|
||||
}}
|
||||
placeholder=""
|
||||
autoFocus
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onBlur={handleInputBlur}
|
||||
/>
|
||||
)}
|
||||
<Pagination.NextButton
|
||||
as={<div></div>}
|
||||
disabled={current === totalPages - 1}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="size-7 px-1.5"
|
||||
disabled={current === totalPages - 1}
|
||||
>
|
||||
<RiArrowRightLine className="size-4" />
|
||||
</Button>
|
||||
</Pagination.NextButton>
|
||||
</div>
|
||||
<div className={cn('flex grow list-none items-center justify-center gap-1')}>
|
||||
<Pagination.PageButton
|
||||
className="flex min-w-8 cursor-pointer items-center justify-center rounded-lg px-1 py-2 system-sm-medium hover:bg-components-button-ghost-bg-hover"
|
||||
activeClassName="bg-components-button-tertiary-bg text-components-button-tertiary-text hover:bg-components-button-ghost-bg-hover"
|
||||
inactiveClassName="text-text-tertiary"
|
||||
/>
|
||||
</div>
|
||||
{onLimitChange && (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<div className="w-[51px] shrink-0 text-end system-2xs-regular-uppercase text-text-tertiary">{showPerPageTip ? t('pagination.perPage', { ns: 'common' }) : ''}</div>
|
||||
<div
|
||||
className="flex items-center gap-px rounded-[10px] bg-components-segmented-control-bg-normal p-0.5"
|
||||
onMouseEnter={() => setShowPerPageTip(true)}
|
||||
onMouseLeave={() => setShowPerPageTip(false)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'cursor-pointer rounded-lg border-[0.5px] border-transparent px-2.5 py-1.5 system-sm-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
|
||||
limit === 10 && 'border-components-segmented-control-item-active-border bg-components-segmented-control-item-active-bg text-text-secondary shadow-xs hover:bg-components-segmented-control-item-active-bg',
|
||||
)}
|
||||
onClick={() => onLimitChange?.(10)}
|
||||
>
|
||||
10
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'cursor-pointer rounded-lg border-[0.5px] border-transparent px-2.5 py-1.5 system-sm-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
|
||||
limit === 25 && 'border-components-segmented-control-item-active-border bg-components-segmented-control-item-active-bg text-text-secondary shadow-xs hover:bg-components-segmented-control-item-active-bg',
|
||||
)}
|
||||
onClick={() => onLimitChange?.(25)}
|
||||
>
|
||||
25
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'cursor-pointer rounded-lg border-[0.5px] border-transparent px-2.5 py-1.5 system-sm-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
|
||||
limit === 50 && 'border-components-segmented-control-item-active-border bg-components-segmented-control-item-active-bg text-text-secondary shadow-xs hover:bg-components-segmented-control-item-active-bg',
|
||||
)}
|
||||
onClick={() => onLimitChange?.(50)}
|
||||
>
|
||||
50
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Pagination>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomizedPagination
|
||||
@ -1,190 +0,0 @@
|
||||
import type {
|
||||
ButtonProps,
|
||||
IPagination,
|
||||
IPaginationProps,
|
||||
PageButtonProps,
|
||||
} from './type'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import * as React from 'react'
|
||||
import usePagination from './hook'
|
||||
|
||||
const defaultState: IPagination = {
|
||||
currentPage: 0,
|
||||
setCurrentPage: noop,
|
||||
truncableText: '...',
|
||||
truncableClassName: '',
|
||||
pages: [],
|
||||
hasPreviousPage: false,
|
||||
hasNextPage: false,
|
||||
previousPages: [],
|
||||
isPreviousTruncable: false,
|
||||
middlePages: [],
|
||||
isNextTruncable: false,
|
||||
nextPages: [],
|
||||
}
|
||||
|
||||
const PaginationContext: React.Context<IPagination> = React.createContext<IPagination>(defaultState)
|
||||
|
||||
const PrevButton = ({
|
||||
className,
|
||||
children,
|
||||
dataTestId,
|
||||
as = <button type="button" />,
|
||||
...buttonProps
|
||||
}: ButtonProps) => {
|
||||
const pagination = React.useContext(PaginationContext)
|
||||
const previous = () => {
|
||||
if (pagination.currentPage + 1 > 1)
|
||||
pagination.setCurrentPage(pagination.currentPage - 1)
|
||||
}
|
||||
|
||||
const disabled = pagination.currentPage === 0
|
||||
|
||||
return (
|
||||
<as.type
|
||||
{...buttonProps}
|
||||
{...as.props}
|
||||
className={cn(className, as.props.className)}
|
||||
onClick={() => previous()}
|
||||
tabIndex={disabled ? '-1' : 0}
|
||||
disabled={disabled}
|
||||
data-testid={dataTestId}
|
||||
onKeyDown={(event: React.KeyboardEvent) => {
|
||||
event.preventDefault()
|
||||
if (event.key === 'Enter' && !disabled)
|
||||
previous()
|
||||
}}
|
||||
>
|
||||
{as.props.children ?? children}
|
||||
</as.type>
|
||||
)
|
||||
}
|
||||
|
||||
const NextButton = ({
|
||||
className,
|
||||
children,
|
||||
dataTestId,
|
||||
as = <button type="button" />,
|
||||
...buttonProps
|
||||
}: ButtonProps) => {
|
||||
const pagination = React.useContext(PaginationContext)
|
||||
const next = () => {
|
||||
if (pagination.currentPage + 1 < pagination.pages.length)
|
||||
pagination.setCurrentPage(pagination.currentPage + 1)
|
||||
}
|
||||
|
||||
const disabled = pagination.currentPage === pagination.pages.length - 1
|
||||
|
||||
return (
|
||||
<as.type
|
||||
{...buttonProps}
|
||||
{...as.props}
|
||||
className={cn(className, as.props.className)}
|
||||
onClick={() => next()}
|
||||
tabIndex={disabled ? '-1' : 0}
|
||||
disabled={disabled}
|
||||
data-testid={dataTestId}
|
||||
onKeyDown={(event: React.KeyboardEvent) => {
|
||||
event.preventDefault()
|
||||
if (event.key === 'Enter' && !disabled)
|
||||
next()
|
||||
}}
|
||||
>
|
||||
{as.props.children ?? children}
|
||||
</as.type>
|
||||
)
|
||||
}
|
||||
|
||||
type ITruncableElementProps = {
|
||||
prev?: boolean
|
||||
}
|
||||
|
||||
const TruncableElement = ({ prev }: ITruncableElementProps) => {
|
||||
const pagination: IPagination = React.useContext(PaginationContext)
|
||||
|
||||
const {
|
||||
isPreviousTruncable,
|
||||
isNextTruncable,
|
||||
truncableText,
|
||||
truncableClassName,
|
||||
} = pagination
|
||||
|
||||
return ((isPreviousTruncable && prev === true) || (isNextTruncable && !prev))
|
||||
? (
|
||||
<li className={truncableClassName || undefined}>{truncableText}</li>
|
||||
)
|
||||
: null
|
||||
}
|
||||
|
||||
const PageButton = ({
|
||||
as = <a />,
|
||||
className,
|
||||
dataTestIdActive,
|
||||
dataTestIdInactive,
|
||||
activeClassName,
|
||||
inactiveClassName,
|
||||
renderExtraProps,
|
||||
}: PageButtonProps) => {
|
||||
const pagination: IPagination = React.useContext(PaginationContext)
|
||||
|
||||
const renderPageButton = (page: number) => (
|
||||
<li key={page}>
|
||||
<as.type
|
||||
data-testid={
|
||||
cn({
|
||||
[`${dataTestIdActive}`]:
|
||||
dataTestIdActive && pagination.currentPage + 1 === page,
|
||||
[`${dataTestIdInactive}-${page}`]:
|
||||
dataTestIdActive && pagination.currentPage + 1 !== page,
|
||||
}) || undefined
|
||||
}
|
||||
tabIndex={0}
|
||||
onKeyDown={(event: React.KeyboardEvent) => {
|
||||
if (event.key === 'Enter')
|
||||
pagination.setCurrentPage(page - 1)
|
||||
}}
|
||||
onClick={() => pagination.setCurrentPage(page - 1)}
|
||||
className={cn(
|
||||
className,
|
||||
pagination.currentPage + 1 === page
|
||||
? activeClassName
|
||||
: inactiveClassName,
|
||||
)}
|
||||
{...as.props}
|
||||
{...(renderExtraProps ? renderExtraProps(page) : {})}
|
||||
>
|
||||
{page}
|
||||
</as.type>
|
||||
</li>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{pagination.previousPages.map(renderPageButton)}
|
||||
<TruncableElement prev />
|
||||
{pagination.middlePages.map(renderPageButton)}
|
||||
<TruncableElement />
|
||||
{pagination.nextPages.map(renderPageButton)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const Pagination = ({
|
||||
dataTestId,
|
||||
...paginationProps
|
||||
}: IPaginationProps & { dataTestId?: string }) => {
|
||||
const pagination = usePagination(paginationProps)
|
||||
|
||||
return (
|
||||
<PaginationContext.Provider value={pagination}>
|
||||
<div className={paginationProps.className} data-testid={dataTestId}>
|
||||
{paginationProps.children}
|
||||
</div>
|
||||
</PaginationContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
Pagination.PrevButton = PrevButton
|
||||
Pagination.NextButton = NextButton
|
||||
Pagination.PageButton = PageButton
|
||||
@ -1,64 +0,0 @@
|
||||
import type { ButtonHTMLAttributes } from 'react'
|
||||
|
||||
type ElementProps = {
|
||||
className?: string
|
||||
children?: React.ReactNode
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type IBasePaginationProps = {
|
||||
currentPage: number
|
||||
setCurrentPage: (page: number) => void
|
||||
truncableText?: string
|
||||
truncableClassName?: string
|
||||
}
|
||||
|
||||
type IPaginationProps = IBasePaginationProps & {
|
||||
totalPages: number
|
||||
edgePageCount: number
|
||||
middlePagesSiblingCount: number
|
||||
className?: string
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
type IUsePagination = IBasePaginationProps & {
|
||||
pages: number[]
|
||||
hasPreviousPage: boolean
|
||||
hasNextPage: boolean
|
||||
previousPages: number[]
|
||||
isPreviousTruncable: boolean
|
||||
middlePages: number[]
|
||||
isNextTruncable: boolean
|
||||
nextPages: number[]
|
||||
}
|
||||
|
||||
type IPagination = IUsePagination & {
|
||||
setCurrentPage: (page: number) => void
|
||||
}
|
||||
|
||||
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
as?: React.ReactElement<ElementProps>
|
||||
children?: string | React.ReactNode
|
||||
className?: string
|
||||
dataTestId?: string
|
||||
}
|
||||
|
||||
type PageButtonProps = ButtonProps & {
|
||||
/**
|
||||
* Provide a custom ReactElement (e.g. Next/Link)
|
||||
*/
|
||||
as?: React.ReactElement<ElementProps>
|
||||
activeClassName?: string
|
||||
inactiveClassName?: string
|
||||
dataTestIdActive?: string
|
||||
dataTestIdInactive?: string
|
||||
renderExtraProps?: (pageNum: number) => {}
|
||||
}
|
||||
|
||||
export type {
|
||||
ButtonProps,
|
||||
IPagination,
|
||||
IPaginationProps,
|
||||
IUsePagination,
|
||||
PageButtonProps,
|
||||
}
|
||||
@ -1,5 +1,4 @@
|
||||
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'
|
||||
@ -9,6 +8,14 @@ import DocumentList from '../../list'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
|
||||
type PaginationProps = {
|
||||
current: number
|
||||
onChange: (page: number) => void
|
||||
total: number
|
||||
limit?: number
|
||||
onLimitChange?: (limit: number) => void
|
||||
}
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
|
||||
@ -1,12 +1,11 @@
|
||||
'use client'
|
||||
import type { Props as PaginationProps } from '@/app/components/base/pagination'
|
||||
import type { SimpleDocumentDetail } from '@/models/datasets'
|
||||
import { Checkbox } from '@langgenius/dify-ui/checkbox'
|
||||
import { CheckboxGroup } from '@langgenius/dify-ui/checkbox-group'
|
||||
import { Pagination } from '@langgenius/dify-ui/pagination'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Pagination from '@/app/components/base/pagination'
|
||||
import EditMetadataBatchModal from '@/app/components/datasets/metadata/edit-metadata-batch/modal'
|
||||
import useBatchEditDocumentMetadata from '@/app/components/datasets/metadata/hooks/use-batch-edit-document-metadata'
|
||||
import { useDatasetDetailContextWithSelector as useDatasetDetailContext } from '@/context/dataset-detail'
|
||||
@ -19,6 +18,15 @@ import RenameModal from './rename-modal'
|
||||
|
||||
type LocalDoc = SimpleDocumentDetail & { percent?: number }
|
||||
|
||||
type PaginationProps = {
|
||||
className?: string
|
||||
current: number
|
||||
total: number
|
||||
limit?: number
|
||||
onChange: (page: number) => void
|
||||
onLimitChange?: (limit: number) => void
|
||||
}
|
||||
|
||||
type DocumentListProps = {
|
||||
embeddingAvailable: boolean
|
||||
documents: LocalDoc[]
|
||||
@ -48,6 +56,8 @@ const DocumentList = ({
|
||||
onSortChange,
|
||||
}: DocumentListProps) => {
|
||||
const { t } = useTranslation()
|
||||
const pageSize = pagination.limit ?? 10
|
||||
const totalPages = Math.max(Math.ceil(pagination.total / pageSize), 1)
|
||||
const datasetConfig = useDatasetDetailContext(s => s.dataset)
|
||||
const chunkingMode = datasetConfig?.doc_form
|
||||
const isGeneralMode = chunkingMode !== ChunkingMode.parentChild
|
||||
@ -198,8 +208,25 @@ const DocumentList = ({
|
||||
|
||||
{!!pagination.total && (
|
||||
<Pagination
|
||||
{...pagination}
|
||||
className="w-full shrink-0"
|
||||
className="shrink-0"
|
||||
page={pagination.current + 1}
|
||||
totalPages={totalPages}
|
||||
onPageChange={page => pagination.onChange(page - 1)}
|
||||
labels={{
|
||||
previous: t('pagination.previous', { ns: 'common' }),
|
||||
next: t('pagination.next', { ns: 'common' }),
|
||||
editPageNumber: (page, totalPages) => t('pagination.editPageNumber', { ns: 'common', page, totalPages }),
|
||||
pageNumberInput: t('pagination.pageNumber', { ns: 'common' }),
|
||||
}}
|
||||
pageSize={pagination.onLimitChange
|
||||
? {
|
||||
value: pageSize,
|
||||
options: [10, 25, 50],
|
||||
onValueChange: pagination.onLimitChange,
|
||||
label: t('pagination.perPage', { ns: 'common' }),
|
||||
ariaLabel: t('pagination.perPage', { ns: 'common' }),
|
||||
}
|
||||
: undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -203,18 +203,22 @@ vi.mock('@/app/components/base/divider', () => ({
|
||||
default: () => <hr data-testid="divider" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/pagination', () => ({
|
||||
default: ({ current, total, onChange, onLimitChange }: {
|
||||
current: number
|
||||
total: number
|
||||
onChange: (page: number) => void
|
||||
onLimitChange: (limit: number) => void
|
||||
vi.mock('@langgenius/dify-ui/pagination', () => ({
|
||||
Pagination: ({ page, totalPages, onPageChange, pageSize }: {
|
||||
page: number
|
||||
totalPages: number
|
||||
onPageChange: (page: number) => void
|
||||
pageSize?: {
|
||||
onValueChange: (limit: number) => void
|
||||
}
|
||||
}) => (
|
||||
<div data-testid="pagination">
|
||||
<span data-testid="current-page">{current}</span>
|
||||
<span data-testid="total-items">{total}</span>
|
||||
<button data-testid="next-page" onClick={() => onChange(current + 1)}>Next</button>
|
||||
<button data-testid="change-limit" onClick={() => onLimitChange(20)}>Change Limit</button>
|
||||
<span data-testid="current-page">{page - 1}</span>
|
||||
<span data-testid="total-pages">{totalPages}</span>
|
||||
<button data-testid="next-page" onClick={() => onPageChange(page + 1)}>Next</button>
|
||||
{pageSize && (
|
||||
<button data-testid="change-limit" onClick={() => pageSize.onValueChange(20)}>Change Limit</button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
@ -1180,15 +1184,14 @@ describe('Inline callback and hook initialization coverage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Covers paginationTotal in full-doc mode
|
||||
it('should compute pagination total from child chunk data in full-doc mode', () => {
|
||||
it('should compute pagination pages from child chunk data in full-doc mode', () => {
|
||||
mockDocForm.current = ChunkingModeEnum.parentChild
|
||||
mockParentMode.current = 'full-doc'
|
||||
mockChildSegmentListData.total = 42
|
||||
|
||||
render(<Completed {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
expect(screen.getByTestId('total-items'))!.toHaveTextContent('42')
|
||||
expect(screen.getByTestId('total-pages'))!.toHaveTextContent('5')
|
||||
})
|
||||
|
||||
// Covers search input change
|
||||
|
||||
@ -3,10 +3,10 @@ import type { FC } from 'react'
|
||||
import type { SegmentListContextValue } from './segment-list-context'
|
||||
import type { SegmentImportStatus } from '@/types/dataset'
|
||||
import { CheckboxGroup } from '@langgenius/dify-ui/checkbox-group'
|
||||
import { Pagination } from '@langgenius/dify-ui/pagination'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import Pagination from '@/app/components/base/pagination'
|
||||
import {
|
||||
useChunkListAllKey,
|
||||
useChunkListDisabledKey,
|
||||
@ -142,10 +142,11 @@ const Completed: FC<ICompletedProps> = ({
|
||||
return childSegmentDataHook.childChunkListData?.total || 0
|
||||
return segmentListDataHook.segmentListData?.total || 0
|
||||
}, [segmentListDataHook.isFullDocMode, childSegmentDataHook.childChunkListData, segmentListDataHook.segmentListData])
|
||||
const totalPages = Math.max(Math.ceil(paginationTotal / limit), 1)
|
||||
|
||||
// Handle page change
|
||||
const handlePageChange = useCallback((page: number) => {
|
||||
setCurrentPage(page + 1)
|
||||
setCurrentPage(page)
|
||||
}, [])
|
||||
|
||||
// Context value
|
||||
@ -225,12 +226,22 @@ const Completed: FC<ICompletedProps> = ({
|
||||
{/* Pagination */}
|
||||
<Divider type="horizontal" className="mx-6 my-0 h-px w-auto bg-divider-subtle" />
|
||||
<Pagination
|
||||
current={currentPage - 1}
|
||||
onChange={handlePageChange}
|
||||
total={paginationTotal}
|
||||
limit={limit}
|
||||
onLimitChange={setLimit}
|
||||
className={segmentListDataHook.isFullDocMode ? 'px-3' : ''}
|
||||
page={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
labels={{
|
||||
previous: t('pagination.previous', { ns: 'common' }),
|
||||
next: t('pagination.next', { ns: 'common' }),
|
||||
editPageNumber: (page, totalPages) => t('pagination.editPageNumber', { ns: 'common', page, totalPages }),
|
||||
pageNumberInput: t('pagination.pageNumber', { ns: 'common' }),
|
||||
}}
|
||||
pageSize={{
|
||||
value: limit,
|
||||
options: [10, 25, 50],
|
||||
onValueChange: setLimit,
|
||||
label: t('pagination.perPage', { ns: 'common' }),
|
||||
ariaLabel: t('pagination.perPage', { ns: 'common' }),
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Drawer Group - only render when docForm is available */}
|
||||
|
||||
@ -18,6 +18,7 @@ import {
|
||||
DrawerPortal,
|
||||
DrawerViewport,
|
||||
} from '@langgenius/dify-ui/drawer'
|
||||
import { Pagination } from '@langgenius/dify-ui/pagination'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
@ -25,7 +26,6 @@ import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import FloatRightContainer from '@/app/components/base/float-right-container'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import Pagination from '@/app/components/base/pagination'
|
||||
import docStyle from '@/app/components/datasets/documents/detail/completed/style.module.css'
|
||||
import DatasetDetailContext from '@/context/dataset-detail'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
@ -63,6 +63,7 @@ const HitTestingPage: FC<Props> = ({ datasetId }: Props) => {
|
||||
const { data: recordsRes, refetch: recordsRefetch, isLoading: isRecordsLoading } = useDatasetTestingRecords(datasetId, { limit, page: currPage + 1 })
|
||||
|
||||
const total = recordsRes?.total || 0
|
||||
const totalPages = total ? Math.max(Math.ceil(total / limit), 1) : 1
|
||||
|
||||
const { dataset: currentDataset } = useContext(DatasetDetailContext)
|
||||
const isExternal = currentDataset?.provider === 'external'
|
||||
@ -151,7 +152,19 @@ const HitTestingPage: FC<Props> = ({ datasetId }: Props) => {
|
||||
<>
|
||||
<Records records={recordsRes?.data} onClickRecord={handleClickRecord} />
|
||||
{(total && total > limit)
|
||||
? <Pagination current={currPage} onChange={setCurrPage} total={total} limit={limit} />
|
||||
? (
|
||||
<Pagination
|
||||
page={currPage + 1}
|
||||
totalPages={totalPages}
|
||||
onPageChange={page => setCurrPage(page - 1)}
|
||||
labels={{
|
||||
previous: t('pagination.previous', { ns: 'common' }),
|
||||
next: t('pagination.next', { ns: 'common' }),
|
||||
editPageNumber: (page, totalPages) => t('pagination.editPageNumber', { ns: 'common', page, totalPages }),
|
||||
pageNumberInput: t('pagination.pageNumber', { ns: 'common' }),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -104,23 +104,12 @@ vi.mock('../../nav', () => ({
|
||||
onCreate,
|
||||
onLoadMore,
|
||||
navigationItems,
|
||||
activeSegment,
|
||||
activeLink,
|
||||
text,
|
||||
}: {
|
||||
onCreate: (state: string) => void
|
||||
onLoadMore?: () => void
|
||||
navigationItems?: Array<{ id: string, name: string, link: string }>
|
||||
activeSegment?: string | string[]
|
||||
activeLink?: { segment: string, text: string, link: string }
|
||||
text?: string
|
||||
}) => (
|
||||
<div data-testid="nav">
|
||||
<div data-testid="nav-text">{text}</div>
|
||||
<div data-testid="nav-active-segment">{JSON.stringify(activeSegment)}</div>
|
||||
{activeLink && (
|
||||
<div data-testid="nav-active-link">{`${activeLink.segment}:${activeLink.text}->${activeLink.link}`}</div>
|
||||
)}
|
||||
<ul data-testid="nav-items">
|
||||
{(navigationItems ?? []).map(item => (
|
||||
<li key={item.id}>{`${item.name} -> ${item.link}`}</li>
|
||||
@ -212,15 +201,6 @@ describe('AppNav', () => {
|
||||
expect(options.getNextPageParam({ has_more: false, page: 3 })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should configure snippets as an active studio child link', () => {
|
||||
setupDefaultMocks()
|
||||
render(<AppNav />)
|
||||
|
||||
expect(screen.getByTestId('nav-text')).toHaveTextContent('menus.apps')
|
||||
expect(screen.getByTestId('nav-active-segment')).toHaveTextContent(JSON.stringify(['apps', 'app', 'snippets']))
|
||||
expect(screen.getByTestId('nav-active-link')).toHaveTextContent('snippets:tabs.snippets->/snippets')
|
||||
})
|
||||
|
||||
it('should build editor links and update app name when app detail changes', async () => {
|
||||
setupDefaultMocks({
|
||||
isEditor: true,
|
||||
|
||||
@ -103,13 +103,8 @@ const AppNav = () => {
|
||||
icon={<RiRobot2Line className="size-4" />}
|
||||
activeIcon={<RiRobot2Fill className="size-4" />}
|
||||
text={t('menus.apps', { ns: 'common' })}
|
||||
activeSegment={['apps', 'app', 'snippets']}
|
||||
activeSegment={['apps', 'app']}
|
||||
link="/apps"
|
||||
activeLink={{
|
||||
segment: 'snippets',
|
||||
text: t('tabs.snippets', { ns: 'workflow' }),
|
||||
link: '/snippets',
|
||||
}}
|
||||
curNav={appDetail}
|
||||
navigationItems={navItems}
|
||||
createText={t('menus.newApp', { ns: 'common' })}
|
||||
|
||||
@ -14,7 +14,7 @@ const HeaderWrapper = ({
|
||||
children,
|
||||
}: HeaderWrapperProps) => {
|
||||
const pathname = usePathname()
|
||||
const isBordered = ['/apps', '/snippets', '/datasets/create', '/tools'].includes(pathname)
|
||||
const isBordered = ['/apps', '/datasets/create', '/tools'].includes(pathname)
|
||||
// Check if the current path is a workflow canvas & fullscreen
|
||||
const inWorkflowCanvas = pathname.endsWith('/workflow')
|
||||
const isPipelineCanvas = pathname.endsWith('/pipeline')
|
||||
|
||||
@ -123,27 +123,6 @@ describe('Nav Component', () => {
|
||||
expect(screen.getByTestId('active-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render active child link when activeLink matches the current segment', () => {
|
||||
vi.mocked(useSelectedLayoutSegment).mockReturnValue('snippets')
|
||||
|
||||
render(
|
||||
<Nav
|
||||
{...defaultProps}
|
||||
activeSegment={['apps', 'app', 'snippets']}
|
||||
activeLink={{
|
||||
segment: 'snippets',
|
||||
text: 'SNIPPETS',
|
||||
link: '/snippets',
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Nav Text')).toBeInTheDocument()
|
||||
expect(screen.getByText('Nav Text')).toHaveClass('max-[1024px]:hidden')
|
||||
expect(screen.getByRole('link', { name: 'SNIPPETS' })).toHaveAttribute('href', '/snippets')
|
||||
expect(screen.getByRole('link', { name: 'SNIPPETS' })).not.toHaveClass('max-[1024px]:hidden')
|
||||
})
|
||||
|
||||
it('should not show hover background if not activated', () => {
|
||||
vi.mocked(useSelectedLayoutSegment).mockReturnValue('other')
|
||||
const { container } = render(<Nav {...defaultProps} />)
|
||||
@ -169,14 +148,6 @@ describe('Nav Component', () => {
|
||||
expect(mockSetAppDetail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call setAppDetail from snippets segment', () => {
|
||||
vi.mocked(useSelectedLayoutSegment).mockReturnValue('snippets')
|
||||
render(<Nav {...defaultProps} activeSegment={['apps', 'app', 'snippets']} />)
|
||||
const link = screen.getByRole('link')
|
||||
fireEvent.click(link.firstChild!)
|
||||
expect(mockSetAppDetail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show ArrowNarrowLeft on hover when curNav is provided and activated', () => {
|
||||
const curNav = navigationItems[0]
|
||||
render(<Nav {...defaultProps} curNav={curNav} />)
|
||||
@ -214,20 +185,19 @@ describe('Nav Component', () => {
|
||||
})
|
||||
|
||||
it('should navigate when an item is selected', async () => {
|
||||
vi.mocked(useSelectedLayoutSegment).mockReturnValue('snippets')
|
||||
render(<Nav {...defaultProps} activeSegment={['apps', 'app', 'snippets']} curNav={curNav} />)
|
||||
render(<Nav {...defaultProps} curNav={curNav} />)
|
||||
const selectorButton = screen.getByRole('button', { name: /Item 1/i })
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(selectorButton)
|
||||
})
|
||||
mockSetAppDetail.mockClear()
|
||||
|
||||
const item2 = await screen.findByText('Item 2')
|
||||
await act(async () => {
|
||||
fireEvent.click(item2)
|
||||
})
|
||||
|
||||
expect(mockSetAppDetail).toHaveBeenCalled()
|
||||
expect(mockPush).toHaveBeenCalledWith('/item2')
|
||||
})
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ import { cn } from '@langgenius/dify-ui/cn'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { ArrowNarrowLeft } from '@/app/components/base/icons/src/vender/line/arrows'
|
||||
import Link from '@/next/link'
|
||||
import { useSelectedLayoutSegment } from '@/next/navigation'
|
||||
import NavSelector from './nav-selector'
|
||||
@ -15,11 +16,6 @@ type INavProps = {
|
||||
text: string
|
||||
activeSegment: string | string[]
|
||||
link: string
|
||||
activeLink?: {
|
||||
segment: string
|
||||
text: string
|
||||
link: string
|
||||
}
|
||||
isApp: boolean
|
||||
} & INavSelectorProps
|
||||
|
||||
@ -29,7 +25,6 @@ const Nav = ({
|
||||
text,
|
||||
activeSegment,
|
||||
link,
|
||||
activeLink,
|
||||
curNav,
|
||||
navigationItems,
|
||||
createText,
|
||||
@ -42,11 +37,10 @@ const Nav = ({
|
||||
const [hovered, setHovered] = useState(false)
|
||||
const segment = useSelectedLayoutSegment()
|
||||
const isActivated = Array.isArray(activeSegment) ? activeSegment.includes(segment!) : segment === activeSegment
|
||||
const shouldShowActiveLink = isActivated && activeLink && segment === activeLink.segment
|
||||
|
||||
return (
|
||||
<div className={`
|
||||
flex h-8 max-w-167.5 shrink-0 items-center rounded-xl px-0.5 text-sm font-medium max-[1024px]:max-w-100
|
||||
flex h-8 max-w-[670px] shrink-0 items-center rounded-xl px-0.5 text-sm font-medium max-[1024px]:max-w-[400px]
|
||||
${isActivated && 'bg-components-main-nav-nav-button-bg-active font-semibold shadow-md'}
|
||||
${!curNav && !isActivated && 'hover:bg-components-main-nav-nav-button-bg-hover'}
|
||||
`}
|
||||
@ -57,8 +51,6 @@ const Nav = ({
|
||||
// Don't clear state if opening in new tab/window
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0)
|
||||
return
|
||||
if (segment === 'snippets')
|
||||
return
|
||||
setAppDetail()
|
||||
}}
|
||||
className={cn('flex h-7 cursor-pointer items-center rounded-[10px] px-2.5', isActivated ? 'text-components-main-nav-nav-button-text-active' : 'text-components-main-nav-nav-button-text', curNav && isActivated && 'hover:bg-components-main-nav-nav-button-bg-active-hover')}
|
||||
@ -68,7 +60,7 @@ const Nav = ({
|
||||
<div>
|
||||
{
|
||||
(hovered && curNav)
|
||||
? <span className="i-custom-vender-line-arrows-arrow-narrow-left size-4" />
|
||||
? <ArrowNarrowLeft className="size-4" />
|
||||
: isActivated
|
||||
? activeIcon
|
||||
: icon
|
||||
@ -95,19 +87,6 @@ const Nav = ({
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
!curNav && shouldShowActiveLink && (
|
||||
<>
|
||||
<div className="font-light text-divider-deep">/</div>
|
||||
<Link
|
||||
href={activeLink.link}
|
||||
className="hover:bg-components-main-nav-nav-button-bg-active-hover flex h-7 cursor-pointer items-center rounded-[10px] px-2.5 text-components-main-nav-nav-button-text-active"
|
||||
>
|
||||
{activeLink.text}
|
||||
</Link>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user