Compare commits

..

5 Commits

Author SHA1 Message Date
yyh
c76599327c Merge branch 'main' into codex/refine-dify-ui-switch 2026-05-23 23:25:38 +08:00
yyh
a78b664841 style: focus visible 2026-05-23 23:23:22 +08:00
72ee50c74f refactor: add missing @override decorators to method overrides (#36501)
Co-authored-by: EvanYao826 <evanyao826@gmail.com>
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: WH-2099 <wh2099@pm.me>
2026-05-23 09:56:36 +00:00
8d99326fb3 feat(plugin): cache plugin model providers by tenant (#36449)
Co-authored-by: WH-2099 <wh2099@pm.me>
2026-05-23 09:12:09 +00:00
yyh
6bf8758807 refactor: refine dify-ui switch contract 2026-05-23 13:30:11 +08:00
198 changed files with 1569 additions and 11583 deletions

View File

@ -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

View File

@ -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__)

View File

@ -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,

View File

@ -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)}")

View File

@ -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:

View File

@ -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

View File

@ -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():

View File

@ -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):

View File

@ -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)

View File

@ -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,
)

View File

@ -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]:

View File

@ -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__)

View File

@ -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

View File

@ -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__)

View File

@ -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])

View File

@ -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__)

View File

@ -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__)

View File

@ -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__)

View File

@ -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__)

View File

@ -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

View File

@ -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__)

View File

@ -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")

View File

@ -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"

View File

@ -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

View File

@ -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"

View File

@ -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."""

View File

@ -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

View File

@ -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")

View File

@ -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")

View File

@ -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

View File

@ -207,6 +207,16 @@ describe('Select wrappers', () => {
expect(screen.getByRole('combobox', { name: 'city select' }).element().className).toContain('data-popup-open:bg-state-base-hover-alt')
})
it('should include keyboard focus ring classes', async () => {
const screen = await renderOpenSelect()
await expect.element(screen.getByRole('combobox', { name: 'city select' })).toHaveClass(
'focus-visible:ring-1',
'focus-visible:ring-components-input-border-active',
'focus-visible:ring-inset',
)
})
})
describe('SelectContent', () => {

View File

@ -24,6 +24,7 @@ const selectTriggerVariants = cva(
[
'group flex w-full items-center border-0 bg-components-input-bg-normal text-left text-components-input-text-filled outline-hidden',
'hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt data-popup-open:bg-state-base-hover-alt',
'focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:ring-inset',
'data-placeholder:text-components-input-text-placeholder',
'data-readonly:cursor-default data-readonly:bg-transparent data-readonly:hover:bg-transparent',
'data-disabled:cursor-not-allowed data-disabled:bg-components-input-bg-disabled data-disabled:text-components-input-text-filled-disabled data-disabled:hover:bg-components-input-bg-disabled',

View File

@ -49,6 +49,19 @@ describe('Switch', () => {
await expect.element(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true')
})
it('should work in uncontrolled mode with defaultChecked prop', async () => {
const onCheckedChange = vi.fn()
const screen = await render(<Switch defaultChecked={false} onCheckedChange={onCheckedChange} />)
const switchElement = screen.getByRole('switch')
await expect.element(switchElement).toHaveAttribute('aria-checked', 'false')
asHTMLElement(switchElement.element()).click()
expect(onCheckedChange).toHaveBeenCalledWith(true)
await expect.element(switchElement).toHaveAttribute('aria-checked', 'true')
})
it('should not call onCheckedChange when disabled', async () => {
const onCheckedChange = vi.fn()
const screen = await render(<Switch checked={false} disabled onCheckedChange={onCheckedChange} />)
@ -142,6 +155,24 @@ describe('Switch', () => {
expect(screen.container.querySelector('span[aria-hidden="true"] i')).toBeInTheDocument()
})
it('should use checked data attributes to position spinner', async () => {
const screen = await render(<Switch checked={false} loading size="md" />)
const spinner = screen.container.querySelector('span[aria-hidden="true"]')
expect(spinner).toHaveClass(
'left-[calc(50%+6px)]',
'group-data-checked:left-[calc(50%-6px)]',
)
await screen.rerender(<Switch checked={true} loading size="md" />)
await expect.element(screen.getByRole('switch')).toHaveAttribute('data-checked', '')
expect(screen.container.querySelector('span[aria-hidden="true"]')).toHaveClass(
'left-[calc(50%+6px)]',
'group-data-checked:left-[calc(50%-6px)]',
)
})
it('should not show spinner for xs and sm sizes', async () => {
const screen = await render(<Switch checked={false} loading size="xs" />)
expect(screen.container.querySelector('span[aria-hidden="true"] i')).not.toBeInTheDocument()

View File

@ -2,6 +2,11 @@ import type { Meta, StoryObj } from '@storybook/react-vite'
import type { ComponentProps } from 'react'
import { useState, useTransition } from 'react'
import { Switch, SwitchSkeleton } from '.'
import {
FieldDescription,
FieldLabel,
FieldRoot,
} from '../field'
const meta = {
title: 'Base/Form/Switch',
@ -10,7 +15,7 @@ const meta = {
layout: 'centered',
docs: {
description: {
component: 'Toggle switch built on Base UI with CVA variants, Figma-aligned design tokens, loading spinner, and skeleton placeholder. Import `Switch` and `SwitchSkeleton` from `@langgenius/dify-ui/switch`.',
component: 'Toggle switch primitive with controlled and uncontrolled state support, loading state, and skeleton placeholder.',
},
},
},
@ -42,20 +47,27 @@ const meta = {
export default meta
type Story = StoryObj<typeof meta>
const SwitchDemo = (args: Partial<ComponentProps<typeof Switch>>) => {
type SwitchDemoProps = Partial<Omit<ComponentProps<typeof Switch>, 'checked' | 'defaultChecked' | 'onCheckedChange'>> & {
checked?: boolean
}
const SwitchDemo = (args: SwitchDemoProps) => {
const [enabled, setEnabled] = useState(args.checked ?? false)
return (
<div className="flex items-center justify-center gap-3">
<Switch
{...args}
checked={enabled}
onCheckedChange={setEnabled}
/>
<span className="text-sm text-gray-700">
{enabled ? 'On' : 'Off'}
</span>
</div>
<FieldRoot name="autoRetry" className="w-72">
<FieldLabel className="flex items-center justify-between gap-3">
<span>Enable auto retry</span>
<Switch
{...args}
checked={enabled}
onCheckedChange={setEnabled}
/>
</FieldLabel>
<FieldDescription>
{enabled ? 'Failures will retry automatically.' : 'Failures require manual retry.'}
</FieldDescription>
</FieldRoot>
)
}
@ -116,24 +128,24 @@ const AllStatesDemo = () => {
<td className="py-3 font-medium text-gray-900">{size}</td>
<td className="py-3">
<div className="flex gap-2">
<Switch size={size} checked={false} onCheckedChange={() => {}} />
<Switch size={size} checked={true} onCheckedChange={() => {}} />
<Switch size={size} checked={false} onCheckedChange={() => {}} aria-label={`${size} unchecked switch`} />
<Switch size={size} checked={true} onCheckedChange={() => {}} aria-label={`${size} checked switch`} />
</div>
</td>
<td className="py-3">
<div className="flex gap-2">
<Switch size={size} checked={false} disabled />
<Switch size={size} checked={true} disabled />
<Switch size={size} checked={false} disabled aria-label={`${size} disabled unchecked switch`} />
<Switch size={size} checked={true} disabled aria-label={`${size} disabled checked switch`} />
</div>
</td>
<td className="py-3">
<div className="flex gap-2">
<Switch size={size} checked={false} loading />
<Switch size={size} checked={true} loading />
<Switch size={size} checked={false} loading aria-label={`${size} loading unchecked switch`} />
<Switch size={size} checked={true} loading aria-label={`${size} loading checked switch`} />
</div>
</td>
<td className="py-3">
<SwitchSkeleton size={size} />
<SwitchSkeleton size={size} aria-hidden="true" />
</td>
</tr>
))}
@ -148,7 +160,7 @@ export const AllStates: Story = {
parameters: {
docs: {
description: {
story: 'Complete variant matrix: all sizes × all states, matching Figma design spec (node 2144:1210).',
story: 'Variant matrix for switch sizes and states.',
},
},
},
@ -164,22 +176,30 @@ const SizeComparisonDemo = () => {
return (
<div className="flex flex-col items-center space-y-4">
<div className="flex items-center gap-3">
<Switch size="xs" checked={states.xs} onCheckedChange={v => setStates({ ...states, xs: v })} />
<span className="text-sm text-gray-700">Extra Small (xs) 14×10</span>
</div>
<div className="flex items-center gap-3">
<Switch size="sm" checked={states.sm} onCheckedChange={v => setStates({ ...states, sm: v })} />
<span className="text-sm text-gray-700">Small (sm) 20×12</span>
</div>
<div className="flex items-center gap-3">
<Switch size="md" checked={states.md} onCheckedChange={v => setStates({ ...states, md: v })} />
<span className="text-sm text-gray-700">Regular (md) 28×16</span>
</div>
<div className="flex items-center gap-3">
<Switch size="lg" checked={states.lg} onCheckedChange={v => setStates({ ...states, lg: v })} />
<span className="text-sm text-gray-700">Large (lg) 36×20</span>
</div>
<FieldRoot name="extraSmallSwitch">
<FieldLabel className="flex items-center gap-3">
<Switch size="xs" checked={states.xs} onCheckedChange={v => setStates({ ...states, xs: v })} />
Extra Small (xs) - 14x10
</FieldLabel>
</FieldRoot>
<FieldRoot name="smallSwitch">
<FieldLabel className="flex items-center gap-3">
<Switch size="sm" checked={states.sm} onCheckedChange={v => setStates({ ...states, sm: v })} />
Small (sm) - 20x12
</FieldLabel>
</FieldRoot>
<FieldRoot name="regularSwitch">
<FieldLabel className="flex items-center gap-3">
<Switch size="md" checked={states.md} onCheckedChange={v => setStates({ ...states, md: v })} />
Regular (md) - 28x16
</FieldLabel>
</FieldRoot>
<FieldRoot name="largeSwitch">
<FieldLabel className="flex items-center gap-3">
<Switch size="lg" checked={states.lg} onCheckedChange={v => setStates({ ...states, lg: v })} />
Large (lg) - 36x20
</FieldLabel>
</FieldRoot>
</div>
)
}
@ -200,30 +220,42 @@ const LoadingDemo = () => {
{loading ? 'Stop Loading' : 'Start Loading'}
</button>
<div className="space-y-3">
<div className="flex items-center gap-3">
<Switch size="lg" checked={false} loading={loading} />
<span className="text-sm text-gray-700">Large unchecked</span>
</div>
<div className="flex items-center gap-3">
<Switch size="lg" checked={true} loading={loading} />
<span className="text-sm text-gray-700">Large checked</span>
</div>
<div className="flex items-center gap-3">
<Switch size="md" checked={false} loading={loading} />
<span className="text-sm text-gray-700">Regular unchecked</span>
</div>
<div className="flex items-center gap-3">
<Switch size="md" checked={true} loading={loading} />
<span className="text-sm text-gray-700">Regular checked</span>
</div>
<div className="flex items-center gap-3">
<Switch size="sm" checked={false} loading={loading} />
<span className="text-sm text-gray-700">Small (no spinner)</span>
</div>
<div className="flex items-center gap-3">
<Switch size="xs" checked={false} loading={loading} />
<span className="text-sm text-gray-700">Extra Small (no spinner)</span>
</div>
<FieldRoot name="largeUncheckedLoading">
<FieldLabel className="flex items-center gap-3">
<Switch size="lg" checked={false} loading={loading} />
Large unchecked
</FieldLabel>
</FieldRoot>
<FieldRoot name="largeCheckedLoading">
<FieldLabel className="flex items-center gap-3">
<Switch size="lg" checked={true} loading={loading} />
Large checked
</FieldLabel>
</FieldRoot>
<FieldRoot name="regularUncheckedLoading">
<FieldLabel className="flex items-center gap-3">
<Switch size="md" checked={false} loading={loading} />
Regular unchecked
</FieldLabel>
</FieldRoot>
<FieldRoot name="regularCheckedLoading">
<FieldLabel className="flex items-center gap-3">
<Switch size="md" checked={true} loading={loading} />
Regular checked
</FieldLabel>
</FieldRoot>
<FieldRoot name="smallLoading">
<FieldLabel className="flex items-center gap-3">
<Switch size="sm" checked={false} loading={loading} />
Small
</FieldLabel>
</FieldRoot>
<FieldRoot name="extraSmallLoading">
<FieldLabel className="flex items-center gap-3">
<Switch size="xs" checked={false} loading={loading} />
Extra Small
</FieldLabel>
</FieldRoot>
</div>
</div>
)
@ -234,7 +266,7 @@ export const Loading: Story = {
parameters: {
docs: {
description: {
story: 'Loading state disables interaction and shows a spinning icon (i-ri-loader-2-line) for md/lg sizes. Spinner position mirrors the knob: appears on the opposite side of the checked state.',
story: 'Loading state disables interaction and shows a spinner for md and lg sizes.',
},
},
},
@ -242,61 +274,76 @@ export const Loading: Story = {
const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
const MutationLoadingDemo = () => {
function useMockAutoRetrySettingQuery() {
const [enabled, setEnabled] = useState(false)
return {
data: {
enabled,
},
setData: setEnabled,
}
}
function useMockUpdateAutoRetrySettingMutation({
onSuccess,
}: {
onSuccess: (enabled: boolean) => void
}) {
const [requestCount, setRequestCount] = useState(0)
const [isPending, startTransition] = useTransition()
const handleChange = (nextValue: boolean) => {
const mutate = (nextValue: boolean) => {
if (isPending)
return
startTransition(async () => {
setRequestCount(current => current + 1)
await wait(1200)
setEnabled(nextValue)
onSuccess(nextValue)
})
}
return {
requestCount,
isPending,
mutate,
}
}
const MutationLoadingDemo = () => {
const autoRetrySetting = useMockAutoRetrySettingQuery()
const updateAutoRetrySetting = useMockUpdateAutoRetrySettingMutation({
onSuccess: autoRetrySetting.setData,
})
const statusText = updateAutoRetrySetting.isPending
? 'Saving changes...'
: autoRetrySetting.data.enabled
? 'Auto retry is enabled.'
: 'Auto retry is disabled.'
return (
<div className="w-[340px] space-y-4 rounded-2xl border border-components-panel-border bg-components-panel-bg p-4 shadow-sm">
<div className="space-y-1">
<p className="text-sm font-medium text-text-primary">Mutation Loading Guard</p>
<p className="text-xs text-text-tertiary">
Click once to start a simulated mutate call. While the request is pending, the switch enters
{' '}
<code className="rounded-sm bg-state-base-hover px-1 py-0.5 text-[11px]">loading</code>
{' '}
and rejects duplicate clicks.
</p>
</div>
<div className="grid w-90 gap-3 rounded-lg border border-components-panel-border bg-components-panel-bg p-4 shadow-sm">
<FieldRoot name="autoRetry">
<FieldLabel className="flex items-center justify-between gap-4">
<span className="system-sm-medium text-text-secondary">Enable auto retry</span>
<Switch
size="lg"
checked={autoRetrySetting.data.enabled}
loading={updateAutoRetrySetting.isPending}
onCheckedChange={updateAutoRetrySetting.mutate}
/>
</FieldLabel>
<FieldDescription>Retry failed workflow runs without manual intervention.</FieldDescription>
</FieldRoot>
<div className="flex items-center justify-between rounded-xl border border-components-panel-border-subtle bg-background-default-dodge px-3 py-2 shadow-sm">
<div className="space-y-1">
<p className="text-sm font-medium text-text-primary">Enable Auto Retry</p>
<p className="text-xs text-text-tertiary">
{isPending ? 'Saving…' : enabled ? 'Saved as on' : 'Saved as off'}
</p>
</div>
<Switch
size="lg"
checked={enabled}
loading={isPending}
onCheckedChange={handleChange}
aria-label="Enable Auto Retry"
/>
</div>
<div className="grid grid-cols-2 gap-2 text-xs text-text-tertiary">
<div className="rounded-lg bg-state-base-hover px-3 py-2">
<div className="font-medium text-text-secondary">Committed Value</div>
<div>{enabled ? 'On' : 'Off'}</div>
</div>
<div className="rounded-lg bg-state-base-hover px-3 py-2">
<div className="font-medium text-text-secondary">Mutate Count</div>
<div>{requestCount}</div>
</div>
</div>
<span className="text-xs text-text-tertiary" aria-live="polite">
{statusText}
{' '}
Save attempts:
{' '}
{updateAutoRetrySetting.requestCount}
</span>
</div>
)
}
@ -306,7 +353,7 @@ export const MutationLoadingGuard: Story = {
parameters: {
docs: {
description: {
story: 'Simulates a controlled switch backed by an async mutate call. The component keeps its previous committed value, sets `loading` during the request, and blocks duplicate clicks until the mutation resolves.',
story: 'Controlled switch that enters loading while the change is saved.',
},
},
},
@ -315,19 +362,19 @@ export const MutationLoadingGuard: Story = {
const SkeletonDemo = () => (
<div className="flex flex-col items-center space-y-4">
<div className="flex items-center gap-3">
<SwitchSkeleton size="xs" />
<SwitchSkeleton size="xs" aria-hidden="true" />
<span className="text-sm text-gray-700">Extra Small skeleton</span>
</div>
<div className="flex items-center gap-3">
<SwitchSkeleton size="sm" />
<SwitchSkeleton size="sm" aria-hidden="true" />
<span className="text-sm text-gray-700">Small skeleton</span>
</div>
<div className="flex items-center gap-3">
<SwitchSkeleton size="md" />
<SwitchSkeleton size="md" aria-hidden="true" />
<span className="text-sm text-gray-700">Regular skeleton</span>
</div>
<div className="flex items-center gap-3">
<SwitchSkeleton size="lg" />
<SwitchSkeleton size="lg" aria-hidden="true" />
<span className="text-sm text-gray-700">Large skeleton</span>
</div>
</div>
@ -338,7 +385,7 @@ export const Skeleton: Story = {
parameters: {
docs: {
description: {
story: '`SwitchSkeleton` renders a non-interactive placeholder with `bg-text-quaternary opacity-20`. Exported from `@langgenius/dify-ui/switch` alongside `Switch`.',
story: 'Non-interactive placeholders for switch loading layouts.',
},
},
},

View File

@ -45,26 +45,34 @@ const switchThumbVariants = cva(
export type SwitchSize = NonNullable<VariantProps<typeof switchRootVariants>['size']>
const spinnerSizeConfig: Partial<Record<SwitchSize, {
icon: string
uncheckedPosition: string
checkedPosition: string
}>> = {
md: {
icon: 'size-2',
uncheckedPosition: 'left-[calc(50%+6px)]',
checkedPosition: 'left-[calc(50%-6px)]',
},
lg: {
icon: 'size-2.5',
uncheckedPosition: 'left-[calc(50%+8px)]',
checkedPosition: 'left-[calc(50%-8px)]',
const switchSpinnerVariants = cva(
'absolute top-1/2 -translate-x-1/2 -translate-y-1/2',
{
variants: {
size: {
md: 'size-2 left-[calc(50%+6px)] group-data-checked:left-[calc(50%-6px)]',
lg: 'size-2.5 left-[calc(50%+8px)] group-data-checked:left-[calc(50%-8px)]',
},
},
},
)
type ControlledSwitchProps = {
checked: boolean
defaultChecked?: never
}
type UncontrolledSwitchProps = {
checked?: never
defaultChecked?: boolean
}
type SwitchControlProps = ControlledSwitchProps | UncontrolledSwitchProps
export type SwitchProps
= Omit<BaseSwitchNS.Root.Props, 'className' | 'size' | 'onCheckedChange'>
= Omit<BaseSwitchNS.Root.Props, 'checked' | 'defaultChecked' | 'className' | 'size' | 'onCheckedChange'>
& VariantProps<typeof switchRootVariants>
& SwitchControlProps
& {
onCheckedChange?: (checked: boolean) => void
loading?: boolean
@ -81,7 +89,6 @@ export function Switch({
...props
}: SwitchProps) {
const isDisabled = disabled || loading
const spinner = loading && size ? spinnerSizeConfig[size] : undefined
return (
<BaseSwitch.Root
@ -95,14 +102,10 @@ export function Switch({
<BaseSwitch.Thumb
className={switchThumbVariants({ size })}
/>
{spinner
{loading && (size === 'md' || size === 'lg')
? (
<span
className={cn(
'absolute top-1/2 -translate-x-1/2 -translate-y-1/2',
spinner.icon,
checked ? spinner.checkedPosition : spinner.uncheckedPosition,
)}
className={switchSpinnerVariants({ size })}
aria-hidden="true"
>
<i className="i-ri-loader-2-line size-full animate-spin text-text-tertiary motion-reduce:animate-none" />
@ -131,11 +134,8 @@ const switchSkeletonVariants = cva(
)
export type SwitchSkeletonProps
= Omit<HTMLAttributes<HTMLDivElement>, 'className'>
= HTMLAttributes<HTMLDivElement>
& VariantProps<typeof switchSkeletonVariants>
& {
className?: string
}
export function SwitchSkeleton({
size = 'md',

View File

@ -41,6 +41,7 @@ describe('@langgenius/dify-ui/toast', () => {
await expect.element(screen.getByRole('region', { name: 'Notifications' })).toHaveAttribute('aria-live', 'polite')
await expect.element(screen.getByRole('region', { name: 'Notifications' })).toHaveClass('z-60')
expect(screen.getByRole('region', { name: 'Notifications' }).element().firstElementChild).toHaveClass('top-4')
expect(screen.getByText('Saved').element().closest('[class*="transition-opacity"]')).toHaveClass('motion-reduce:transition-none')
expect(screen.getByRole('dialog').element()).not.toHaveClass('outline-hidden')
expect(document.body.querySelector('[aria-hidden="true"].i-ri-checkbox-circle-fill')).toBeInTheDocument()
expect(document.body.querySelector('button[aria-label="Close notification"][aria-hidden="true"]')).toBeInTheDocument()

View File

@ -171,7 +171,7 @@ function ToastCard({
aria-hidden="true"
className={cn('absolute -inset-px bg-linear-to-r opacity-40', getToneGradientClasses(toastType))}
/>
<BaseToast.Content className="relative flex items-start gap-1 overflow-hidden p-3 transition-opacity duration-200 data-behind:opacity-0 data-expanded:opacity-100">
<BaseToast.Content className="relative flex items-start gap-1 overflow-hidden p-3 transition-opacity duration-200 data-behind:opacity-0 data-expanded:opacity-100 motion-reduce:transition-none">
<div className="flex shrink-0 items-center justify-center p-0.5">
<ToastIcon type={toastType} />
</div>

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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>"
},

View File

@ -1,7 +1,7 @@
{
"prefix": "custom-vender",
"name": "Dify Custom Vender",
"total": 281,
"total": 277,
"version": "0.0.0-private",
"author": {
"name": "LangGenius, Inc.",

View File

@ -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()
})
})

View File

@ -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}/`)

View File

@ -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

View File

@ -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')
})
})

View File

@ -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

View File

@ -1,7 +0,0 @@
import SnippetList from '@/app/components/snippet-list'
const SnippetsPage = () => {
return <SnippetList />
}
export default SnippetsPage

View File

@ -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', () => {

View File

@ -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>

View File

@ -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)
})
})
})

View File

@ -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()}

View File

@ -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')
})
})
})

View File

@ -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()
})
})
})

View File

@ -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)

View File

@ -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)

View File

@ -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])

View File

@ -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

View File

@ -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 {

View File

@ -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 {

View File

@ -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

View File

@ -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>

View File

@ -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()
})
})

View File

@ -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()
}
})
})

View File

@ -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' })

View File

@ -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>
)
}

View File

@ -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

View File

@ -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>
</>

View File

@ -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)
})
})

View File

@ -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])
}

View File

@ -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 || ''}

View File

@ -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} />
)}

View File

@ -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,

View File

@ -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' })}

View File

@ -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')

View File

@ -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')
})

View File

@ -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>
)
}

View File

@ -1,270 +0,0 @@
import { fireEvent, screen } from '@testing-library/react'
import * as React from 'react'
import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
import { renderWithNuqs } from '@/test/nuqs-testing'
import SnippetList from '..'
const mockUseInfiniteSnippetList = vi.hoisted(() => vi.fn())
const mockSetKeywords = vi.hoisted(() => vi.fn())
const mockSetTagIDs = vi.hoisted(() => vi.fn())
const mockSetCreatorID = vi.hoisted(() => vi.fn())
const mockQueryState = vi.hoisted(() => ({
tagIDs: [] as string[],
keywords: '',
creatorID: '',
}))
vi.mock('@/service/use-snippets', () => ({
useCreateSnippetMutation: () => ({
mutate: vi.fn(),
isPending: false,
}),
useDeleteSnippetMutation: () => ({
mutate: vi.fn(),
isPending: false,
}),
useExportSnippetMutation: () => ({
mutateAsync: vi.fn(),
}),
useInfiniteSnippetList: (params: unknown, options: unknown) => mockUseInfiniteSnippetList(params, options),
useUpdateSnippetMutation: () => ({
mutate: vi.fn(),
isPending: false,
}),
}))
vi.mock('../hooks/use-snippets-query-state', () => ({
useSnippetsQueryState: () => ({
query: mockQueryState,
setKeywords: mockSetKeywords,
setTagIDs: mockSetTagIDs,
setCreatorID: mockSetCreatorID,
}),
}))
vi.mock('@/service/client', () => ({
consoleClient: {
systemFeatures: vi.fn(),
},
consoleQuery: {
tags: {
list: {
queryOptions: (options: unknown) => options,
},
},
systemFeatures: {
queryKey: () => ['console', 'systemFeatures'],
},
},
}))
const mockIsCurrentWorkspaceEditor = vi.fn(() => true)
const mockIsCurrentWorkspaceDatasetOperator = vi.fn(() => false)
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor(),
isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator(),
isLoadingCurrentWorkspace: false,
userProfile: { id: 'creator-1' },
}),
}))
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' },
],
},
}),
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push: vi.fn() }),
useSearchParams: () => new URLSearchParams(''),
}))
vi.mock('@/next/dynamic', () => ({
default: () => {
return function MockDynamicComponent() {
return React.createElement('div', { 'data-testid': 'tag-management-modal' })
}
},
}))
vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({
default: () => null,
}))
vi.mock('@/features/tag-management/components/tag-selector', () => ({
TagSelector: () => <div data-testid="snippet-card-tags" />,
}))
vi.mock('@/hooks/use-document-title', () => ({
default: vi.fn(),
}))
const mockObserve = vi.fn()
const mockDisconnect = vi.fn()
beforeAll(() => {
globalThis.IntersectionObserver = class MockIntersectionObserver {
constructor(_callback: IntersectionObserverCallback) {}
observe = mockObserve
disconnect = mockDisconnect
unobserve = vi.fn()
root = null
rootMargin = ''
thresholds = []
takeRecords = () => []
} as unknown as typeof IntersectionObserver
})
const mockRefetch = vi.fn()
const mockFetchNextPage = vi.fn()
const mockSnippetListState = {
data: {
pages: [{
data: [
{
id: 'snippet-1',
name: 'Sales Snippet',
description: 'Builds a sales follow-up.',
type: 'node',
is_published: true,
use_count: 12,
tags: [],
created_at: 1704067200,
created_by: 'creator-1',
updated_at: 1704153600,
updated_by: 'creator-2',
},
],
page: 1,
limit: 30,
total: 1,
has_more: false,
}],
},
isLoading: false,
isFetching: false,
isFetchingNextPage: false,
hasNextPage: false,
error: null as Error | null,
}
const renderList = () => {
const { wrapper: SystemFeaturesWrapper } = createSystemFeaturesWrapper({
systemFeatures: { branding: { enabled: false } },
})
return renderWithNuqs(
<SystemFeaturesWrapper>
<SnippetList />
</SystemFeaturesWrapper>,
)
}
describe('SnippetList', () => {
beforeEach(() => {
vi.clearAllMocks()
mockQueryState.tagIDs = []
mockQueryState.keywords = ''
mockQueryState.creatorID = ''
mockIsCurrentWorkspaceEditor.mockReturnValue(true)
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(false)
mockUseInfiniteSnippetList.mockReturnValue({
...mockSnippetListState,
refetch: mockRefetch,
fetchNextPage: mockFetchNextPage,
})
})
it('renders the dedicated snippets list layout', () => {
renderList()
expect(screen.getByText('app.studio.filters.allCreators')).toBeInTheDocument()
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
expect(screen.getByPlaceholderText('workflow.tabs.searchSnippets')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'snippet.create' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /Sales Snippet/ })).toHaveAttribute('href', '/snippets/snippet-1/orchestrate')
expect(screen.getByTestId('tag-management-modal')).toBeInTheDocument()
})
it('passes creator, tag, and search filters to the snippets list query', () => {
mockQueryState.tagIDs = ['tag-1', 'tag-2']
mockQueryState.keywords = 'sales'
mockQueryState.creatorID = 'creator-1'
renderList()
expect(mockUseInfiniteSnippetList).toHaveBeenCalledWith({
page: 1,
limit: 30,
keyword: 'sales',
tag_ids: ['tag-1', 'tag-2'],
creator_id: 'creator-1',
}, {
enabled: true,
})
})
it('updates the search query state from the search input', () => {
renderList()
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'summary' } })
expect(mockSetKeywords).toHaveBeenCalledWith('summary')
})
it('clears the search query state', () => {
mockQueryState.keywords = 'summary'
renderList()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' }))
expect(mockSetKeywords).toHaveBeenCalledWith('')
})
it('updates the creator query state as a single creator filter', () => {
renderList()
fireEvent.click(screen.getByRole('button', { name: 'app.studio.filters.allCreators' }))
fireEvent.click(screen.getByRole('button', { name: /Bob/ }))
expect(mockSetCreatorID).toHaveBeenCalledWith('creator-2')
})
it('hides the create button for non-editors', () => {
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
renderList()
expect(screen.queryByRole('button', { name: 'snippet.create' })).not.toBeInTheDocument()
})
it('shows an empty state when no snippets are returned', () => {
mockUseInfiniteSnippetList.mockReturnValue({
...mockSnippetListState,
data: {
pages: [{
data: [],
page: 1,
limit: 30,
total: 0,
has_more: false,
}],
},
refetch: mockRefetch,
fetchNextPage: mockFetchNextPage,
})
renderList()
expect(screen.getByText('workflow.tabs.noSnippetsFound')).toBeInTheDocument()
})
})

View File

@ -1,196 +0,0 @@
import type { SnippetListItem } from '@/types/snippet'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import SnippetCard from '../snippet-card'
const {
mockDeleteMutate,
mockDownloadBlob,
mockExportMutateAsync,
mockOnRefresh,
mockToastError,
mockToastSuccess,
mockUpdateMutate,
} = vi.hoisted(() => ({
mockDeleteMutate: vi.fn(),
mockDownloadBlob: vi.fn(),
mockExportMutateAsync: vi.fn(),
mockOnRefresh: vi.fn(),
mockToastError: vi.fn(),
mockToastSuccess: vi.fn(),
mockUpdateMutate: vi.fn(),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceEditor: true,
}),
}))
vi.mock('@/service/use-common', () => ({
useMembers: () => ({
data: {
accounts: [
{ id: 'creator-id', name: 'Creator', email: 'creator@example.com', avatar: '', avatar_url: null, role: 'editor', last_login_at: '', created_at: '', status: 'active' },
{ id: 'updater-id', name: 'Updater', email: 'updater@example.com', avatar: '', avatar_url: null, role: 'editor', last_login_at: '', created_at: '', status: 'active' },
],
},
}),
}))
vi.mock('@/service/use-snippets', () => ({
useDeleteSnippetMutation: () => ({
mutate: mockDeleteMutate,
isPending: false,
}),
useExportSnippetMutation: () => ({
mutateAsync: mockExportMutateAsync,
}),
useUpdateSnippetMutation: () => ({
mutate: mockUpdateMutate,
isPending: false,
}),
}))
vi.mock('@/utils/time', () => ({
formatTime: () => 'formatted-time',
}))
vi.mock('@/utils/download', () => ({
downloadBlob: mockDownloadBlob,
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
success: mockToastSuccess,
error: mockToastError,
},
}))
vi.mock('@/features/tag-management/components/tag-selector', () => ({
TagSelector: ({ value }: { value: Array<{ name: string }> }) => (
<div data-testid="snippet-tags">{value.map(tag => tag.name).join(', ')}</div>
),
}))
const createSnippet = (overrides: Partial<SnippetListItem> = {}): SnippetListItem => ({
id: 'snippet-1',
name: 'Tone Rewriter',
description: 'Rewrites rough drafts.',
type: 'node',
is_published: true,
use_count: 19,
tags: [],
created_at: 1_704_067_200,
created_by: 'creator-id',
updated_at: 1_704_153_600,
updated_by: 'updater-id',
...overrides,
})
describe('SnippetCard', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render updater name and updated time from member data', () => {
render(<SnippetCard snippet={createSnippet()} />)
expect(screen.getByText('Tone Rewriter')).toBeInTheDocument()
expect(screen.getByText('snippet.updatedBy:{"name":"Updater","time":"formatted-time"}')).toBeInTheDocument()
expect(screen.queryByText('snippet.usageCount:{"count":19}')).not.toBeInTheDocument()
expect(screen.queryByText('Creator')).not.toBeInTheDocument()
expect(screen.queryByRole('img')).not.toBeInTheDocument()
})
it('should fall back to creator name when updater is unavailable', () => {
render(<SnippetCard snippet={createSnippet({ updated_by: 'missing-user' })} />)
expect(screen.getByText('snippet.updatedBy:{"name":"Creator","time":"formatted-time"}')).toBeInTheDocument()
})
it('should not render draft status for unpublished snippets', () => {
render(<SnippetCard snippet={createSnippet({ is_published: false })} />)
expect(screen.queryByText('snippet.draft')).not.toBeInTheDocument()
})
it('should render supported operations only', async () => {
render(<SnippetCard snippet={createSnippet()} />)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
expect(await screen.findByRole('menuitem', { name: 'snippet.menu.editInfo' })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: 'snippet.menu.exportSnippet' })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: 'snippet.menu.deleteSnippet' })).toBeInTheDocument()
expect(screen.queryByRole('menuitem', { name: /duplicate/i })).not.toBeInTheDocument()
})
it('should export a snippet from the operations menu', async () => {
mockExportMutateAsync.mockResolvedValue('snippet-yaml')
render(<SnippetCard snippet={createSnippet()} />)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
fireEvent.click(await screen.findByRole('menuitem', { name: 'snippet.menu.exportSnippet' }))
await waitFor(() => {
expect(mockExportMutateAsync).toHaveBeenCalledWith({ snippetId: 'snippet-1' })
expect(mockDownloadBlob).toHaveBeenCalledWith(expect.objectContaining({
fileName: 'Tone Rewriter.yml',
}))
})
})
it('should update snippet info from the operations menu', async () => {
mockUpdateMutate.mockImplementation((_payload, options?: { onSuccess?: () => void }) => {
options?.onSuccess?.()
})
render(<SnippetCard snippet={createSnippet()} onRefresh={mockOnRefresh} />)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
fireEvent.click(await screen.findByRole('menuitem', { name: 'snippet.menu.editInfo' }))
fireEvent.change(screen.getByPlaceholderText('workflow.snippet.namePlaceholder'), {
target: { value: 'Updated Snippet' },
})
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
await waitFor(() => {
expect(mockUpdateMutate).toHaveBeenCalledWith({
params: { snippetId: 'snippet-1' },
body: {
name: 'Updated Snippet',
description: 'Rewrites rough drafts.',
},
}, expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}))
expect(mockOnRefresh).toHaveBeenCalled()
})
})
it('should delete a snippet from the operations menu', async () => {
mockDeleteMutate.mockImplementation((_payload, options?: { onSuccess?: () => void }) => {
options?.onSuccess?.()
})
render(<SnippetCard snippet={createSnippet()} onRefresh={mockOnRefresh} />)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
fireEvent.click(await screen.findByRole('menuitem', { name: 'snippet.menu.deleteSnippet' }))
fireEvent.click(screen.getByRole('button', { name: 'snippet.menu.deleteSnippet' }))
await waitFor(() => {
expect(mockDeleteMutate).toHaveBeenCalledWith({
params: { snippetId: 'snippet-1' },
}, expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}))
expect(mockOnRefresh).toHaveBeenCalled()
})
})
})
})

View File

@ -1,70 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import SnippetCreateButton from '../snippet-create-button'
const { mockPush, mockCreateMutate, mockToastSuccess, mockToastError } = vi.hoisted(() => ({
mockPush: vi.fn(),
mockCreateMutate: vi.fn(),
mockToastSuccess: vi.fn(),
mockToastError: vi.fn(),
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
success: mockToastSuccess,
error: mockToastError,
},
}))
vi.mock('@/service/use-snippets', () => ({
useCreateSnippetMutation: () => ({
mutate: mockCreateMutate,
isPending: false,
}),
}))
describe('SnippetCreateButton', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should open the create dialog and create a snippet from the modal', async () => {
mockCreateMutate.mockImplementation((_payload, options?: { onSuccess?: (snippet: { id: string }) => void }) => {
options?.onSuccess?.({ id: 'snippet-123' })
})
render(<SnippetCreateButton />)
fireEvent.click(screen.getByRole('button', { name: 'snippet.create' }))
expect(screen.getByText('workflow.snippet.createDialogTitle')).toBeInTheDocument()
fireEvent.change(screen.getByPlaceholderText('workflow.snippet.namePlaceholder'), {
target: { value: 'My Snippet' },
})
fireEvent.change(screen.getByPlaceholderText('workflow.snippet.descriptionPlaceholder'), {
target: { value: 'Useful snippet description' },
})
fireEvent.click(screen.getByRole('button', { name: /workflow\.snippet\.confirm/i }))
expect(mockCreateMutate).toHaveBeenCalledWith({
body: {
name: 'My Snippet',
description: 'Useful snippet description',
},
}, expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}))
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith('/snippets/snippet-123/orchestrate')
})
expect(mockToastSuccess).toHaveBeenCalledWith('workflow.snippet.createSuccess')
})
})

View File

@ -1,245 +0,0 @@
'use client'
import type { SnippetListItem } from '@/types/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 { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog'
import { useAppContext } from '@/context/app-context'
import { TagSelector } from '@/features/tag-management/components/tag-selector'
import Link from '@/next/link'
import { useMembers } from '@/service/use-common'
import { useDeleteSnippetMutation, useExportSnippetMutation, useUpdateSnippetMutation } from '@/service/use-snippets'
import { downloadBlob } from '@/utils/download'
import { formatTime } from '@/utils/time'
type Props = {
snippet: SnippetListItem
onOpenTagManagement?: () => void
onRefresh?: () => void
onTagsChange?: () => void
}
const SnippetCard = ({
snippet,
onOpenTagManagement = () => {},
onRefresh,
onTagsChange,
}: Props) => {
const { t } = useTranslation('snippet')
const { t: tCommon } = useTranslation()
const { isCurrentWorkspaceEditor } = useAppContext()
const { data: membersData } = useMembers()
const [isOperationsMenuOpen, setIsOperationsMenuOpen] = useState(false)
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const updateSnippetMutation = useUpdateSnippetMutation()
const exportSnippetMutation = useExportSnippetMutation()
const deleteSnippetMutation = useDeleteSnippetMutation()
const memberNameById = useMemo(() => {
return new Map((membersData?.accounts ?? []).map(member => [member.id, member.name]))
}, [membersData?.accounts])
const updatedByName = memberNameById.get(snippet.updated_by)
|| memberNameById.get(snippet.created_by)
|| t('unknownUser')
const updatedAt = snippet.updated_at || snippet.created_at
const updatedAtText = formatTime({
date: (updatedAt > 1_000_000_000_000 ? updatedAt : updatedAt * 1000),
dateFormat: `${t('segment.dateTimeFormat', { ns: 'datasetDocuments' })}`,
})
const updatedText = t('updatedBy', {
name: updatedByName,
time: updatedAtText,
})
const initialValue = useMemo(() => ({
name: snippet.name,
description: snippet.description,
}), [snippet.description, snippet.name])
const handleOpenEditDialog = () => {
setIsOperationsMenuOpen(false)
setIsEditDialogOpen(true)
}
const handleExportSnippet = async () => {
setIsOperationsMenuOpen(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'))
}
}
const handleDeleteSnippet = () => {
deleteSnippetMutation.mutate({
params: { snippetId: snippet.id },
}, {
onSuccess: () => {
toast.success(t('deleted'))
setIsDeleteDialogOpen(false)
onRefresh?.()
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : t('deleteFailed'))
},
})
}
const handleUpdateSnippet = ({ name, description }: {
name: string
description: string
}) => {
updateSnippetMutation.mutate({
params: { snippetId: snippet.id },
body: {
name,
description: description || undefined,
},
}, {
onSuccess: () => {
toast.success(t('editDone'))
setIsEditDialogOpen(false)
onRefresh?.()
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : t('editFailed'))
},
})
}
return (
<>
<article className="group relative col-span-1 inline-flex h-55 w-full flex-col rounded-xl border border-components-card-border bg-components-card-bg p-6 shadow-sm transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg">
<Link href={`/snippets/${snippet.id}/orchestrate`} className="flex min-h-0 grow flex-col">
<div className="truncate text-lg leading-6 font-semibold text-text-secondary" title={snippet.name}>
{snippet.name}
</div>
<div className="mt-1 truncate text-sm leading-5 text-text-tertiary italic" title={updatedText}>
{updatedText}
</div>
<div className="mt-6 min-h-0 text-sm leading-5 text-text-tertiary">
<div className="line-clamp-3" title={snippet.description}>
{snippet.description}
</div>
</div>
</Link>
<div className="mt-4 mr-10">
<TagSelector
placement="bottom-start"
type="snippet"
targetId={snippet.id}
value={snippet.tags}
onOpenTagManagement={onOpenTagManagement}
onTagsChange={onTagsChange}
/>
</div>
{isCurrentWorkspaceEditor && (
<div
className={cn(
'absolute right-6 bottom-5 flex items-center transition-opacity',
isOperationsMenuOpen
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100',
)}
>
<DropdownMenu modal={false} open={isOperationsMenuOpen} onOpenChange={setIsOperationsMenuOpen}>
<DropdownMenuTrigger
aria-label={tCommon('operation.more', { ns: 'common' })}
className="flex size-8 items-center justify-center rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg p-2 text-text-tertiary shadow-xs hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:ring-inset data-popup-open:bg-state-base-hover"
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
}}
>
<span aria-hidden className="i-ri-more-fill size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="w-[216px]"
>
<DropdownMenuItem className="gap-2 px-3" onClick={handleOpenEditDialog}>
<span className="system-sm-regular text-text-secondary">{t('menu.editInfo')}</span>
</DropdownMenuItem>
<DropdownMenuItem className="gap-2 px-3" onClick={handleExportSnippet}>
<span className="system-sm-regular text-text-secondary">{t('menu.exportSnippet')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
className="gap-2 px-3"
onClick={() => {
setIsOperationsMenuOpen(false)
setIsDeleteDialogOpen(true)
}}
>
<span className="system-sm-regular">{t('menu.deleteSnippet')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</article>
{isEditDialogOpen && (
<CreateSnippetDialog
isOpen={isEditDialogOpen}
initialValue={initialValue}
title={t('editDialogTitle')}
confirmText={tCommon('operation.save', { ns: 'common' })}
isSubmitting={updateSnippetMutation.isPending}
onClose={() => setIsEditDialogOpen(false)}
onConfirm={handleUpdateSnippet}
/>
)}
<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 disabled={deleteSnippetMutation.isPending}>
{tCommon('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
loading={deleteSnippetMutation.isPending}
onClick={handleDeleteSnippet}
>
{t('menu.deleteSnippet')}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}
export default SnippetCard

View File

@ -1,66 +0,0 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { toast } from '@langgenius/dify-ui/toast'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog'
import { useRouter } from '@/next/navigation'
import {
useCreateSnippetMutation,
} from '@/service/use-snippets'
const SnippetCreateButton = () => {
const { t } = useTranslation('snippet')
const { push } = useRouter()
const createSnippetMutation = useCreateSnippetMutation()
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
const handleCreateSnippet = ({
name,
description,
}: {
name: string
description: string
}) => {
createSnippetMutation.mutate({
body: {
name,
description: description || undefined,
},
}, {
onSuccess: (snippet) => {
toast.success(t('snippet.createSuccess', { ns: 'workflow' }))
setIsCreateDialogOpen(false)
push(`/snippets/${snippet.id}/orchestrate`)
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : t('createFailed'))
},
})
}
return (
<>
<Button
variant="primary"
disabled={createSnippetMutation.isPending}
onClick={() => setIsCreateDialogOpen(true)}
>
<span aria-hidden className="mr-0.5 i-ri-add-line size-4" />
<span>{t('create')}</span>
</Button>
{isCreateDialogOpen && (
<CreateSnippetDialog
isOpen={isCreateDialogOpen}
isSubmitting={createSnippetMutation.isPending}
onClose={() => setIsCreateDialogOpen(false)}
onConfirm={handleCreateSnippet}
/>
)}
</>
)
}
export default SnippetCreateButton

View File

@ -1 +0,0 @@
export const SNIPPET_LIST_SEARCH_DEBOUNCE_MS = 500

View File

@ -1,38 +0,0 @@
import { debounce, parseAsArrayOf, parseAsString, useQueryStates } from 'nuqs'
import { useCallback, useMemo } from 'react'
import { SNIPPET_LIST_SEARCH_DEBOUNCE_MS } from '../constants'
const snippetListQueryParsers = {
tagIDs: parseAsArrayOf(parseAsString, ';')
.withDefault([])
.withOptions({ history: 'push' }),
keywords: parseAsString.withDefault('').withOptions({
limitUrlUpdates: debounce(SNIPPET_LIST_SEARCH_DEBOUNCE_MS),
}),
creatorID: parseAsString
.withDefault('')
.withOptions({ history: 'push' }),
}
export function useSnippetsQueryState() {
const [query, setQuery] = useQueryStates(snippetListQueryParsers)
const setKeywords = useCallback((keywords: string) => {
setQuery({ keywords })
}, [setQuery])
const setTagIDs = useCallback((tagIDs: string[]) => {
setQuery({ tagIDs })
}, [setQuery])
const setCreatorID = useCallback((creatorID: string) => {
setQuery({ creatorID })
}, [setQuery])
return useMemo(() => ({
query,
setKeywords,
setTagIDs,
setCreatorID,
}), [query, setCreatorID, setKeywords, setTagIDs])
}

View File

@ -1,195 +0,0 @@
'use client'
import type { SnippetListItem } from '@/types/snippet'
import { cn } from '@langgenius/dify-ui/cn'
import { Input } from '@langgenius/dify-ui/input'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useDebounce } from 'ahooks'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useAppContext } from '@/context/app-context'
import { TagFilter } from '@/features/tag-management/components/tag-filter'
import useDocumentTitle from '@/hooks/use-document-title'
import dynamic from '@/next/dynamic'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useInfiniteSnippetList } from '@/service/use-snippets'
import CreatorsFilter from '../apps/creators-filter'
import Empty from '../apps/empty'
import Footer from '../apps/footer'
import SnippetCard from './components/snippet-card'
import SnippetCreateButton from './components/snippet-create-button'
import { SNIPPET_LIST_SEARCH_DEBOUNCE_MS } from './constants'
import { useSnippetsQueryState } from './hooks/use-snippets-query-state'
const TagManagementModal = dynamic(() => import('@/features/tag-management/components/tag-management-modal').then(mod => mod.TagManagementModal), {
ssr: false,
})
const SNIPPET_CARD_SKELETON_KEYS = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth']
type SnippetCardSkeletonProps = {
count: number
}
const SnippetCardSkeleton = ({ count }: SnippetCardSkeletonProps) => {
return (
<>
{SNIPPET_CARD_SKELETON_KEYS.slice(0, count).map(key => (
<div
key={key}
className="col-span-1 h-55 animate-pulse rounded-xl bg-background-default-lighter"
/>
))}
</>
)
}
const SnippetList = () => {
const { t } = useTranslation()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
// eslint-disable-next-line react/use-state -- custom URL query hook, not React.useState
const {
query: { tagIDs, keywords, creatorID },
setKeywords,
setTagIDs,
setCreatorID,
} = useSnippetsQueryState()
const debouncedKeywords = useDebounce(keywords, { wait: SNIPPET_LIST_SEARCH_DEBOUNCE_MS })
const containerRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const [showTagManagementModal, setShowTagManagementModal] = useState(false)
useDocumentTitle(t('tabs.snippets', { ns: 'workflow' }))
const snippetListQuery = useMemo(() => ({
page: 1,
limit: 30,
keyword: debouncedKeywords,
...(tagIDs.length ? { tag_ids: tagIDs } : {}),
...(creatorID ? { creator_id: creatorID } : {}),
}), [creatorID, debouncedKeywords, tagIDs])
const {
data,
isLoading,
isFetching,
isFetchingNextPage,
fetchNextPage,
hasNextPage,
error,
refetch,
} = useInfiniteSnippetList(snippetListQuery, {
enabled: !isCurrentWorkspaceDatasetOperator,
})
useEffect(() => {
if (isCurrentWorkspaceDatasetOperator)
return
const hasMore = hasNextPage ?? true
let observer: IntersectionObserver | undefined
if (error) {
if (observer)
observer.disconnect()
return
}
if (anchorRef.current && containerRef.current) {
const containerHeight = containerRef.current.clientHeight
const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200))
observer = new IntersectionObserver((entries) => {
if (entries[0]!.isIntersecting && !isLoading && !isFetchingNextPage && !error && hasMore)
fetchNextPage()
}, {
root: containerRef.current,
rootMargin: `${dynamicMargin}px`,
threshold: 0.1,
})
observer.observe(anchorRef.current)
}
return () => observer?.disconnect()
}, [error, fetchNextPage, hasNextPage, isCurrentWorkspaceDatasetOperator, isFetchingNextPage, isLoading])
const handleCreatorsChange = useCallback((creatorIDs: string[]) => {
setCreatorID(creatorIDs.at(-1) ?? '')
}, [setCreatorID])
const pages = useMemo(() => data?.pages ?? [], [data?.pages])
const snippets = useMemo<SnippetListItem[]>(() => pages.flatMap(({ data: pageSnippets }) => pageSnippets), [pages])
const hasAnySnippet = (pages[0]?.total ?? 0) > 0
const showSkeleton = isLoading || (isFetching && pages.length === 0)
return (
<div ref={containerRef} className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
<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">
<CreatorsFilter
value={creatorID ? [creatorID] : []}
onChange={handleCreatorsChange}
/>
<TagFilter type="snippet" 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('tabs.searchSnippets', { ns: 'workflow' })}
/>
{!!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>
</div>
{(isCurrentWorkspaceEditor || isLoadingCurrentWorkspace) && (
<SnippetCreateButton />
)}
</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',
!hasAnySnippet && 'overflow-hidden',
)}
>
{showSkeleton
? <SnippetCardSkeleton count={6} />
: hasAnySnippet
? snippets.map(snippet => (
<SnippetCard
key={snippet.id}
snippet={snippet}
onOpenTagManagement={() => setShowTagManagementModal(true)}
onRefresh={refetch}
onTagsChange={refetch}
/>
))
: <Empty message={t('tabs.noSnippetsFound', { ns: 'workflow' })} />}
{isFetchingNextPage && (
<SnippetCardSkeleton count={3} />
)}
</div>
{!systemFeatures.branding.enabled && (
<Footer />
)}
<div ref={anchorRef} className="h-0"> </div>
<TagManagementModal
type="snippet"
show={showTagManagementModal}
onClose={() => setShowTagManagementModal(false)}
onTagsChange={refetch}
/>
</div>
)
}
export default SnippetList

View File

@ -1,137 +0,0 @@
import type { SnippetDetailPayload } from '@/models/snippet'
import { render, screen } from '@testing-library/react'
import SnippetPage from '..'
const mockUseSnippetInit = vi.fn()
const mockSetAppSidebarExpand = vi.fn()
vi.mock('../hooks/use-snippet-init', () => ({
useSnippetInit: (snippetId: string) => mockUseSnippetInit(snippetId),
}))
vi.mock('../components/snippet-main', () => ({
default: ({ snippetId }: { snippetId: string }) => <div data-testid="snippet-main">{snippetId}</div>,
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
replace: vi.fn(),
push: vi.fn(),
}),
}))
vi.mock('@/hooks/use-breakpoints', () => ({
default: () => 'desktop',
MediaType: { mobile: 'mobile', desktop: 'desktop' },
}))
vi.mock('@/hooks/use-document-title', () => ({
default: vi.fn(),
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: { setAppSidebarExpand: typeof mockSetAppSidebarExpand }) => unknown) => selector({
setAppSidebarExpand: mockSetAppSidebarExpand,
}),
}))
vi.mock('@/app/components/workflow', () => ({
default: ({ children }: { children: React.ReactNode }) => (
<div data-testid="workflow-default-context">{children}</div>
),
}))
vi.mock('@/app/components/workflow/context', () => ({
WorkflowContextProvider: ({ children }: { children: React.ReactNode }) => (
<div data-testid="workflow-context-provider">{children}</div>
),
}))
vi.mock('@/app/components/workflow/utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/workflow/utils')>()
return {
...actual,
initialNodes: (nodes: unknown[]) => nodes,
initialEdges: (edges: unknown[]) => edges,
}
})
vi.mock('@/app/components/app-sidebar', () => ({
default: ({
renderHeader,
renderNavigation,
}: {
renderHeader?: (modeState: string) => React.ReactNode
renderNavigation?: (modeState: string) => React.ReactNode
}) => (
<div data-testid="app-sidebar">
<div data-testid="app-sidebar-header">{renderHeader?.('expand')}</div>
<div data-testid="app-sidebar-navigation">{renderNavigation?.('expand')}</div>
</div>
),
}))
vi.mock('@/app/components/app-sidebar/nav-link', () => ({
default: ({ name, onClick }: { name: string, onClick?: () => void }) => (
<button type="button" onClick={onClick}>{name}</button>
),
}))
vi.mock('@/app/components/app-sidebar/snippet-info', () => ({
default: () => <div data-testid="snippet-info" />,
}))
const mockSnippetDetail: SnippetDetailPayload = {
snippet: {
id: 'snippet-1',
name: 'Tone Rewriter',
description: 'A static snippet mock.',
updatedAt: 'Updated 2h ago',
usage: 'Used 19 times',
tags: [],
status: 'Draft',
},
graph: {
viewport: { x: 0, y: 0, zoom: 1 },
nodes: [],
edges: [],
},
inputFields: [],
uiMeta: {
inputFieldCount: 0,
checklistCount: 0,
autoSavedAt: 'Auto-saved · a few seconds ago',
},
}
describe('SnippetPage', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseSnippetInit.mockReturnValue({
data: mockSnippetDetail,
isLoading: false,
})
})
it('should render the orchestrate route shell with independent main content', () => {
render(<SnippetPage snippetId="snippet-1" />)
expect(screen.getByTestId('app-sidebar')).toBeInTheDocument()
expect(screen.getByTestId('snippet-info')).toBeInTheDocument()
expect(screen.getByTestId('workflow-context-provider')).toBeInTheDocument()
expect(screen.getByTestId('workflow-default-context')).toBeInTheDocument()
expect(screen.getByTestId('snippet-main')).toHaveTextContent('snippet-1')
})
it('should render loading fallback when orchestrate data is unavailable', () => {
mockUseSnippetInit.mockReturnValue({
data: null,
isLoading: false,
})
render(<SnippetPage snippetId="missing-snippet" />)
expect(screen.getByRole('status')).toBeInTheDocument()
})
})

View File

@ -1,80 +0,0 @@
import type { InputVar } from '@/models/pipeline'
import { render, screen } from '@testing-library/react'
import { PipelineInputVarType } from '@/models/pipeline'
import SnippetInputFieldEditor from '../input-field-editor'
const mockUseFloatingRight = vi.fn()
vi.mock('@/app/components/rag-pipeline/components/panel/input-field/hooks', () => ({
useFloatingRight: (...args: unknown[]) => mockUseFloatingRight(...args),
}))
vi.mock('../input-field-form', () => ({
default: ({ isEditMode }: { isEditMode: boolean }) => (
<div data-testid="snippet-input-field-form">{isEditMode ? 'edit' : 'create'}</div>
),
}))
const createField = (overrides: Partial<InputVar> = {}): InputVar => ({
type: PipelineInputVarType.textInput,
label: 'Blog URL',
variable: 'blog_url',
required: true,
options: [],
placeholder: 'Paste a source article URL',
max_length: 256,
...overrides,
})
describe('SnippetInputFieldEditor', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseFloatingRight.mockReturnValue({
floatingRight: false,
floatingRightWidth: 400,
})
})
// Verifies the default desktop layout keeps the editor inline with the panel.
describe('Rendering', () => {
it('should render the add title without floating positioning by default', () => {
render(
<SnippetInputFieldEditor
onClose={vi.fn()}
onSubmit={vi.fn()}
/>,
)
const title = screen.getByText('datasetPipeline.inputFieldPanel.addInputField')
const editor = title.parentElement
expect(title).toBeInTheDocument()
expect(editor).not.toHaveClass('absolute')
expect(editor).toHaveStyle({ width: 'min(400px, calc(100vw - 24px))' })
expect(mockUseFloatingRight).toHaveBeenCalledWith(400)
})
it('should float over the panel when there is not enough room', () => {
mockUseFloatingRight.mockReturnValue({
floatingRight: true,
floatingRightWidth: 320,
})
render(
<SnippetInputFieldEditor
field={createField()}
onClose={vi.fn()}
onSubmit={vi.fn()}
/>,
)
const title = screen.getByText('datasetPipeline.inputFieldPanel.editInputField')
const editor = title.parentElement
expect(title).toBeInTheDocument()
expect(editor).toHaveClass('absolute', 'right-0', 'z-[100]')
expect(editor).toHaveStyle({ width: 'min(320px, calc(100vw - 24px))' })
expect(screen.getByTestId('snippet-input-field-form')).toHaveTextContent('edit')
})
})
})

View File

@ -1,42 +0,0 @@
import { render, screen } from '@testing-library/react'
import PublishMenu from '../publish-menu'
describe('PublishMenu', () => {
it('should render the draft summary and publish shortcut', () => {
const { container } = render(
<PublishMenu
draftUpdatedAt={0}
publishedAt={0}
uiMeta={{
inputFieldCount: 1,
checklistCount: 2,
autoSavedAt: 'Auto-saved · a few seconds ago',
}}
onPublish={vi.fn()}
/>,
)
expect(screen.getByText('snippet.publishMenuCurrentDraft')).toBeInTheDocument()
expect(screen.getByText('Auto-saved · a few seconds ago')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'snippet.publishButton' })).toBeInTheDocument()
expect(container.querySelectorAll('.system-kbd')).toHaveLength(3)
})
it('should render published summary when a published version exists', () => {
render(
<PublishMenu
draftUpdatedAt={1_712_300_000_000}
publishedAt={1_712_345_678_000}
uiMeta={{
inputFieldCount: 1,
checklistCount: 2,
autoSavedAt: 'Auto-saved · a few seconds ago',
}}
onPublish={vi.fn()}
/>,
)
expect(screen.getByText('workflow.common.latestPublished')).toBeInTheDocument()
expect(screen.getByText(/workflow\.common\.publishedAt/)).toBeInTheDocument()
})
})

View File

@ -1,60 +0,0 @@
import type { SnippetDetail } from '@/models/snippet'
import { render, screen } from '@testing-library/react'
import SnippetLayout from '../snippet-layout'
const mockUseDocumentTitle = vi.fn()
vi.mock('@/hooks/use-document-title', () => ({
default: (title: string) => mockUseDocumentTitle(title),
}))
const createSnippet = (overrides: Partial<SnippetDetail> = {}): SnippetDetail => ({
id: 'snippet-1',
name: 'Snippet Title',
description: 'Snippet description',
updatedAt: '2026-04-15',
usage: '42',
tags: [],
is_published: true,
...overrides,
})
describe('SnippetLayout', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
})
describe('Document title', () => {
it('should set the document title to the snippet name when snippet detail is available', () => {
render(
<SnippetLayout
snippetId="snippet-1"
snippet={createSnippet()}
section="orchestrate"
>
<div>content</div>
</SnippetLayout>,
)
expect(mockUseDocumentTitle).toHaveBeenCalledWith('Snippet Title')
})
})
describe('Layout', () => {
it('should render the detail content without the app detail sidebar navigation', () => {
render(
<SnippetLayout
snippetId="snippet-1"
snippet={createSnippet()}
section="orchestrate"
>
<div>content</div>
</SnippetLayout>,
)
expect(screen.getByText('content')).toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'snippet.sectionOrchestrate' })).not.toBeInTheDocument()
})
})
})

View File

@ -1,381 +0,0 @@
import type { ReactNode } from 'react'
import type { WorkflowProps } from '@/app/components/workflow'
import type { SnippetDetailPayload, SnippetInputField } from '@/models/snippet'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { PipelineInputVarType } from '@/models/pipeline'
import SnippetMain from '../snippet-main'
const mockSyncInputFieldsDraft = vi.fn()
const mockCloseEditor = vi.fn()
const mockOpenEditor = vi.fn()
const mockReset = vi.fn()
const mockSetFields = vi.fn()
const mockSetInputPanelOpen = vi.fn()
const mockSetPublishMenuOpen = vi.fn()
const mockToggleInputPanel = vi.fn()
const mockTogglePublishMenu = vi.fn()
const mockPublishSnippetMutateAsync = vi.fn()
const mockUseSnippetPublishedWorkflow = vi.fn()
const mockFetchInspectVars = vi.fn()
const mockHandleBackupDraft = vi.fn()
const mockHandleLoadBackupDraft = vi.fn()
const mockHandleRestoreFromPublishedWorkflow = vi.fn()
const mockHandleRun = vi.fn()
const mockHandleStartWorkflowRun = vi.fn()
const mockHandleStopRun = vi.fn()
const mockHandleWorkflowStartRunInWorkflow = vi.fn()
const mockHandleCheckBeforePublish = vi.fn()
const mockInspectVarsCrud = {
hasNodeInspectVars: vi.fn(),
hasSetInspectVar: vi.fn(),
fetchInspectVarValue: vi.fn(),
editInspectVarValue: vi.fn(),
renameInspectVarName: vi.fn(),
appendNodeInspectVars: vi.fn(),
deleteInspectVar: vi.fn(),
deleteNodeInspectorVars: vi.fn(),
deleteAllInspectorVars: vi.fn(),
isInspectVarEdited: vi.fn(),
resetToLastRunVar: vi.fn(),
invalidateSysVarValues: vi.fn(),
resetConversationVar: vi.fn(),
invalidateConversationVarValues: vi.fn(),
}
let capturedHooksStore: Record<string, unknown> | undefined
let snippetDetailStoreState: {
editingField: SnippetInputField | null
fields: SnippetInputField[]
isEditorOpen: boolean
isInputPanelOpen: boolean
isPublishMenuOpen: boolean
closeEditor: typeof mockCloseEditor
openEditor: typeof mockOpenEditor
reset: typeof mockReset
setFields: typeof mockSetFields
setInputPanelOpen: typeof mockSetInputPanelOpen
setPublishMenuOpen: typeof mockSetPublishMenuOpen
toggleInputPanel: typeof mockToggleInputPanel
togglePublishMenu: typeof mockTogglePublishMenu
}
vi.mock('@/app/components/snippets/store', () => ({
useSnippetDetailStore: (selector: (state: typeof snippetDetailStoreState) => unknown) => selector(snippetDetailStoreState),
}))
vi.mock('@/service/use-snippet-workflows', () => ({
usePublishSnippetWorkflowMutation: () => ({
mutateAsync: mockPublishSnippetMutateAsync,
isPending: false,
}),
useSnippetPublishedWorkflow: () => mockUseSnippetPublishedWorkflow(),
}))
vi.mock('@/app/components/snippets/hooks/use-configs-map', () => ({
useConfigsMap: () => ({
flowId: 'snippet-1',
flowType: 'snippet',
fileSettings: {},
}),
}))
vi.mock('@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars', () => ({
useSetWorkflowVarsWithValue: () => ({
fetchInspectVars: mockFetchInspectVars,
}),
}))
vi.mock('@/app/components/workflow/hooks/use-checklist', () => ({
useChecklistBeforePublish: () => ({
handleCheckBeforePublish: mockHandleCheckBeforePublish,
}),
}))
vi.mock('@/app/components/snippets/hooks/use-inspect-vars-crud', () => ({
useInspectVarsCrud: () => mockInspectVarsCrud,
}))
vi.mock('@/app/components/snippets/hooks/use-nodes-sync-draft', () => ({
useNodesSyncDraft: () => ({
doSyncWorkflowDraft: vi.fn(),
syncInputFieldsDraft: mockSyncInputFieldsDraft,
syncWorkflowDraftWhenPageClose: vi.fn(),
}),
}))
vi.mock('@/app/components/snippets/hooks/use-snippet-refresh-draft', () => ({
useSnippetRefreshDraft: () => ({
handleRefreshWorkflowDraft: vi.fn(),
}),
}))
vi.mock('@/app/components/snippets/hooks/use-snippet-run', () => ({
useSnippetRun: () => ({
handleBackupDraft: mockHandleBackupDraft,
handleLoadBackupDraft: mockHandleLoadBackupDraft,
handleRestoreFromPublishedWorkflow: mockHandleRestoreFromPublishedWorkflow,
handleRun: mockHandleRun,
handleStopRun: mockHandleStopRun,
}),
}))
vi.mock('@/app/components/snippets/hooks/use-snippet-start-run', () => ({
useSnippetStartRun: () => ({
handleStartWorkflowRun: mockHandleStartWorkflowRun,
handleWorkflowStartRunInWorkflow: mockHandleWorkflowStartRunInWorkflow,
}),
}))
vi.mock('@/app/components/workflow', () => ({
WorkflowWithInnerContext: ({
children,
hooksStore,
}: {
children: ReactNode
hooksStore?: Record<string, unknown>
}) => {
capturedHooksStore = hooksStore
return (
<div data-testid="workflow-inner-context">{children}</div>
)
},
}))
vi.mock('@/app/components/snippets/components/snippet-children', () => ({
default: ({
onCancel,
onPublish,
}: {
onCancel: () => void
onPublish: () => void
}) => (
<div>
<button type="button" onClick={onPublish}>publish</button>
<button type="button" onClick={onCancel}>cancel</button>
</div>
),
}))
vi.mock('@/app/components/snippets/components/snippet-sidebar', () => ({
default: ({
children,
onRemove,
}: {
children?: ReactNode
onRemove: (index: number) => void
}) => (
<div>
{children}
<button type="button" onClick={() => onRemove(0)}>remove</button>
</div>
),
}))
vi.mock('@/app/components/snippets/components/input-field-editor', () => ({
default: ({
onSubmit,
}: {
onSubmit: (field: SnippetInputField) => void
}) => (
<button
type="button"
onClick={() => onSubmit({
type: PipelineInputVarType.textInput,
label: 'New Field',
variable: 'new_field',
required: true,
})}
>
submit
</button>
),
}))
const payload: SnippetDetailPayload = {
snippet: {
id: 'snippet-1',
name: 'Snippet',
description: 'desc',
updatedAt: '2026-03-29 10:00',
usage: '0',
tags: [],
},
graph: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
inputFields: [
{
type: PipelineInputVarType.textInput,
label: 'Blog URL',
variable: 'blog_url',
required: true,
},
],
uiMeta: {
inputFieldCount: 1,
checklistCount: 0,
autoSavedAt: '2026-03-29 10:00',
},
}
const renderSnippetMain = () => {
return renderWorkflowComponent(
<SnippetMain
payload={payload}
snippetId="snippet-1"
nodes={[] as WorkflowProps['nodes']}
edges={[] as WorkflowProps['edges']}
viewport={{ x: 0, y: 0, zoom: 1 }}
/>,
)
}
describe('SnippetMain', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSyncInputFieldsDraft.mockResolvedValue(undefined)
mockPublishSnippetMutateAsync.mockResolvedValue({ created_at: 1_744_000_000 })
mockUseSnippetPublishedWorkflow.mockReturnValue({
data: {
graph: payload.graph,
input_fields: payload.inputFields,
},
})
mockHandleCheckBeforePublish.mockResolvedValue(true)
capturedHooksStore = undefined
snippetDetailStoreState = {
editingField: null,
fields: [...payload.inputFields],
isEditorOpen: false,
isInputPanelOpen: true,
isPublishMenuOpen: false,
closeEditor: mockCloseEditor,
openEditor: mockOpenEditor,
reset: mockReset,
setFields: mockSetFields,
setInputPanelOpen: mockSetInputPanelOpen,
setPublishMenuOpen: mockSetPublishMenuOpen,
toggleInputPanel: mockToggleInputPanel,
togglePublishMenu: mockTogglePublishMenu,
}
})
describe('Input Fields Sync', () => {
it('should sync draft input_fields when removing a field from the panel', async () => {
renderSnippetMain()
fireEvent.click(screen.getByRole('button', { name: 'remove' }))
await waitFor(() => {
expect(mockSyncInputFieldsDraft).toHaveBeenCalledWith([], {
onRefresh: expect.any(Function),
})
})
})
it('should sync draft input_fields when submitting a field from the editor', async () => {
snippetDetailStoreState.isEditorOpen = true
renderSnippetMain()
fireEvent.click(screen.getByRole('button', { name: 'submit' }))
await waitFor(() => {
expect(mockSyncInputFieldsDraft).toHaveBeenCalledWith([
payload.inputFields[0],
{
type: PipelineInputVarType.textInput,
label: 'New Field',
variable: 'new_field',
required: true,
},
], {
onRefresh: expect.any(Function),
})
})
})
})
describe('Publish', () => {
it('should call the publish mutation and close the publish menu', async () => {
renderSnippetMain()
fireEvent.click(screen.getByRole('button', { name: 'publish' }))
await waitFor(() => {
expect(mockPublishSnippetMutateAsync).toHaveBeenCalledWith({
params: { snippetId: 'snippet-1' },
})
})
expect(mockSetPublishMenuOpen).toHaveBeenCalledWith(false)
})
})
describe('Cancel', () => {
it('should restore from the published workflow and reset published input fields', async () => {
renderSnippetMain()
fireEvent.click(screen.getByRole('button', { name: 'cancel' }))
await waitFor(() => {
expect(mockHandleRestoreFromPublishedWorkflow).toHaveBeenCalledWith({
graph: payload.graph,
input_fields: payload.inputFields,
})
expect(mockSetFields).toHaveBeenCalledWith(payload.inputFields)
expect(mockSyncInputFieldsDraft).toHaveBeenCalledWith(payload.inputFields, {
onRefresh: expect.any(Function),
})
})
})
})
describe('Inspect Vars', () => {
it('should pass inspect vars handlers to WorkflowWithInnerContext', () => {
renderSnippetMain()
expect(capturedHooksStore?.fetchInspectVars).toBe(mockFetchInspectVars)
expect(capturedHooksStore?.hasNodeInspectVars).toBe(mockInspectVarsCrud.hasNodeInspectVars)
expect(capturedHooksStore?.hasSetInspectVar).toBe(mockInspectVarsCrud.hasSetInspectVar)
expect(capturedHooksStore?.fetchInspectVarValue).toBe(mockInspectVarsCrud.fetchInspectVarValue)
expect(capturedHooksStore?.editInspectVarValue).toBe(mockInspectVarsCrud.editInspectVarValue)
expect(capturedHooksStore?.renameInspectVarName).toBe(mockInspectVarsCrud.renameInspectVarName)
expect(capturedHooksStore?.appendNodeInspectVars).toBe(mockInspectVarsCrud.appendNodeInspectVars)
expect(capturedHooksStore?.deleteInspectVar).toBe(mockInspectVarsCrud.deleteInspectVar)
expect(capturedHooksStore?.deleteNodeInspectorVars).toBe(mockInspectVarsCrud.deleteNodeInspectorVars)
expect(capturedHooksStore?.deleteAllInspectorVars).toBe(mockInspectVarsCrud.deleteAllInspectorVars)
expect(capturedHooksStore?.isInspectVarEdited).toBe(mockInspectVarsCrud.isInspectVarEdited)
expect(capturedHooksStore?.resetToLastRunVar).toBe(mockInspectVarsCrud.resetToLastRunVar)
expect(capturedHooksStore?.invalidateSysVarValues).toBe(mockInspectVarsCrud.invalidateSysVarValues)
expect(capturedHooksStore?.resetConversationVar).toBe(mockInspectVarsCrud.resetConversationVar)
expect(capturedHooksStore?.invalidateConversationVarValues).toBe(mockInspectVarsCrud.invalidateConversationVarValues)
})
})
describe('Run Hooks', () => {
it('should pass snippet run handlers to WorkflowWithInnerContext', () => {
renderSnippetMain()
expect(capturedHooksStore?.handleBackupDraft).toBe(mockHandleBackupDraft)
expect(capturedHooksStore?.handleLoadBackupDraft).toBe(mockHandleLoadBackupDraft)
expect(capturedHooksStore?.handleRestoreFromPublishedWorkflow).toBe(mockHandleRestoreFromPublishedWorkflow)
expect(capturedHooksStore?.handleRun).toBe(mockHandleRun)
expect(capturedHooksStore?.handleStopRun).toBe(mockHandleStopRun)
expect(capturedHooksStore?.handleStartWorkflowRun).toBe(mockHandleStartWorkflowRun)
expect(capturedHooksStore?.handleWorkflowStartRunInWorkflow).toBe(mockHandleWorkflowStartRunInWorkflow)
})
it('should pass snippet workflow run detail urls to WorkflowWithInnerContext', () => {
renderSnippetMain()
const getWorkflowRunAndTraceUrl = capturedHooksStore?.getWorkflowRunAndTraceUrl as ((runId?: string) => { runUrl: string, traceUrl: string }) | undefined
expect(getWorkflowRunAndTraceUrl?.('run-1')).toEqual({
runUrl: '/snippets/snippet-1/workflow-runs/run-1',
traceUrl: '/snippets/snippet-1/workflow-runs/run-1/node-executions',
})
})
})
})

View File

@ -1,43 +0,0 @@
import type { PanelProps } from '@/app/components/workflow/panel'
import type { SnippetInputField } from '@/models/snippet'
import { render, waitFor } from '@testing-library/react'
import SnippetWorkflowPanel from '../workflow-panel'
let capturedPanelProps: PanelProps | null = null
vi.mock('@/app/components/workflow/panel', () => ({
default: (props: PanelProps) => {
capturedPanelProps = props
return <div data-testid="workflow-panel" />
},
}))
const defaultFields: SnippetInputField[] = []
describe('SnippetWorkflowPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
capturedPanelProps = null
})
// Verifies snippet panel wires version history support into the shared workflow panel.
describe('Rendering', () => {
it('should pass snippet version history panel props to the shared workflow panel', async () => {
render(
<SnippetWorkflowPanel
snippetId="snippet-1"
fields={defaultFields}
/>,
)
await waitFor(() => {
expect(capturedPanelProps?.versionHistoryPanelProps?.getVersionListUrl).toBe('/snippets/snippet-1/workflows')
expect(capturedPanelProps?.versionHistoryPanelProps?.deleteVersionUrl?.('version-1')).toBe('/snippets/snippet-1/workflows/version-1')
expect(capturedPanelProps?.versionHistoryPanelProps?.restoreVersionUrl('version-1')).toBe('/snippets/snippet-1/workflows/version-1/restore')
expect(capturedPanelProps?.versionHistoryPanelProps?.updateVersionUrl?.('version-1')).toBe('/snippets/snippet-1/workflows/version-1')
expect(capturedPanelProps?.versionHistoryPanelProps?.latestVersionId).toBe('')
expect(capturedPanelProps?.components?.right).toBeTruthy()
})
})
})
})

View File

@ -1,168 +0,0 @@
import type { SnippetInputField } from '@/models/snippet'
import { toast } from '@langgenius/dify-ui/toast'
import { act, renderHook } from '@testing-library/react'
import { PipelineInputVarType } from '@/models/pipeline'
import { useSnippetInputFieldActions } from '../use-snippet-input-field-actions'
const mockSyncInputFieldsDraft = vi.fn()
const mockCloseEditor = vi.fn()
const mockOpenEditor = vi.fn()
const mockSetFields = vi.fn()
const mockSetInputPanelOpen = vi.fn()
const mockToggleInputPanel = vi.fn()
let snippetDetailStoreState: {
editingField: SnippetInputField | null
fields: SnippetInputField[]
isEditorOpen: boolean
isInputPanelOpen: boolean
closeEditor: typeof mockCloseEditor
openEditor: typeof mockOpenEditor
setFields: typeof mockSetFields
setInputPanelOpen: typeof mockSetInputPanelOpen
toggleInputPanel: typeof mockToggleInputPanel
}
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
error: vi.fn(),
},
}))
vi.mock('../../../hooks/use-nodes-sync-draft', () => ({
useNodesSyncDraft: () => ({
syncInputFieldsDraft: mockSyncInputFieldsDraft,
}),
}))
vi.mock('../../../store', () => ({
useSnippetDetailStore: (selector: (state: typeof snippetDetailStoreState) => unknown) => selector(snippetDetailStoreState),
}))
const createField = (overrides: Partial<SnippetInputField> = {}): SnippetInputField => ({
type: PipelineInputVarType.textInput,
label: 'Blog URL',
variable: 'blog_url',
required: true,
...overrides,
})
describe('useSnippetInputFieldActions', () => {
beforeEach(() => {
vi.clearAllMocks()
snippetDetailStoreState = {
editingField: null,
fields: [],
isEditorOpen: false,
isInputPanelOpen: true,
closeEditor: mockCloseEditor,
openEditor: mockOpenEditor,
setFields: mockSetFields,
setInputPanelOpen: mockSetInputPanelOpen,
toggleInputPanel: mockToggleInputPanel,
}
mockSetFields.mockImplementation((fields: SnippetInputField[]) => {
snippetDetailStoreState.fields = fields
})
mockSyncInputFieldsDraft.mockResolvedValue(undefined)
})
describe('Field sync', () => {
it('should remove a field and sync the draft', () => {
snippetDetailStoreState.fields = [createField()]
const { result } = renderHook(() => useSnippetInputFieldActions({
snippetId: 'snippet-1',
}))
act(() => {
result.current.handleRemoveField(0)
})
expect(mockSetFields).toHaveBeenCalledWith([])
expect(mockSyncInputFieldsDraft).toHaveBeenCalledWith([], {
onRefresh: expect.any(Function),
})
})
it('should append a new field and close the editor after syncing', () => {
snippetDetailStoreState.fields = [createField()]
const { result } = renderHook(() => useSnippetInputFieldActions({
snippetId: 'snippet-1',
}))
act(() => {
result.current.handleSubmitField(createField({
label: 'Topic',
variable: 'topic',
}))
})
expect(mockSetFields).toHaveBeenCalledWith([
createField(),
createField({
label: 'Topic',
variable: 'topic',
}),
])
expect(mockSyncInputFieldsDraft).toHaveBeenCalledWith([
createField(),
createField({
label: 'Topic',
variable: 'topic',
}),
], {
onRefresh: expect.any(Function),
})
expect(mockCloseEditor).toHaveBeenCalledTimes(1)
})
it('should reject duplicated variables without syncing', () => {
snippetDetailStoreState.fields = [createField()]
const { result } = renderHook(() => useSnippetInputFieldActions({
snippetId: 'snippet-1',
}))
act(() => {
result.current.handleSubmitField(createField({
label: 'Duplicated',
variable: 'blog_url',
}))
})
expect(toast.error).toHaveBeenCalledWith('datasetPipeline.inputFieldPanel.error.variableDuplicate')
expect(mockSyncInputFieldsDraft).not.toHaveBeenCalled()
expect(mockCloseEditor).not.toHaveBeenCalled()
expect(mockSetFields).not.toHaveBeenCalled()
})
})
describe('Panel actions', () => {
it('should close the editor before toggling the input panel when the panel is open', () => {
snippetDetailStoreState.fields = [createField()]
const { result } = renderHook(() => useSnippetInputFieldActions({
snippetId: 'snippet-1',
}))
act(() => {
result.current.handleToggleInputPanel()
})
expect(mockCloseEditor).toHaveBeenCalledTimes(1)
expect(mockToggleInputPanel).toHaveBeenCalledTimes(1)
})
it('should close the input panel and clear the editor state', () => {
snippetDetailStoreState.fields = [createField()]
const { result } = renderHook(() => useSnippetInputFieldActions({
snippetId: 'snippet-1',
}))
act(() => {
result.current.handleCloseInputPanel()
})
expect(mockCloseEditor).toHaveBeenCalledTimes(1)
expect(mockSetInputPanelOpen).toHaveBeenCalledWith(false)
})
})
})

View File

@ -1,174 +0,0 @@
import { toast } from '@langgenius/dify-ui/toast'
import { act, renderHook, waitFor } from '@testing-library/react'
import { useSnippetPublish } from '../use-snippet-publish'
const mockMutateAsync = vi.fn()
const mockSetPublishMenuOpen = vi.fn()
const mockUseKeyPress = vi.fn()
const mockSetPublishedAt = vi.fn()
const mockSetQueryData = vi.fn()
const mockHandleCheckBeforePublish = vi.fn<() => Promise<boolean>>()
let isPublishMenuOpen = false
let isPending = false
let shortcutHandler: ((event: KeyboardEvent) => void) | undefined
vi.mock('ahooks', () => ({
useKeyPress: (...args: Parameters<typeof mockUseKeyPress>) => mockUseKeyPress(...args),
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
error: vi.fn(),
success: vi.fn(),
},
}))
vi.mock('@tanstack/react-query', () => ({
useQueryClient: () => ({
setQueryData: mockSetQueryData,
}),
}))
vi.mock('@/service/use-snippet-workflows', () => ({
usePublishSnippetWorkflowMutation: () => ({
mutateAsync: mockMutateAsync,
isPending,
}),
}))
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: () => ({
setPublishedAt: mockSetPublishedAt,
}),
}),
}))
vi.mock('@/app/components/workflow/hooks/use-checklist', () => ({
useChecklistBeforePublish: () => ({
handleCheckBeforePublish: mockHandleCheckBeforePublish,
}),
}))
vi.mock('../../../store', () => ({
useSnippetDetailStore: (selector: (state: {
isPublishMenuOpen: boolean
setPublishMenuOpen: typeof mockSetPublishMenuOpen
}) => unknown) => selector({
isPublishMenuOpen,
setPublishMenuOpen: mockSetPublishMenuOpen,
}),
}))
describe('useSnippetPublish', () => {
beforeEach(() => {
vi.clearAllMocks()
isPublishMenuOpen = false
isPending = false
shortcutHandler = undefined
mockHandleCheckBeforePublish.mockResolvedValue(true)
mockMutateAsync.mockResolvedValue({ created_at: 1_712_345_678 })
mockUseKeyPress.mockImplementation((_key, handler) => {
shortcutHandler = handler
})
})
describe('Publish action', () => {
it('should publish the snippet, close the menu, and show success feedback', async () => {
const { result } = renderHook(() => useSnippetPublish({
snippetId: 'snippet-1',
}))
await act(async () => {
await result.current.handlePublish()
})
expect(mockHandleCheckBeforePublish).toHaveBeenCalledTimes(1)
expect(mockMutateAsync).toHaveBeenCalledWith({
params: { snippetId: 'snippet-1' },
})
expect(mockSetQueryData).toHaveBeenCalledTimes(1)
const setQueryDataCall = mockSetQueryData.mock.calls[0]
expect(setQueryDataCall).toBeDefined()
const updateSnippetDetail = setQueryDataCall![1] as (old: { is_published: boolean }) => { is_published: boolean }
expect(updateSnippetDetail({ is_published: false })).toEqual({ is_published: true })
expect(mockSetPublishedAt).toHaveBeenCalledWith(1_712_345_678)
expect(mockSetPublishMenuOpen).toHaveBeenCalledWith(false)
expect(toast.success).toHaveBeenCalledWith('snippet.publishSuccess')
})
it('should not publish the snippet when checklist validation fails', async () => {
mockHandleCheckBeforePublish.mockResolvedValue(false)
const { result } = renderHook(() => useSnippetPublish({
snippetId: 'snippet-1',
}))
await act(async () => {
await result.current.handlePublish()
})
expect(mockHandleCheckBeforePublish).toHaveBeenCalledTimes(1)
expect(mockMutateAsync).not.toHaveBeenCalled()
expect(mockSetQueryData).not.toHaveBeenCalled()
expect(mockSetPublishedAt).not.toHaveBeenCalled()
expect(mockSetPublishMenuOpen).not.toHaveBeenCalled()
expect(toast.success).not.toHaveBeenCalled()
})
it('should surface publish errors through toast feedback', async () => {
mockMutateAsync.mockRejectedValue(new Error('publish failed'))
const { result } = renderHook(() => useSnippetPublish({
snippetId: 'snippet-1',
}))
await act(async () => {
await result.current.handlePublish()
})
expect(toast.error).toHaveBeenCalledWith('publish failed')
expect(mockSetPublishMenuOpen).not.toHaveBeenCalled()
})
})
describe('Keyboard shortcut', () => {
it('should trigger publish on ctrl+shift+p in the orchestrate section', async () => {
renderHook(() => useSnippetPublish({
snippetId: 'snippet-1',
}))
const event = new KeyboardEvent('keydown')
const preventDefault = vi.spyOn(event, 'preventDefault')
act(() => {
shortcutHandler?.(event)
})
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith({
params: { snippetId: 'snippet-1' },
})
})
expect(preventDefault).toHaveBeenCalledTimes(1)
})
it('should ignore the shortcut while publishing is pending', () => {
isPending = true
renderHook(() => useSnippetPublish({
snippetId: 'snippet-1',
}))
const event = new KeyboardEvent('keydown')
const preventDefault = vi.spyOn(event, 'preventDefault')
act(() => {
shortcutHandler?.(event)
})
expect(mockMutateAsync).not.toHaveBeenCalled()
expect(preventDefault).not.toHaveBeenCalled()
})
})
})

View File

@ -1,96 +0,0 @@
import type { SnippetInputField } from '@/models/snippet'
import { toast } from '@langgenius/dify-ui/toast'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useShallow } from 'zustand/react/shallow'
import { useNodesSyncDraft } from '../../hooks/use-nodes-sync-draft'
import { useSnippetDetailStore } from '../../store'
type UseSnippetInputFieldActionsOptions = {
snippetId: string
}
export const useSnippetInputFieldActions = ({
snippetId,
}: UseSnippetInputFieldActionsOptions) => {
const { t } = useTranslation('snippet')
const { syncInputFieldsDraft } = useNodesSyncDraft(snippetId)
const {
editingField,
fields,
isEditorOpen,
isInputPanelOpen,
closeEditor,
openEditor,
setFields,
setInputPanelOpen,
toggleInputPanel,
} = useSnippetDetailStore(useShallow(state => ({
editingField: state.editingField,
fields: state.fields,
isEditorOpen: state.isEditorOpen,
isInputPanelOpen: state.isInputPanelOpen,
closeEditor: state.closeEditor,
openEditor: state.openEditor,
setFields: state.setFields,
setInputPanelOpen: state.setInputPanelOpen,
toggleInputPanel: state.toggleInputPanel,
})))
const handleSortChange = useCallback((newFields: SnippetInputField[]) => {
setFields(newFields)
}, [setFields])
const handleRemoveField = useCallback((index: number) => {
const nextFields = fields.filter((_, currentIndex) => currentIndex !== index)
setFields(nextFields)
void syncInputFieldsDraft(nextFields, {
onRefresh: setFields,
})
}, [fields, setFields, syncInputFieldsDraft])
const handleSubmitField = useCallback((field: SnippetInputField) => {
const originalVariable = editingField?.variable
const duplicated = fields.some(item => item.variable === field.variable && item.variable !== originalVariable)
if (duplicated) {
toast.error(t('inputFieldPanel.error.variableDuplicate', { ns: 'datasetPipeline' }))
return
}
const nextFields = originalVariable
? fields.map(item => item.variable === originalVariable ? field : item)
: [...fields, field]
setFields(nextFields)
void syncInputFieldsDraft(nextFields, {
onRefresh: setFields,
})
closeEditor()
}, [closeEditor, editingField?.variable, fields, setFields, syncInputFieldsDraft, t])
const handleToggleInputPanel = useCallback(() => {
if (isInputPanelOpen)
closeEditor()
toggleInputPanel()
}, [closeEditor, isInputPanelOpen, toggleInputPanel])
const handleCloseInputPanel = useCallback(() => {
closeEditor()
setInputPanelOpen(false)
}, [closeEditor, setInputPanelOpen])
return {
editingField,
fields,
isEditorOpen,
isInputPanelOpen,
openEditor,
closeEditor,
handleCloseInputPanel,
handleRemoveField,
handleSortChange,
handleSubmitField,
handleToggleInputPanel,
}
}

View File

@ -1,75 +0,0 @@
import type { Snippet as SnippetContract } from '@/types/snippet'
import { toast } from '@langgenius/dify-ui/toast'
import { useQueryClient } from '@tanstack/react-query'
import { useKeyPress } from 'ahooks'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useShallow } from 'zustand/react/shallow'
import { useChecklistBeforePublish } from '@/app/components/workflow/hooks/use-checklist'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils'
import { consoleQuery } from '@/service/client'
import { usePublishSnippetWorkflowMutation } from '@/service/use-snippet-workflows'
import { useSnippetDetailStore } from '../../store'
type UseSnippetPublishOptions = {
snippetId: string
}
export const useSnippetPublish = ({
snippetId,
}: UseSnippetPublishOptions) => {
const { t } = useTranslation('snippet')
const workflowStore = useWorkflowStore()
const queryClient = useQueryClient()
const publishSnippetMutation = usePublishSnippetWorkflowMutation(snippetId)
const { handleCheckBeforePublish } = useChecklistBeforePublish()
const {
isPublishMenuOpen,
setPublishMenuOpen,
} = useSnippetDetailStore(useShallow(state => ({
isPublishMenuOpen: state.isPublishMenuOpen,
setPublishMenuOpen: state.setPublishMenuOpen,
})))
const handlePublish = useCallback(async () => {
try {
const canPublish = await handleCheckBeforePublish()
if (!canPublish)
return
const publishedWorkflow = await publishSnippetMutation.mutateAsync({
params: { snippetId },
})
queryClient.setQueryData<SnippetContract | undefined>(
consoleQuery.snippets.detail.queryKey({
input: {
params: { snippetId },
},
}),
old => old ? { ...old, is_published: true } : old,
)
workflowStore.getState().setPublishedAt(publishedWorkflow.created_at)
setPublishMenuOpen(false)
toast.success(t('publishSuccess'))
}
catch (error) {
toast.error(error instanceof Error ? error.message : t('publishFailed'))
}
}, [handleCheckBeforePublish, publishSnippetMutation, queryClient, setPublishMenuOpen, snippetId, t, workflowStore])
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (event) => {
if (publishSnippetMutation.isPending)
return
event.preventDefault()
void handlePublish()
}, { exactMatch: true, useCapture: true })
return {
handlePublish,
isPublishMenuOpen,
isPublishing: publishSnippetMutation.isPending,
setPublishMenuOpen,
}
}

View File

@ -1,66 +0,0 @@
'use client'
import type { FormData } from '@/app/components/rag-pipeline/components/panel/input-field/editor/form/types'
import type { SnippetInputField } from '@/models/snippet'
import { cn } from '@langgenius/dify-ui/cn'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { convertFormDataToINputField, convertToInputFieldFormData } from '@/app/components/rag-pipeline/components/panel/input-field/editor/utils'
import { useFloatingRight } from '@/app/components/rag-pipeline/components/panel/input-field/hooks'
import InputFieldForm from './input-field-form'
type SnippetInputFieldEditorProps = {
field?: SnippetInputField | null
onClose: () => void
onSubmit: (field: SnippetInputField) => void
}
const SnippetInputFieldEditor = ({
field,
onClose,
onSubmit,
}: SnippetInputFieldEditorProps) => {
const { t } = useTranslation()
const { floatingRight, floatingRightWidth } = useFloatingRight(400)
const initialData = useMemo(() => {
return convertToInputFieldFormData(field || undefined)
}, [field])
const handleSubmit = useCallback((value: FormData) => {
onSubmit(convertFormDataToINputField(value))
}, [onSubmit])
return (
<div
className={cn(
'relative mr-1 flex h-fit max-h-full flex-col overflow-y-auto rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-2xl shadow-shadow-shadow-9',
'transition-all duration-300 ease-in-out',
floatingRight && 'absolute right-0 z-[100]',
)}
style={{
width: `min(${floatingRightWidth}px, calc(100vw - 24px))`,
}}
>
<div className="system-xl-semibold flex items-center pt-3.5 pr-11 pb-1 pl-4 text-text-primary">
{field ? t('inputFieldPanel.editInputField', { ns: 'datasetPipeline' }) : t('inputFieldPanel.addInputField', { ns: 'datasetPipeline' })}
</div>
<button
type="button"
className="absolute top-2.5 right-2.5 flex h-8 w-8 items-center justify-center"
onClick={onClose}
>
<span aria-hidden className="i-ri-close-line h-4 w-4 text-text-tertiary" />
</button>
<InputFieldForm
initialData={initialData}
supportFile
onCancel={onClose}
onSubmit={handleSubmit}
isEditMode={!!field}
/>
</div>
)
}
export default SnippetInputFieldEditor

View File

@ -1,40 +0,0 @@
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PipelineInputVarType } from '@/models/pipeline'
import { useSnippetInputFieldConfigurations } from '../hooks'
const mockUseConfigurations = vi.fn()
vi.mock('@/app/components/rag-pipeline/components/panel/input-field/editor/form/hooks', () => ({
useConfigurations: (...args: unknown[]) => mockUseConfigurations(...args),
}))
describe('useSnippetInputFieldConfigurations', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should make text maxLength configuration optional for snippets only', () => {
mockUseConfigurations.mockReturnValue([
{
variable: 'maxLength',
required: true,
showConditions: [{ variable: 'type', value: PipelineInputVarType.textInput }],
},
{
variable: 'required',
required: true,
showConditions: [],
},
])
const { result } = renderHook(() => useSnippetInputFieldConfigurations({
getFieldValue: vi.fn(),
setFieldValue: vi.fn(),
supportFile: true,
}))
expect(result.current[0]!.required).toBe(false)
expect(result.current[1]!.required).toBe(true)
})
})

View File

@ -1,51 +0,0 @@
import type { TFunction } from 'i18next'
import { describe, expect, it, vi } from 'vitest'
import { PipelineInputVarType } from '@/models/pipeline'
import { createSnippetInputFieldSchema, TEXT_MAX_LENGTH } from '../schema'
vi.mock('@/config', () => ({
MAX_VAR_KEY_LENGTH: 30,
}))
const t: TFunction = ((key: string) => key) as unknown as TFunction
describe('createSnippetInputFieldSchema', () => {
const defaultOptions = { maxFileUploadLimit: 10 }
it('should allow text-input maxLength to be omitted', () => {
const schema = createSnippetInputFieldSchema(PipelineInputVarType.textInput, t, defaultOptions)
const result = schema.safeParse({
type: 'text-input',
variable: 'text_var',
label: 'Text',
required: false,
})
expect(result.success).toBe(true)
})
it('should still reject text-input maxLength above the limit', () => {
const schema = createSnippetInputFieldSchema(PipelineInputVarType.textInput, t, defaultOptions)
const result = schema.safeParse({
type: 'text-input',
variable: 'text_var',
label: 'Text',
required: false,
maxLength: TEXT_MAX_LENGTH + 1,
})
expect(result.success).toBe(false)
})
it('should allow paragraph maxLength to be omitted', () => {
const schema = createSnippetInputFieldSchema(PipelineInputVarType.paragraph, t, defaultOptions)
const result = schema.safeParse({
type: 'paragraph',
variable: 'paragraph_var',
label: 'Paragraph',
required: false,
})
expect(result.success).toBe(true)
})
})

View File

@ -1,30 +0,0 @@
import type { DeepKeys } from '@tanstack/react-form'
import type { FormData } from '@/app/components/rag-pipeline/components/panel/input-field/editor/form/types'
import { useMemo } from 'react'
import { useConfigurations } from '@/app/components/rag-pipeline/components/panel/input-field/editor/form/hooks'
import { PipelineInputVarType } from '@/models/pipeline'
type UseSnippetInputFieldConfigurationsProps = {
getFieldValue: (fieldName: DeepKeys<FormData>) => unknown
setFieldValue: (fieldName: DeepKeys<FormData>, value: unknown) => void
supportFile: boolean
}
export const useSnippetInputFieldConfigurations = (props: UseSnippetInputFieldConfigurationsProps) => {
const configurations = useConfigurations(props)
return useMemo(() => {
return configurations.map((configuration) => {
const isTextMaxLengthField = configuration.variable === 'maxLength'
&& configuration.showConditions?.some(condition => condition.value === PipelineInputVarType.textInput)
if (!isTextMaxLengthField)
return configuration
return {
...configuration,
required: false,
}
})
}, [configurations])
}

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