mirror of
https://github.com/langgenius/dify.git
synced 2026-06-23 15:57:40 +08:00
Compare commits
7 Commits
deploy/dev
...
fix/add-al
| Author | SHA1 | Date | |
|---|---|---|---|
| 31f436a251 | |||
| 38b12106fb | |||
| a993998fdc | |||
| 1e324233f4 | |||
| f92c6e68e2 | |||
| 8ff692f524 | |||
| 0cff18e59b |
@ -768,6 +768,7 @@ EVENT_BUS_REDIS_CHANNEL_TYPE=pubsub
|
||||
# Whether to use Redis cluster mode while use redis as event bus.
|
||||
# It's highly recommended to enable this for large deployments.
|
||||
EVENT_BUS_REDIS_USE_CLUSTERS=false
|
||||
EVENT_BUS_LISTENER_JOIN_TIMEOUT_MS=2000
|
||||
|
||||
# Whether to Enable human input timeout check task
|
||||
ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true
|
||||
|
||||
@ -2,6 +2,7 @@ from typing import Literal, Protocol, cast
|
||||
from urllib.parse import quote_plus, urlunparse
|
||||
|
||||
from pydantic import AliasChoices, Field
|
||||
from pydantic.types import NonNegativeInt
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
@ -70,6 +71,24 @@ class RedisPubSubConfig(BaseSettings):
|
||||
default=600,
|
||||
)
|
||||
|
||||
PUBSUB_LISTENER_JOIN_TIMEOUT_MS: NonNegativeInt = Field(
|
||||
validation_alias=AliasChoices("EVENT_BUS_LISTENER_JOIN_TIMEOUT_MS", "PUBSUB_LISTENER_JOIN_TIMEOUT_MS"),
|
||||
description=(
|
||||
"Maximum time (milliseconds) that ``Subscription.close()`` waits for its listener thread to "
|
||||
"finish before returning. Bounds the tail latency between a terminal event being delivered to "
|
||||
"an SSE client and the response stream actually closing.\n\n"
|
||||
"The listener thread blocks on a polling read (XREAD BLOCK for streams, get_message timeout "
|
||||
"for pubsub/sharded) with a fixed 1s window, so close() naturally has to wait up to ~1s for "
|
||||
"the thread to notice the subscription was closed. Setting this lower (e.g. 100) lets close() "
|
||||
"return promptly while the daemon listener thread cleans itself up on the next poll "
|
||||
"boundary - safe because the listener holds no critical state and exits within one poll "
|
||||
"window. Setting it higher (e.g. 5000) gives the listener more grace before close() gives up "
|
||||
"and logs a warning. Default 2000ms preserves the pre-change behaviour.\n\n"
|
||||
"Also accepts ENV: EVENT_BUS_LISTENER_JOIN_TIMEOUT_MS."
|
||||
),
|
||||
default=2000,
|
||||
)
|
||||
|
||||
def _build_default_pubsub_url(self) -> str:
|
||||
defaults = _redis_defaults(self)
|
||||
if not defaults.REDIS_HOST or not defaults.REDIS_PORT:
|
||||
|
||||
@ -83,7 +83,7 @@ class ApiKeyAuthDataSourceBinding(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_CREATE, resource_required=False)
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
|
||||
@console_ns.expect(console_ns.models[ApiKeyAuthBindingPayload.__name__])
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str):
|
||||
|
||||
@ -222,7 +222,7 @@ class DatasourceAuth(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.CREDENTIAL_CREATE, resource_required=False)
|
||||
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str, provider_id: str):
|
||||
payload = DatasourceCredentialPayload.model_validate(console_ns.payload or {})
|
||||
|
||||
@ -80,10 +80,6 @@ class SnippetDraftConfigResponse(BaseModel):
|
||||
parallel_depth_limit: int
|
||||
|
||||
|
||||
class SnippetWorkflowPaginationResponse(WorkflowPaginationResponse):
|
||||
items: list[SnippetWorkflowResponse]
|
||||
|
||||
|
||||
register_schema_models(
|
||||
console_ns,
|
||||
SnippetDraftSyncPayload,
|
||||
@ -102,7 +98,6 @@ register_response_schema_models(
|
||||
SimpleResultResponse,
|
||||
SnippetDraftConfigResponse,
|
||||
SnippetWorkflowResponse,
|
||||
SnippetWorkflowPaginationResponse,
|
||||
WorkflowPublishResponse,
|
||||
WorkflowPaginationResponse,
|
||||
WorkflowRestoreResponse,
|
||||
@ -334,7 +329,7 @@ class SnippetPublishedAllWorkflowApi(Resource):
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Published workflows retrieved successfully",
|
||||
console_ns.models[SnippetWorkflowPaginationResponse.__name__],
|
||||
console_ns.models[WorkflowPaginationResponse.__name__],
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@ -355,7 +350,7 @@ class SnippetPublishedAllWorkflowApi(Resource):
|
||||
limit=args.limit,
|
||||
)
|
||||
|
||||
response = SnippetWorkflowPaginationResponse.model_validate(
|
||||
return WorkflowPaginationResponse.model_validate(
|
||||
{
|
||||
"items": workflows,
|
||||
"page": args.page,
|
||||
@ -364,9 +359,6 @@ class SnippetPublishedAllWorkflowApi(Resource):
|
||||
},
|
||||
from_attributes=True,
|
||||
).model_dump(mode="json")
|
||||
for item in response["items"]:
|
||||
item["input_fields"] = snippet.input_fields_list
|
||||
return response
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/<string:workflow_id>/restore")
|
||||
|
||||
@ -5,7 +5,6 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
from configs import dify_config
|
||||
from extensions.ext_database import db
|
||||
from libs.login import current_account_with_tenant
|
||||
from models.account import TenantPluginPermission
|
||||
@ -18,9 +17,6 @@ def plugin_permission_required(
|
||||
def interceptor[**P, R](view: Callable[P, R]) -> Callable[P, R]:
|
||||
@wraps(view)
|
||||
def decorated(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||
if dify_config.RBAC_ENABLED:
|
||||
return view(*args, **kwargs)
|
||||
|
||||
current_user, current_tenant_id = current_account_with_tenant()
|
||||
user = current_user
|
||||
tenant_id = current_tenant_id
|
||||
|
||||
@ -169,7 +169,7 @@ class ModelProviderCredentialApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_CREATE, resource_required=False)
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str, provider: str):
|
||||
@ -244,7 +244,7 @@ class ModelProviderCredentialSwitchApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_USE, resource_required=False)
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str, provider: str):
|
||||
@ -326,7 +326,7 @@ class PreferredProviderTypeUpdateApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_USE, resource_required=False)
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, provider: str):
|
||||
|
||||
@ -395,7 +395,7 @@ class ModelProviderModelCredentialApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_CREATE, resource_required=False)
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, provider: str):
|
||||
@ -481,7 +481,7 @@ class ModelProviderModelCredentialSwitchApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_USE, resource_required=False)
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str, provider: str):
|
||||
|
||||
@ -469,7 +469,6 @@ class PluginDebuggingKeyApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_DEBUG, resource_required=False)
|
||||
@plugin_permission_required(debug_required=True)
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str):
|
||||
@ -615,7 +614,6 @@ class PluginUploadFromPkgApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
|
||||
@plugin_permission_required(install_required=True)
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str):
|
||||
@ -636,7 +634,6 @@ class PluginUploadFromGithubApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
|
||||
@plugin_permission_required(install_required=True)
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str):
|
||||
@ -656,7 +653,6 @@ class PluginUploadFromBundleApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
|
||||
@plugin_permission_required(install_required=True)
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str):
|
||||
@ -677,7 +673,6 @@ class PluginInstallFromPkgApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
|
||||
@plugin_permission_required(install_required=True)
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str):
|
||||
@ -698,7 +693,6 @@ class PluginInstallFromGithubApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
|
||||
@plugin_permission_required(install_required=True)
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str):
|
||||
@ -725,7 +719,6 @@ class PluginInstallFromMarketplaceApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
|
||||
@plugin_permission_required(install_required=True)
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str):
|
||||
@ -746,7 +739,6 @@ class PluginFetchMarketplacePkgApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
|
||||
@plugin_permission_required(install_required=True)
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str):
|
||||
@ -772,7 +764,6 @@ class PluginFetchManifestApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
|
||||
@plugin_permission_required(install_required=True)
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str):
|
||||
@ -793,7 +784,6 @@ class PluginFetchInstallTasksApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
|
||||
@plugin_permission_required(install_required=True)
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str):
|
||||
@ -811,7 +801,6 @@ class PluginFetchInstallTaskApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
|
||||
@plugin_permission_required(install_required=True)
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, task_id: str):
|
||||
@ -827,7 +816,6 @@ class PluginDeleteInstallTaskApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
|
||||
@plugin_permission_required(install_required=True)
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, task_id: str):
|
||||
@ -843,7 +831,6 @@ class PluginDeleteAllInstallTaskItemsApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
|
||||
@plugin_permission_required(install_required=True)
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str):
|
||||
@ -859,7 +846,6 @@ class PluginDeleteInstallTaskItemApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
|
||||
@plugin_permission_required(install_required=True)
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, task_id: str, identifier: str):
|
||||
@ -876,7 +862,6 @@ class PluginUpgradeFromMarketplaceApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
|
||||
@plugin_permission_required(install_required=True)
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str):
|
||||
@ -899,7 +884,6 @@ class PluginUpgradeFromGithubApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
|
||||
@plugin_permission_required(install_required=True)
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str):
|
||||
@ -927,7 +911,6 @@ class PluginUninstallApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
|
||||
@plugin_permission_required(install_required=True)
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str):
|
||||
@ -1058,11 +1041,10 @@ class PluginChangeAutoUpgradeApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, user: Account):
|
||||
if not dify_config.RBAC_ENABLED and not user.is_admin_or_owner:
|
||||
if not user.is_admin_or_owner:
|
||||
raise Forbidden()
|
||||
|
||||
args = ParserAutoUpgradeChange.model_validate(console_ns.payload)
|
||||
@ -1115,7 +1097,6 @@ class PluginAutoUpgradeExcludePluginApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False)
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str):
|
||||
# exclude one single plugin
|
||||
|
||||
@ -971,7 +971,7 @@ class ToolBuiltinProviderSetDefaultApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_USE, resource_required=False)
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str, provider: str):
|
||||
@ -1070,7 +1070,6 @@ class ToolProviderMCPApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.MCP_MANAGE, resource_required=False)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, user: Account):
|
||||
@ -1126,7 +1125,6 @@ class ToolProviderMCPApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.MCP_MANAGE, resource_required=False)
|
||||
@with_current_tenant_id
|
||||
def put(self, current_tenant_id: str):
|
||||
payload = MCPProviderUpdatePayload.model_validate(console_ns.payload or {})
|
||||
@ -1180,7 +1178,6 @@ class ToolProviderMCPApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.MCP_MANAGE, resource_required=False)
|
||||
@with_current_tenant_id
|
||||
def delete(self, current_tenant_id: str):
|
||||
payload = MCPProviderDeletePayload.model_validate(console_ns.payload or {})
|
||||
@ -1199,7 +1196,6 @@ class ToolMCPAuthApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.MCP_MANAGE, resource_required=False)
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str):
|
||||
payload = MCPAuthPayload.model_validate(console_ns.payload or {})
|
||||
@ -1304,7 +1300,6 @@ class ToolMCPUpdateApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.MCP_MANAGE, resource_required=False)
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, provider_id: str):
|
||||
with sessionmaker(db.engine).begin() as session:
|
||||
|
||||
@ -22,35 +22,23 @@ class RBACPermission(StrEnum):
|
||||
|
||||
APP_VIEW_LAYOUT = "app_view_layout"
|
||||
APP_TEST_AND_RUN = "app_test_and_run"
|
||||
APP_PREVIEW = "app_preview"
|
||||
APP_CREATE_AND_MANAGEMENT = "app_create_and_management"
|
||||
APP_RELEASE_AND_VERSION = "app_release_and_version"
|
||||
APP_IMPORT_EXPORT_DSL = "app_import_export_dsl"
|
||||
APP_EDIT = "app_edit"
|
||||
APP_MONITOR = "app_monitor"
|
||||
APP_DELETE = "app_delete"
|
||||
APP_ACCESS_CONFIG = "app_access_config"
|
||||
|
||||
DATASET_PREVIEW = "dataset_preview"
|
||||
DATASET_READONLY = "dataset_readonly"
|
||||
DATASET_EDIT = "dataset_edit"
|
||||
DATASET_CREATE_AND_MANAGEMENT = "dataset_create_and_management"
|
||||
DATASET_PIPELINE_TEST = "dataset_pipeline_test"
|
||||
DATASET_DOCUMENT_DOWNLOAD = "dataset_document_download"
|
||||
DATASET_RETRIEVAL_RECALL = "dataset_retrieval_recall"
|
||||
DATASET_USE = "dataset_use"
|
||||
DATASET_DELETE_FILE = "dataset_delete_file"
|
||||
DATASET_PIPELINE_RELEASE = "dataset_pipeline_release"
|
||||
DATASET_DELETE = "dataset_delete"
|
||||
DATASET_ACCESS_CONFIG = "dataset_access_config"
|
||||
DATASET_API_KEY_MANAGE = "dataset_api_key_manage"
|
||||
DATASET_EXTERNAL_CONNECT = "dataset_external_connect"
|
||||
DATASET_IMPORT_EXPORT_DSL = "dataset_import_export_dsl"
|
||||
|
||||
WORKSPACE_MEMBER_MANAGE = "workspace_member_manage"
|
||||
WORKSPACE_ROLE_MANAGE = "workspace_role_manage"
|
||||
API_EXTENSION_MANAGE = "api_extension_manage"
|
||||
CUSTOMIZATION_MANAGE = "customization_manage"
|
||||
|
||||
SNIPPETS_CREATE_AND_MODIFY = "snippets_create_and_modify"
|
||||
SNIPPETS_MANAGE = "snippets_management"
|
||||
@ -61,7 +49,6 @@ class RBACPermission(StrEnum):
|
||||
PLUGIN_DEBUG = "plugin_debug"
|
||||
|
||||
CREDENTIAL_USE = "credential_use"
|
||||
CREDENTIAL_CREATE = "credential_create"
|
||||
CREDENTIAL_MANAGE = "credential_manage"
|
||||
|
||||
TOOL_MANAGE = "tool_manage"
|
||||
|
||||
@ -25,7 +25,7 @@ from extensions.redis_names import (
|
||||
serialize_redis_name_args,
|
||||
)
|
||||
from libs.broadcast_channel.channel import BroadcastChannel as BroadcastChannelProtocol
|
||||
from libs.broadcast_channel.redis.pubsub_channel import BroadcastChannel as RedisBroadcastChannel
|
||||
from libs.broadcast_channel.redis.channel import BroadcastChannel as RedisBroadcastChannel
|
||||
from libs.broadcast_channel.redis.sharded_channel import ShardedRedisBroadcastChannel
|
||||
from libs.broadcast_channel.redis.streams_channel import StreamsBroadcastChannel
|
||||
|
||||
@ -457,14 +457,16 @@ def init_app(app: DifyApp):
|
||||
|
||||
def get_pubsub_broadcast_channel() -> BroadcastChannelProtocol:
|
||||
assert _pubsub_redis_client is not None, "PubSub redis Client should be initialized here."
|
||||
join_timeout_ms = dify_config.PUBSUB_LISTENER_JOIN_TIMEOUT_MS
|
||||
if dify_config.PUBSUB_REDIS_CHANNEL_TYPE == "sharded":
|
||||
return ShardedRedisBroadcastChannel(_pubsub_redis_client)
|
||||
return ShardedRedisBroadcastChannel(_pubsub_redis_client, join_timeout_ms=join_timeout_ms)
|
||||
if dify_config.PUBSUB_REDIS_CHANNEL_TYPE == "streams":
|
||||
return StreamsBroadcastChannel(
|
||||
_pubsub_redis_client,
|
||||
retention_seconds=dify_config.PUBSUB_STREAMS_RETENTION_SECONDS,
|
||||
join_timeout_ms=join_timeout_ms,
|
||||
)
|
||||
return RedisBroadcastChannel(_pubsub_redis_client)
|
||||
return RedisBroadcastChannel(_pubsub_redis_client, join_timeout_ms=join_timeout_ms)
|
||||
|
||||
|
||||
def redis_fallback[T](default_return: T | None = None): # type: ignore
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
from .pubsub_channel import BroadcastChannel
|
||||
from .channel import BroadcastChannel
|
||||
from .sharded_channel import ShardedRedisBroadcastChannel
|
||||
|
||||
__all__ = ["BroadcastChannel", "ShardedRedisBroadcastChannel"]
|
||||
|
||||
@ -7,7 +7,6 @@ from typing import Any, Self, override
|
||||
|
||||
from libs.broadcast_channel.channel import Subscription
|
||||
from libs.broadcast_channel.exc import SubscriptionClosedError
|
||||
from libs.broadcast_channel.signals import SIG_CLOSE
|
||||
from redis import Redis, RedisCluster
|
||||
from redis.client import PubSub
|
||||
|
||||
@ -27,6 +26,8 @@ class RedisSubscriptionBase(Subscription):
|
||||
client: Redis | RedisCluster,
|
||||
pubsub: PubSub,
|
||||
topic: str,
|
||||
*,
|
||||
join_timeout_ms: int = 2000,
|
||||
):
|
||||
# The _pubsub is None only if the subscription is closed.
|
||||
self._client = client
|
||||
@ -38,6 +39,11 @@ class RedisSubscriptionBase(Subscription):
|
||||
self._listener_thread: threading.Thread | None = None
|
||||
self._start_lock = threading.Lock()
|
||||
self._started = False
|
||||
# Max time close() will wait for the listener thread to finish before
|
||||
# returning. Bounds SSE close tail latency. The listener is a daemon
|
||||
# and exits on its own within one poll window (~1s), so a low value
|
||||
# here just means close() returns sooner without breaking anything.
|
||||
self._join_timeout_ms = max(int(join_timeout_ms or 0), 0)
|
||||
|
||||
def _start_if_needed(self) -> None:
|
||||
"""Start the subscription if not already started."""
|
||||
@ -84,11 +90,6 @@ class RedisSubscriptionBase(Subscription):
|
||||
if raw_message is None:
|
||||
continue
|
||||
|
||||
# If close() sent a control event to unblock us, exit immediately
|
||||
# without processing any message — the subscription is shutting down.
|
||||
if self._closed.is_set():
|
||||
break
|
||||
|
||||
if raw_message.get("type") != self._get_message_type():
|
||||
continue
|
||||
|
||||
@ -118,8 +119,6 @@ class RedisSubscriptionBase(Subscription):
|
||||
continue
|
||||
|
||||
self._enqueue_message(payload_bytes)
|
||||
if payload_bytes == SIG_CLOSE:
|
||||
break
|
||||
|
||||
_logger.debug("%s listener thread stopped for channel %s", self._get_subscription_type().title(), self._topic)
|
||||
try:
|
||||
@ -213,16 +212,13 @@ class RedisSubscriptionBase(Subscription):
|
||||
return
|
||||
|
||||
self._closed.set()
|
||||
# Send a control event on the same Redis channel to unblock the
|
||||
self._publish_close_event()
|
||||
|
||||
# NOTE: PubSub is not thread-safe. More specifically, the `PubSub.close` method and the
|
||||
# message retrieval method should NOT be called concurrently.
|
||||
#
|
||||
# Due to the restriction above, the PubSub cleanup logic happens inside the consumer thread.
|
||||
listener = self._listener_thread
|
||||
if listener is not None:
|
||||
listener.join(timeout=2)
|
||||
listener.join(timeout=self._join_timeout_ms / 1000.0)
|
||||
self._listener_thread = None
|
||||
|
||||
# Abstract methods to be implemented by subclasses
|
||||
@ -230,15 +226,6 @@ class RedisSubscriptionBase(Subscription):
|
||||
"""Return the subscription type (e.g., 'regular' or 'sharded')."""
|
||||
raise NotImplementedError
|
||||
|
||||
def _publish_close_event(self) -> None:
|
||||
"""Publish a control event on the Redis channel to unblock the listener.
|
||||
|
||||
This is called by close() after setting _closed. The subclass should
|
||||
publish an empty message on the same topic so that a blocking
|
||||
get_message() call in the listener thread returns promptly.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def _subscribe(self) -> None:
|
||||
"""Subscribe to the Redis topic using the appropriate command."""
|
||||
raise NotImplementedError
|
||||
|
||||
@ -1,17 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, override
|
||||
|
||||
from extensions.redis_names import serialize_redis_name
|
||||
from libs.broadcast_channel.channel import Producer, Subscriber, Subscription
|
||||
from libs.broadcast_channel.signals import SIG_CLOSE
|
||||
from redis import Redis, RedisCluster
|
||||
|
||||
from ._subscription import RedisSubscriptionBase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BroadcastChannel:
|
||||
"""
|
||||
@ -26,11 +22,16 @@ class BroadcastChannel:
|
||||
def __init__(
|
||||
self,
|
||||
redis_client: Redis | RedisCluster,
|
||||
*,
|
||||
join_timeout_ms: int = 2000,
|
||||
):
|
||||
self._client = redis_client
|
||||
# See `RedisSubscriptionBase._join_timeout_ms`: how long close()
|
||||
# waits for the listener thread before returning.
|
||||
self._join_timeout_ms = max(int(join_timeout_ms or 0), 0)
|
||||
|
||||
def topic(self, topic: str) -> Topic:
|
||||
return Topic(self._client, topic)
|
||||
return Topic(self._client, topic, join_timeout_ms=self._join_timeout_ms)
|
||||
|
||||
|
||||
class Topic:
|
||||
@ -38,10 +39,13 @@ class Topic:
|
||||
self,
|
||||
redis_client: Redis | RedisCluster,
|
||||
topic: str,
|
||||
*,
|
||||
join_timeout_ms: int = 2000,
|
||||
):
|
||||
self._client = redis_client
|
||||
self._topic = topic
|
||||
self._redis_topic = serialize_redis_name(topic)
|
||||
self._join_timeout_ms = max(int(join_timeout_ms or 0), 0)
|
||||
|
||||
def as_producer(self) -> Producer:
|
||||
return self
|
||||
@ -57,6 +61,7 @@ class Topic:
|
||||
client=self._client,
|
||||
pubsub=self._client.pubsub(),
|
||||
topic=self._redis_topic,
|
||||
join_timeout_ms=self._join_timeout_ms,
|
||||
)
|
||||
|
||||
|
||||
@ -67,13 +72,6 @@ class _RedisSubscription(RedisSubscriptionBase):
|
||||
def _get_subscription_type(self) -> str:
|
||||
return "regular"
|
||||
|
||||
@override
|
||||
def _publish_close_event(self) -> None:
|
||||
try:
|
||||
self._client.publish(self._topic, SIG_CLOSE)
|
||||
except Exception:
|
||||
logger.exception("failed to publish close event")
|
||||
|
||||
@override
|
||||
def _subscribe(self) -> None:
|
||||
assert self._pubsub is not None
|
||||
@ -1,17 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, override
|
||||
|
||||
from extensions.redis_names import serialize_redis_name
|
||||
from libs.broadcast_channel.channel import Producer, Subscriber, Subscription
|
||||
from libs.broadcast_channel.signals import SIG_CLOSE
|
||||
from redis import Redis, RedisCluster
|
||||
|
||||
from ._subscription import RedisSubscriptionBase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ShardedRedisBroadcastChannel:
|
||||
"""
|
||||
@ -24,11 +20,14 @@ class ShardedRedisBroadcastChannel:
|
||||
def __init__(
|
||||
self,
|
||||
redis_client: Redis | RedisCluster,
|
||||
*,
|
||||
join_timeout_ms: int = 2000,
|
||||
):
|
||||
self._client = redis_client
|
||||
self._join_timeout_ms = max(int(join_timeout_ms or 0), 0)
|
||||
|
||||
def topic(self, topic: str) -> ShardedTopic:
|
||||
return ShardedTopic(self._client, topic)
|
||||
return ShardedTopic(self._client, topic, join_timeout_ms=self._join_timeout_ms)
|
||||
|
||||
|
||||
class ShardedTopic:
|
||||
@ -36,10 +35,13 @@ class ShardedTopic:
|
||||
self,
|
||||
redis_client: Redis | RedisCluster,
|
||||
topic: str,
|
||||
*,
|
||||
join_timeout_ms: int = 2000,
|
||||
):
|
||||
self._client = redis_client
|
||||
self._topic = topic
|
||||
self._redis_topic = serialize_redis_name(topic)
|
||||
self._join_timeout_ms = max(int(join_timeout_ms or 0), 0)
|
||||
|
||||
def as_producer(self) -> Producer:
|
||||
return self
|
||||
@ -55,6 +57,7 @@ class ShardedTopic:
|
||||
client=self._client,
|
||||
pubsub=self._client.pubsub(),
|
||||
topic=self._redis_topic,
|
||||
join_timeout_ms=self._join_timeout_ms,
|
||||
)
|
||||
|
||||
|
||||
@ -65,13 +68,6 @@ class _RedisShardedSubscription(RedisSubscriptionBase):
|
||||
def _get_subscription_type(self) -> str:
|
||||
return "sharded"
|
||||
|
||||
@override
|
||||
def _publish_close_event(self) -> None:
|
||||
try:
|
||||
self._client.spublish(self._topic, SIG_CLOSE) # type: ignore[attr-defined,union-attr]
|
||||
except Exception:
|
||||
logger.exception("failed to publish close event")
|
||||
|
||||
@override
|
||||
def _subscribe(self) -> None:
|
||||
assert self._pubsub is not None
|
||||
|
||||
@ -9,7 +9,6 @@ from typing import Self, override
|
||||
from extensions.redis_names import serialize_redis_name
|
||||
from libs.broadcast_channel.channel import Producer, Subscriber, Subscription
|
||||
from libs.broadcast_channel.exc import SubscriptionClosedError
|
||||
from libs.broadcast_channel.signals import SIG_CLOSE
|
||||
from redis import Redis, RedisCluster
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -30,15 +29,20 @@ class StreamsBroadcastChannel:
|
||||
redis_client: Redis | RedisCluster,
|
||||
*,
|
||||
retention_seconds: int = 600,
|
||||
join_timeout_ms: int = 2000,
|
||||
):
|
||||
self._client = redis_client
|
||||
self._retention_seconds = max(int(retention_seconds or 0), 0)
|
||||
# Max time close() will wait for the listener thread to finish.
|
||||
# See `_StreamsSubscription._join_timeout_ms` for the rationale.
|
||||
self._join_timeout_ms = max(int(join_timeout_ms or 0), 0)
|
||||
|
||||
def topic(self, topic: str) -> StreamsTopic:
|
||||
return StreamsTopic(
|
||||
self._client,
|
||||
topic,
|
||||
retention_seconds=self._retention_seconds,
|
||||
join_timeout_ms=self._join_timeout_ms,
|
||||
)
|
||||
|
||||
|
||||
@ -49,11 +53,13 @@ class StreamsTopic:
|
||||
topic: str,
|
||||
*,
|
||||
retention_seconds: int = 600,
|
||||
join_timeout_ms: int = 2000,
|
||||
):
|
||||
self._client = redis_client
|
||||
self._topic = topic
|
||||
self._key = serialize_redis_name(f"stream:{topic}")
|
||||
self._retention_seconds = retention_seconds
|
||||
self._join_timeout_ms = max(int(join_timeout_ms or 0), 0)
|
||||
self.max_length = 5000
|
||||
|
||||
def as_producer(self) -> Producer:
|
||||
@ -71,15 +77,23 @@ class StreamsTopic:
|
||||
return self
|
||||
|
||||
def subscribe(self) -> Subscription:
|
||||
return _StreamsSubscription(self._client, self._key)
|
||||
return _StreamsSubscription(self._client, self._key, join_timeout_ms=self._join_timeout_ms)
|
||||
|
||||
|
||||
class _StreamsSubscription(Subscription):
|
||||
_SENTINEL = object()
|
||||
|
||||
def __init__(self, client: Redis | RedisCluster, key: str):
|
||||
def __init__(self, client: Redis | RedisCluster, key: str, *, join_timeout_ms: int = 2000):
|
||||
self._client = client
|
||||
self._key = key
|
||||
# Max time close() will wait for the listener thread to finish before
|
||||
# returning. Bounds SSE close tail latency: the listener blocks on
|
||||
# XREAD with BLOCK=1000ms, so close() naturally waits up to ~1s for
|
||||
# the thread to notice _closed. Setting this lower lets close()
|
||||
# return promptly while the daemon listener exits on its own within
|
||||
# one BLOCK window - safe because the listener holds no critical
|
||||
# state. ``0`` means close() does not wait at all.
|
||||
self._join_timeout_ms = max(int(join_timeout_ms or 0), 0)
|
||||
|
||||
self._queue: queue.Queue[object] = queue.Queue()
|
||||
|
||||
@ -92,6 +106,7 @@ class _StreamsSubscription(Subscription):
|
||||
# reading and writing the _listener / `_closed` attribute.
|
||||
self._lock = threading.Lock()
|
||||
self._closed: bool = False
|
||||
# self._closed = threading.Event()
|
||||
self._listener: threading.Thread | None = None
|
||||
|
||||
def _listen(self) -> None:
|
||||
@ -129,8 +144,6 @@ class _StreamsSubscription(Subscription):
|
||||
case bytes() | bytearray():
|
||||
data_bytes = bytes(data)
|
||||
if data_bytes is not None:
|
||||
if data_bytes == SIG_CLOSE:
|
||||
break
|
||||
self._queue.put_nowait(data_bytes)
|
||||
last_id = entry_id
|
||||
finally:
|
||||
@ -190,13 +203,6 @@ class _StreamsSubscription(Subscription):
|
||||
assert isinstance(item, (bytes, bytearray)), "Unexpected item type in stream queue"
|
||||
return bytes(item)
|
||||
|
||||
def _publish_close_event(self) -> None:
|
||||
"""Publish an empty message to the stream to unblock the listener's xread."""
|
||||
try:
|
||||
self._client.xadd(self._key, {b"data": SIG_CLOSE})
|
||||
except Exception:
|
||||
logger.exception("failed to publish close event")
|
||||
|
||||
@override
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
@ -206,17 +212,16 @@ class _StreamsSubscription(Subscription):
|
||||
listener = self._listener
|
||||
if listener is not None:
|
||||
self._listener = None
|
||||
|
||||
if listener is not None:
|
||||
self._publish_close_event()
|
||||
|
||||
# We close the listener outside of the with block to avoid holding the
|
||||
# lock for a long time.
|
||||
if listener is not None and listener.is_alive():
|
||||
listener.join(timeout=2)
|
||||
listener.join(timeout=self._join_timeout_ms / 1000.0)
|
||||
if listener.is_alive():
|
||||
logger.debug(
|
||||
"Streams subscription listener for key %s did not stop after join; "
|
||||
"Streams subscription listener for key %s did not stop within %dms; "
|
||||
"daemon thread will exit on its own within one poll window.",
|
||||
self._key,
|
||||
self._join_timeout_ms,
|
||||
)
|
||||
|
||||
# Context manager helpers
|
||||
|
||||
@ -1 +0,0 @@
|
||||
SIG_CLOSE = b"__closed__"
|
||||
@ -182,10 +182,6 @@ class EnterpriseRequest(BaseRequest):
|
||||
inner_headers: dict[str, str] = {INNER_TENANT_ID_HEADER: tenant_id}
|
||||
if account_id:
|
||||
inner_headers[INNER_ACCOUNT_ID_HEADER] = account_id
|
||||
|
||||
if not cls.base_url.startswith("http") or not cls.base_url.startswith("https") or not cls.base_url:
|
||||
raise ValueError("ENTERPRISE_RBAC_API_URL is required when RBAC_ENABLED=true")
|
||||
|
||||
url = f"{cls.rbac_base_url}{endpoint}"
|
||||
mounts = cls._build_mounts()
|
||||
|
||||
|
||||
@ -312,7 +312,6 @@ _LEGACY_WORKSPACE_OWNER_KEYS: list[str] = [
|
||||
"plugin.manage",
|
||||
"plugin.debug",
|
||||
"credential.use",
|
||||
"credential.create",
|
||||
"credential.manage",
|
||||
"billing.view",
|
||||
"billing.subscription.manage",
|
||||
@ -345,7 +344,6 @@ _LEGACY_WORKSPACE_ADMIN_KEYS: list[str] = [
|
||||
"plugin.manage",
|
||||
"plugin.debug",
|
||||
"credential.use",
|
||||
"credential.create",
|
||||
"credential.manage",
|
||||
"billing.view",
|
||||
"billing.subscription.manage",
|
||||
|
||||
@ -20,7 +20,7 @@ from testcontainers.redis import RedisContainer
|
||||
|
||||
from libs.broadcast_channel.channel import BroadcastChannel, Subscription, Topic
|
||||
from libs.broadcast_channel.exc import SubscriptionClosedError
|
||||
from libs.broadcast_channel.redis.pubsub_channel import BroadcastChannel as RedisBroadcastChannel
|
||||
from libs.broadcast_channel.redis.channel import BroadcastChannel as RedisBroadcastChannel
|
||||
|
||||
|
||||
class TestRedisBroadcastChannelIntegration:
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from inspect import unwrap
|
||||
from types import SimpleNamespace
|
||||
@ -200,54 +199,6 @@ def test_default_block_configs_delegates_to_service(app: Flask, monkeypatch: pyt
|
||||
get_default_block_configs.assert_called_once()
|
||||
|
||||
|
||||
def test_list_published_snippet_workflows_includes_input_fields(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
workflow = SimpleNamespace(
|
||||
id="workflow-1",
|
||||
graph_dict={"nodes": [], "edges": []},
|
||||
features_dict={},
|
||||
unique_hash="hash-1",
|
||||
version="2024-01-01 00:00:00",
|
||||
marked_name="",
|
||||
marked_comment="",
|
||||
created_by_account=None,
|
||||
created_at=datetime(2024, 1, 1),
|
||||
updated_by_account=None,
|
||||
updated_at=datetime(2024, 1, 1),
|
||||
tool_published=False,
|
||||
environment_variables=[],
|
||||
conversation_variables=[],
|
||||
rag_pipeline_variables=[],
|
||||
)
|
||||
input_fields = [{"variable": "query", "type": "text"}]
|
||||
snippet = _snippet(input_fields=json.dumps(input_fields))
|
||||
|
||||
class SessionContext:
|
||||
def __init__(self, engine):
|
||||
self.engine = engine
|
||||
|
||||
def __enter__(self):
|
||||
return Mock()
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(snippet_workflow_module, "Session", SessionContext)
|
||||
monkeypatch.setattr(snippet_workflow_module, "db", SimpleNamespace(engine=object()))
|
||||
monkeypatch.setattr(
|
||||
snippet_workflow_module,
|
||||
"SnippetService",
|
||||
lambda: SimpleNamespace(get_all_published_workflows=Mock(return_value=([workflow], False))),
|
||||
)
|
||||
|
||||
api = snippet_workflow_module.SnippetPublishedAllWorkflowApi()
|
||||
handler = unwrap(api.get)
|
||||
|
||||
with app.test_request_context("/snippets/snippet-1/workflows?page=1&limit=20"):
|
||||
response = handler(api, snippet=snippet)
|
||||
|
||||
assert response["items"][0]["input_fields"] == input_fields
|
||||
|
||||
|
||||
def test_restore_published_snippet_workflow_to_draft_success(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
workflow = SimpleNamespace(
|
||||
unique_hash="restored-hash",
|
||||
|
||||
@ -2,7 +2,7 @@ import pytest
|
||||
|
||||
from configs import dify_config
|
||||
from extensions import ext_redis
|
||||
from libs.broadcast_channel.redis.pubsub_channel import BroadcastChannel as RedisBroadcastChannel
|
||||
from libs.broadcast_channel.redis.channel import BroadcastChannel as RedisBroadcastChannel
|
||||
from libs.broadcast_channel.redis.sharded_channel import ShardedRedisBroadcastChannel
|
||||
|
||||
|
||||
|
||||
@ -18,10 +18,13 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from libs.broadcast_channel.exc import BroadcastChannelError, SubscriptionClosedError
|
||||
from libs.broadcast_channel.redis.pubsub_channel import (
|
||||
from libs.broadcast_channel.redis.channel import (
|
||||
BroadcastChannel as RedisBroadcastChannel,
|
||||
)
|
||||
from libs.broadcast_channel.redis.pubsub_channel import Topic, _RedisSubscription
|
||||
from libs.broadcast_channel.redis.channel import (
|
||||
Topic,
|
||||
_RedisSubscription,
|
||||
)
|
||||
from libs.broadcast_channel.redis.sharded_channel import (
|
||||
ShardedRedisBroadcastChannel,
|
||||
ShardedTopic,
|
||||
|
||||
@ -77,28 +77,11 @@ class FailExpireRedis(FakeStreamsRedis):
|
||||
|
||||
|
||||
class BlockingRedis:
|
||||
"""A Redis mock whose xread blocks until a control event is xadd-ed."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._release = threading.Event()
|
||||
self._store: dict[str, list[tuple[str, dict]]] = {}
|
||||
self._next_id: dict[str, int] = {}
|
||||
|
||||
def xadd(self, key: str, fields: dict[str, Any], *, maxlen: int | None = None) -> str:
|
||||
n = self._next_id.get(key, 0) + 1
|
||||
self._next_id[key] = n
|
||||
entry_id = f"{n}-0"
|
||||
self._store.setdefault(key, []).append((entry_id, fields))
|
||||
self._release.set() # Wake up any blocked xread
|
||||
return entry_id
|
||||
|
||||
def xread(self, streams: dict[str, Any], block: int | None = None, count: int | None = None):
|
||||
self._release.wait(timeout=block / 1000.0 if block else None)
|
||||
key = next(iter(streams))
|
||||
entries = self._store.get(key, [])
|
||||
if entries:
|
||||
self._store[key] = [] # Consume entries
|
||||
return [(key, entries)]
|
||||
return []
|
||||
|
||||
def release(self) -> None:
|
||||
@ -193,6 +176,48 @@ class TestStreamsBroadcastChannel:
|
||||
assert topic.as_producer() is topic
|
||||
assert topic.as_subscriber() is topic
|
||||
|
||||
def test_join_timeout_ms_propagates_from_channel_to_subscription(self, fake_redis: FakeStreamsRedis):
|
||||
channel = StreamsBroadcastChannel(fake_redis, retention_seconds=60, join_timeout_ms=150)
|
||||
topic = channel.topic("join-timeout-prop")
|
||||
|
||||
assert topic._join_timeout_ms == 150
|
||||
|
||||
sub = topic.subscribe()
|
||||
try:
|
||||
assert sub._join_timeout_ms == 150
|
||||
finally:
|
||||
sub.close()
|
||||
|
||||
def test_join_timeout_ms_defaults_to_2000(self, fake_redis: FakeStreamsRedis):
|
||||
channel = StreamsBroadcastChannel(fake_redis, retention_seconds=60)
|
||||
topic = channel.topic("join-timeout-default")
|
||||
|
||||
assert topic._join_timeout_ms == 2000
|
||||
|
||||
def test_small_join_timeout_makes_close_return_promptly(self, fake_redis: FakeStreamsRedis):
|
||||
"""close() should respect the configured join timeout.
|
||||
|
||||
Regression test for SSE close tail latency: when an idle listener is
|
||||
blocked on its poll cycle, close() with a small join_timeout_ms must
|
||||
not wait for the full poll window. The orphaned daemon listener
|
||||
cleans itself up later.
|
||||
"""
|
||||
channel = StreamsBroadcastChannel(fake_redis, retention_seconds=60, join_timeout_ms=50)
|
||||
topic = channel.topic("join-timeout-prompt-close")
|
||||
sub = topic.subscribe()
|
||||
|
||||
# Drive listener startup so the thread is actually blocked in xread.
|
||||
assert sub.receive(timeout=0.05) is None
|
||||
time.sleep(0.05)
|
||||
|
||||
started = time.monotonic()
|
||||
sub.close()
|
||||
elapsed = time.monotonic() - started
|
||||
|
||||
# 50ms timeout + scheduling slack; pick a ceiling well under the
|
||||
# default poll window (1000ms) to make the regression meaningful.
|
||||
assert elapsed < 0.5, f"close() took {elapsed:.3f}s; expected prompt return"
|
||||
|
||||
def test_publish_logs_warning_when_expire_fails(self, caplog: pytest.LogCaptureFixture):
|
||||
channel = StreamsBroadcastChannel(FailExpireRedis(), retention_seconds=60)
|
||||
topic = channel.topic("expire-warning")
|
||||
@ -359,32 +384,40 @@ class TestStreamsSubscription:
|
||||
|
||||
assert next(iter(subscription)) == b"event"
|
||||
|
||||
def test_control_event_unblocks_listener_for_prompt_close(self):
|
||||
"""close() returns promptly because the control event (xadd) unblocks
|
||||
the listener from its blocking xread call.
|
||||
def test_close_logs_debug_when_listener_does_not_stop_in_time(
|
||||
self,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
):
|
||||
"""When a low join_timeout elapses with the listener still alive,
|
||||
close() should log at DEBUG (not WARNING) - with a deliberately small
|
||||
timeout this is expected, not anomalous; the orphaned daemon thread
|
||||
cleans itself up on the next poll boundary.
|
||||
"""
|
||||
blocking_redis = BlockingRedis()
|
||||
subscription = _StreamsSubscription(blocking_redis, "stream:prompt-close")
|
||||
import logging
|
||||
|
||||
blocking_redis = BlockingRedis()
|
||||
subscription = _StreamsSubscription(blocking_redis, "stream:slow-close")
|
||||
|
||||
# Drive listener startup so the thread is blocked in xread.
|
||||
subscription._start_if_needed()
|
||||
listener = subscription._listener
|
||||
assert listener is not None
|
||||
assert listener.is_alive()
|
||||
|
||||
started = time.monotonic()
|
||||
subscription.close()
|
||||
elapsed = time.monotonic() - started
|
||||
original_join = listener.join
|
||||
original_is_alive = listener.is_alive
|
||||
|
||||
# The control event (xadd) wakes up xread immediately, so close()
|
||||
# should return well under 1s (the xread BLOCK timeout).
|
||||
assert elapsed < 0.5, f"close() took {elapsed:.3f}s; expected prompt return via control event"
|
||||
def delayed_join(timeout: float | None = None) -> None:
|
||||
original_join(0.01)
|
||||
|
||||
def test_control_event_not_sent_when_listener_not_started(self):
|
||||
"""close() should not fail when the listener was never started."""
|
||||
subscription = _StreamsSubscription(FakeStreamsRedis(), "stream:no-listener")
|
||||
subscription.close()
|
||||
listener.join = delayed_join # type: ignore[method-assign]
|
||||
listener.is_alive = lambda: True # type: ignore[method-assign]
|
||||
|
||||
assert subscription._listener is None
|
||||
with pytest.raises(SubscriptionClosedError):
|
||||
subscription.receive(timeout=0.01)
|
||||
try:
|
||||
with caplog.at_level(logging.DEBUG, logger="libs.broadcast_channel.redis.streams_channel"):
|
||||
subscription.close()
|
||||
assert "did not stop within" in caplog.text
|
||||
assert "daemon thread will exit on its own" in caplog.text
|
||||
finally:
|
||||
listener.join = original_join # type: ignore[method-assign]
|
||||
listener.is_alive = original_is_alive # type: ignore[method-assign]
|
||||
blocking_redis.release()
|
||||
original_join(timeout=1)
|
||||
|
||||
@ -109,7 +109,7 @@ def _patch_get_channel_streams(monkeypatch: pytest.MonkeyPatch):
|
||||
|
||||
@pytest.fixture
|
||||
def _patch_get_channel_pubsub(monkeypatch: pytest.MonkeyPatch):
|
||||
from libs.broadcast_channel.redis.pubsub_channel import BroadcastChannel as RedisBroadcastChannel
|
||||
from libs.broadcast_channel.redis.channel import BroadcastChannel as RedisBroadcastChannel
|
||||
|
||||
store: dict[str, deque[bytes]] = defaultdict(deque)
|
||||
client = _FakeRedisClient(store)
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@langgenius/difyctl",
|
||||
"type": "module",
|
||||
"version": "0.1.0-rc.1",
|
||||
"version": "0.1.0-alpha",
|
||||
"description": "Dify command-line interface",
|
||||
"difyctl": {
|
||||
"channel": "rc",
|
||||
"channel": "alpha",
|
||||
"compat": {
|
||||
"minDify": "1.14.0",
|
||||
"minDify": "1.15.0",
|
||||
"maxDify": "1.15.0"
|
||||
},
|
||||
"release": {
|
||||
|
||||
@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
export const BUILD_CHANNELS = ['dev', 'rc', 'stable'] as const
|
||||
export const BUILD_CHANNELS = ['dev', 'alpha', 'rc', 'edge', 'stable'] as const
|
||||
export type BuildChannel = (typeof BUILD_CHANNELS)[number]
|
||||
|
||||
export type BuildInfo = {
|
||||
|
||||
@ -26,6 +26,7 @@ const SEMVER_CORE_LEN = 3
|
||||
// Add channels here: { name, prerelease, versionForm }.
|
||||
const CHANNELS = [
|
||||
{ name: 'stable', prerelease: false, versionForm: /^\d+\.\d+\.\d+(\+[0-9A-Z.-]+)?$/i },
|
||||
{ name: 'alpha', prerelease: true, versionForm: /^\d+\.\d+\.\d+-alpha(\.\d+)?$/ },
|
||||
{ name: 'rc', prerelease: true, versionForm: /^\d+\.\d+\.\d+-rc\.\d+$/ },
|
||||
{ name: 'edge', prerelease: true, versionForm: /^\d+\.\d+\.\d+-edge\.[0-9a-f]{7,40}$/ },
|
||||
]
|
||||
|
||||
@ -15,13 +15,13 @@ function run(args: string[]): { code: number, stdout: string, stderr: string } {
|
||||
}
|
||||
}
|
||||
|
||||
describe('release-naming compat-check (compat 1.14.0..1.15.0)', () => {
|
||||
describe('release-naming compat-check (compat 1.15.0..1.15.0)', () => {
|
||||
it('accepts a version inside the window', () => {
|
||||
expect(run(['compat-check', '1.14.7']).code).toBe(0)
|
||||
expect(run(['compat-check', '1.15.0']).code).toBe(0)
|
||||
})
|
||||
|
||||
it('accepts the inclusive lower bound', () => {
|
||||
expect(run(['compat-check', '1.14.0']).code).toBe(0)
|
||||
expect(run(['compat-check', '1.15.0']).code).toBe(0)
|
||||
})
|
||||
|
||||
it('accepts the inclusive upper bound', () => {
|
||||
@ -29,26 +29,22 @@ describe('release-naming compat-check (compat 1.14.0..1.15.0)', () => {
|
||||
})
|
||||
|
||||
it('accepts a v-prefixed tag', () => {
|
||||
expect(run(['compat-check', 'v1.14.2']).code).toBe(0)
|
||||
expect(run(['compat-check', 'v1.15.0']).code).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects a version below the lower bound', () => {
|
||||
expect(run(['compat-check', '1.13.9']).code).not.toBe(0)
|
||||
expect(run(['compat-check', '1.14.9']).code).not.toBe(0)
|
||||
})
|
||||
|
||||
it('rejects a version above the upper bound', () => {
|
||||
expect(run(['compat-check', '1.15.1']).code).not.toBe(0)
|
||||
})
|
||||
|
||||
it('treats a prerelease of the upper bound as in range (1.15.0-rc1 <= 1.15.0)', () => {
|
||||
expect(run(['compat-check', '1.15.0-rc1']).code).toBe(0)
|
||||
it('treats a prerelease of the bound as below it (1.15.0-rc1 < 1.15.0)', () => {
|
||||
expect(run(['compat-check', '1.15.0-rc1']).code).not.toBe(0)
|
||||
})
|
||||
|
||||
it('treats a prerelease of the lower bound as below it (1.14.0-rc1 < 1.14.0)', () => {
|
||||
expect(run(['compat-check', '1.14.0-rc1']).code).not.toBe(0)
|
||||
})
|
||||
|
||||
it('ignores build metadata on the upper bound (1.15.0+build == 1.15.0)', () => {
|
||||
it('ignores build metadata on the bound (1.15.0+build == 1.15.0)', () => {
|
||||
expect(run(['compat-check', '1.15.0+build123']).code).toBe(0)
|
||||
})
|
||||
|
||||
@ -64,7 +60,7 @@ describe('release-naming compat-check (compat 1.14.0..1.15.0)', () => {
|
||||
describe('release-naming github-env', () => {
|
||||
it('emits difyctlTag = tagPrefix + version', () => {
|
||||
const { stdout } = run(['github-env'])
|
||||
expect(stdout).toMatch(/^difyctlTag=difyctl-v0\.1\.0-rc\.1$/m)
|
||||
expect(stdout).toMatch(/^difyctlTag=difyctl-v0\.1\.0-alpha$/m)
|
||||
})
|
||||
|
||||
it('still emits the existing trace fields', () => {
|
||||
@ -79,8 +75,8 @@ describe('release-naming edge channel', () => {
|
||||
expect(run(['channels']).stdout).toMatch(/^edge$/m)
|
||||
})
|
||||
|
||||
it('edge-version derives <pkgcore>-edge.<sha> stripping the rc prerelease', () => {
|
||||
// package.json version is 0.1.0-rc.1 -> core 0.1.0
|
||||
it('edge-version derives <pkgcore>-edge.<sha> stripping the alpha prerelease', () => {
|
||||
// package.json version is 0.1.0-alpha -> core 0.1.0
|
||||
expect(run(['edge-version', '2fd7b82']).stdout.trim()).toBe('0.1.0-edge.2fd7b82')
|
||||
})
|
||||
|
||||
|
||||
@ -81,7 +81,7 @@ describe('release-r2-edge manifest', () => {
|
||||
|
||||
it('carries the compat window from package.json', () => {
|
||||
const { json } = buildManifest()
|
||||
expect(json.compat).toEqual({ minDify: '1.14.0', maxDify: '1.15.0' })
|
||||
expect(json.compat).toEqual({ minDify: '1.15.0', maxDify: '1.15.0' })
|
||||
})
|
||||
|
||||
it('lists all 5 targets with asset name + sha256 from the checksums file', () => {
|
||||
|
||||
@ -158,6 +158,6 @@ describe('Version command', () => {
|
||||
if (output?.kind !== 'formatted')
|
||||
throw new Error('expected formatted output')
|
||||
|
||||
expect(output.data.text()).toContain('WARNING: This build is a release candidate')
|
||||
expect(output.data.text()).toContain('WARNING: This build is a rc release')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { arch, platform } from '@/sys/index'
|
||||
import { compatString } from './compat'
|
||||
|
||||
export type Channel = 'dev' | 'edge' | 'rc' | 'stable'
|
||||
export type Channel = 'dev' | 'alpha' | 'edge' | 'rc' | 'stable'
|
||||
|
||||
export type VersionInfo = {
|
||||
version: string
|
||||
|
||||
@ -52,7 +52,7 @@ describe('renderVersionText', () => {
|
||||
expect(text).not.toContain('WARNING:')
|
||||
})
|
||||
|
||||
it('appends RC warning when channel is rc', () => {
|
||||
it('appends warning when channel is rc', () => {
|
||||
const report: VersionReport = {
|
||||
client: baseClient({ channel: 'rc' }),
|
||||
server: { endpoint: '', reachable: false },
|
||||
@ -60,8 +60,43 @@ describe('renderVersionText', () => {
|
||||
}
|
||||
const text = renderVersionText(report)
|
||||
|
||||
expect(text).toContain('WARNING: This build is a release candidate')
|
||||
expect(text).toContain('install the stable channel')
|
||||
expect(text).toContain('WARNING: This build is a rc release')
|
||||
expect(text).toContain('install or wait for the stable channel')
|
||||
})
|
||||
|
||||
it('appends warning when channel is alpha', () => {
|
||||
const report: VersionReport = {
|
||||
client: baseClient({ channel: 'alpha' }),
|
||||
server: { endpoint: '', reachable: false },
|
||||
compat: { ...compatible(), status: 'unknown', detail: 'server probe skipped' },
|
||||
}
|
||||
const text = renderVersionText(report)
|
||||
|
||||
expect(text).toContain('WARNING: This build is a alpha release')
|
||||
expect(text).toContain('install or wait for the stable channel')
|
||||
})
|
||||
|
||||
it('appends warning when channel is edge', () => {
|
||||
const report: VersionReport = {
|
||||
client: baseClient({ channel: 'edge' }),
|
||||
server: { endpoint: '', reachable: false },
|
||||
compat: { ...compatible(), status: 'unknown', detail: 'server probe skipped' },
|
||||
}
|
||||
const text = renderVersionText(report)
|
||||
|
||||
expect(text).toContain('WARNING: This build is a edge release')
|
||||
expect(text).toContain('install or wait for the stable channel')
|
||||
})
|
||||
|
||||
it('does not append warning when channel is stable', () => {
|
||||
const report: VersionReport = {
|
||||
client: baseClient({ channel: 'stable' }),
|
||||
server: { endpoint: '', reachable: false },
|
||||
compat: { ...compatible(), status: 'unknown', detail: 'server probe skipped' },
|
||||
}
|
||||
const text = renderVersionText(report)
|
||||
|
||||
expect(text).not.toContain('WARNING:')
|
||||
})
|
||||
|
||||
it('shows "(skipped …)" when server.endpoint is empty', () => {
|
||||
@ -105,7 +140,7 @@ describe('renderVersionText', () => {
|
||||
// RC warning) ran, yet the output is byte-clean.
|
||||
expect(plain).not.toMatch(ANSI_RE)
|
||||
expect(plain).toContain('Compatibility: incompatible')
|
||||
expect(plain).toContain('WARNING: This build is a release candidate')
|
||||
expect(plain).toContain('WARNING: This build is a rc release')
|
||||
})
|
||||
|
||||
describe('with picocolors stubbed to always emit ANSI', () => {
|
||||
@ -147,8 +182,8 @@ describe('renderVersionText', () => {
|
||||
const colored = render(report, { color: true })
|
||||
expect(colored).toMatch(ANSI_RE)
|
||||
expect(colored).toContain('Compatibility: incompatible')
|
||||
// RC warning lines also routed through yellow.
|
||||
expect(colored).toContain('release candidate')
|
||||
// prerelease warning lines also routed through yellow.
|
||||
expect(colored).toContain('WARNING: This build is a rc release')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
import type { Channel } from './info'
|
||||
import type { VersionReport } from './probe'
|
||||
import { colorScheme } from '@/sys/io/color'
|
||||
|
||||
const RC_WARNING_LINES = [
|
||||
'WARNING: This build is a release candidate. It is in beta test, not stable,',
|
||||
' and may have bugs. For production use, install the stable channel.',
|
||||
] as const
|
||||
function prereleaseWarning(channel: Channel): readonly string[] {
|
||||
return [
|
||||
`WARNING: This build is a ${channel} release. It is not stable`,
|
||||
' and may have bugs. For production use, install or wait for the stable channel.',
|
||||
]
|
||||
}
|
||||
|
||||
export type RenderOptions = {
|
||||
readonly color?: boolean
|
||||
@ -49,9 +52,9 @@ export function renderVersionText(report: VersionReport, opts: RenderOptions = {
|
||||
const verdictText = `Compatibility: ${COMPAT_LABEL[compat.status]} — ${compat.detail}`
|
||||
lines.push(compat.status === 'unsupported' ? c.yellow(verdictText) : verdictText)
|
||||
|
||||
if (client.channel === 'rc') {
|
||||
if (client.channel !== 'stable') {
|
||||
lines.push('')
|
||||
for (const line of RC_WARNING_LINES)
|
||||
for (const line of prereleaseWarning(client.channel))
|
||||
lines.push(c.yellow(line))
|
||||
}
|
||||
|
||||
|
||||
@ -120,6 +120,7 @@ CELERY_TASK_ANNOTATIONS=null
|
||||
EVENT_BUS_REDIS_URL=
|
||||
EVENT_BUS_REDIS_CHANNEL_TYPE=pubsub
|
||||
EVENT_BUS_REDIS_USE_CLUSTERS=false
|
||||
EVENT_BUS_LISTENER_JOIN_TIMEOUT_MS=2000
|
||||
|
||||
# Web and app limits
|
||||
WEB_API_CORS_ALLOW_ORIGINS=*
|
||||
|
||||
@ -421,18 +421,16 @@ describe('List', () => {
|
||||
expect(screen.getByRole('button', { name: 'common.operation.create' }))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render sort filter before search and the snippets link', () => {
|
||||
it('should render sort filter before search and hide the snippets link', () => {
|
||||
renderList()
|
||||
|
||||
const sortButton = screen.getByRole('button', { name: 'Sort by Last modified' })
|
||||
const searchInput = screen.getByRole('searchbox', { name: 'app.gotoAnything.actions.searchApplications' })
|
||||
const snippetsLink = screen.getByRole('link', { name: 'app.studio.viewSnippets' })
|
||||
const createButton = screen.getByRole('button', { name: 'common.operation.create' })
|
||||
|
||||
expect(snippetsLink).toHaveAttribute('href', '/snippets')
|
||||
expect(sortButton.compareDocumentPosition(searchInput) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
expect(searchInput.compareDocumentPosition(snippetsLink) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
expect(snippetsLink.compareDocumentPosition(createButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
expect(searchInput.compareDocumentPosition(createButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
expect(screen.queryByRole('link', { name: 'app.studio.viewSnippets' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render app cards when apps exist', () => {
|
||||
|
||||
@ -8,7 +8,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SearchInput } from '@/app/components/base/search-input'
|
||||
import { TagFilter } from '@/features/tag-management/components/tag-filter'
|
||||
import Link from '@/next/link'
|
||||
import { AppSortFilter } from './app-sort-filter'
|
||||
import { AppTypeFilter } from './app-type-filter'
|
||||
import CreatorsFilter from './creators-filter'
|
||||
@ -71,12 +70,6 @@ export function AppListHeaderFilters({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/snippets"
|
||||
className="flex h-8 items-center rounded-lg px-3 text-sm font-semibold text-text-secondary outline-hidden hover:bg-state-base-hover hover:text-text-primary focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
>
|
||||
{t('studio.viewSnippets', { ns: 'app' })}
|
||||
</Link>
|
||||
{showCreateButton && (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
|
||||
@ -17,7 +17,7 @@ vi.mock('@/next/navigation', () => ({
|
||||
|
||||
// Mock useDocLink hook
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => (path?: string) => `https://docs.dify.ai/en${path?.startsWith('/use-dify/') ? `/cloud${path}` : path || ''}`,
|
||||
useDocLink: () => (path?: string) => `https://docs.dify.ai/en${path || ''}`,
|
||||
}))
|
||||
|
||||
// Mock external context providers (these are external dependencies)
|
||||
@ -155,7 +155,7 @@ describe('ExternalKnowledgeBaseCreate', () => {
|
||||
renderComponent()
|
||||
|
||||
const docLink = screen.getByText('dataset.connectHelper.helper4')
|
||||
expect(docLink)!.toHaveAttribute('href', 'https://docs.dify.ai/en/cloud/use-dify/knowledge/connect-external-knowledge-base')
|
||||
expect(docLink)!.toHaveAttribute('href', 'https://docs.dify.ai/en/use-dify/knowledge/connect-external-knowledge-base')
|
||||
expect(docLink)!.toHaveAttribute('target', '_blank')
|
||||
expect(docLink)!.toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
|
||||
@ -22,7 +22,6 @@ vi.mock('react-i18next', () => ({
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
defaultDocBaseUrl: 'https://docs.dify.ai',
|
||||
getDocHomePath: () => '/home',
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n-config/language', () => ({
|
||||
@ -46,7 +45,7 @@ describe('docsCommand', () => {
|
||||
docsCommand.execute?.()
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://docs.dify.ai/en/home',
|
||||
expect.stringContaining('https://docs.dify.ai'),
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
@ -86,11 +85,7 @@ describe('docsCommand', () => {
|
||||
const handlers = vi.mocked(registerCommands).mock.calls[0]![0]
|
||||
await handlers['navigation.doc']!()
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://docs.dify.ai/en/home',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
expect(openSpy).toHaveBeenCalledWith('https://docs.dify.ai/en', '_blank', 'noopener,noreferrer')
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
|
||||
@ -2,20 +2,13 @@ import type { SlashCommandHandler } from './types'
|
||||
import { RiBookOpenLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { getI18n } from 'react-i18next'
|
||||
import { defaultDocBaseUrl, getDocHomePath } from '@/context/i18n'
|
||||
import { defaultDocBaseUrl } from '@/context/i18n'
|
||||
import { getDocLanguage } from '@/i18n-config/language'
|
||||
import { registerCommands, unregisterCommands } from './command-bus'
|
||||
|
||||
// Documentation command dependency types - no external dependencies needed
|
||||
type DocDeps = Record<string, never>
|
||||
|
||||
const getDocsHomeUrl = () => {
|
||||
const i18n = getI18n()
|
||||
const currentLocale = i18n.language
|
||||
const docLanguage = getDocLanguage(currentLocale)
|
||||
return `${defaultDocBaseUrl}/${docLanguage}${getDocHomePath()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Documentation command - Opens help documentation
|
||||
*/
|
||||
@ -26,7 +19,11 @@ export const docsCommand: SlashCommandHandler<DocDeps> = {
|
||||
|
||||
// Direct execution function
|
||||
execute: () => {
|
||||
window.open(getDocsHomeUrl(), '_blank', 'noopener,noreferrer')
|
||||
const i18n = getI18n()
|
||||
const currentLocale = i18n.language
|
||||
const docLanguage = getDocLanguage(currentLocale)
|
||||
const url = `${defaultDocBaseUrl}/${docLanguage}`
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
},
|
||||
|
||||
async search(args: string, locale: string = 'en') {
|
||||
@ -46,9 +43,14 @@ export const docsCommand: SlashCommandHandler<DocDeps> = {
|
||||
},
|
||||
|
||||
register(_deps: DocDeps) {
|
||||
const i18n = getI18n()
|
||||
registerCommands({
|
||||
'navigation.doc': async (_args) => {
|
||||
window.open(getDocsHomeUrl(), '_blank', 'noopener,noreferrer')
|
||||
// Get the current language from i18n
|
||||
const currentLocale = i18n.language
|
||||
const docLanguage = getDocLanguage(currentLocale)
|
||||
const url = `${defaultDocBaseUrl}/${docLanguage}`
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
@ -11,8 +11,8 @@ describe('Empty State', () => {
|
||||
expect(screen.getByText('common.apiBasedExtension.title')).toBeInTheDocument()
|
||||
const link = screen.getByText('common.apiBasedExtension.link')
|
||||
expect(link).toBeInTheDocument()
|
||||
// The real useDocLink includes language and product prefixes in tests.
|
||||
expect(link.closest('a')).toHaveAttribute('href', 'https://docs.dify.ai/en/self-host/use-dify/workspace/api-extension/api-extension')
|
||||
// The real useDocLink includes the language prefix (defaulting to /en in tests)
|
||||
expect(link.closest('a')).toHaveAttribute('href', 'https://docs.dify.ai/en/use-dify/workspace/api-extension/api-extension')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -578,7 +578,7 @@ describe('IntegrationsPage', () => {
|
||||
expect(screen.getAllByText('common.toolsPage.toolPlugin')).toHaveLength(2)
|
||||
expect(screen.getByText('common.toolsPage.description')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.toolsPage.description').closest('[class*="max-w-[1600px]"]')).toHaveClass('px-6')
|
||||
expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/self-host/use-dify/workspace/tools')
|
||||
expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/use-dify/workspace/tools')
|
||||
})
|
||||
|
||||
it('aligns model provider headers to the unified content frame', () => {
|
||||
@ -600,7 +600,7 @@ describe('IntegrationsPage', () => {
|
||||
|
||||
expect(screen.getAllByText('MCP')).toHaveLength(2)
|
||||
expect(screen.getByText('common.mcpPage.description')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/self-host/use-dify/build/mcp')
|
||||
expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/use-dify/build/mcp')
|
||||
expect(screen.queryByText('common.toolsPage.description')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -609,7 +609,7 @@ describe('IntegrationsPage', () => {
|
||||
|
||||
expect(screen.getAllByText('common.settings.swaggerAPIAsTool')).toHaveLength(2)
|
||||
expect(screen.getByText('common.swaggerAPIAsToolPage.description')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/self-host/use-dify/workspace/tools#custom-tool')
|
||||
expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/use-dify/workspace/tools#custom-tool')
|
||||
expect(screen.queryByText('common.toolsPage.description')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -630,7 +630,7 @@ describe('IntegrationsPage', () => {
|
||||
expect(screen.getAllByText('common.settings.customEndpoint')).toHaveLength(2)
|
||||
expect(screen.getByText('common.apiBasedExtensionPage.description')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('api-extension-toolbar')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/self-host/use-dify/workspace/api-extension/api-extension')
|
||||
expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/use-dify/workspace/api-extension/api-extension')
|
||||
expect(screen.queryByText('common.toolsPage.description')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -652,7 +652,7 @@ describe('IntegrationsPage', () => {
|
||||
|
||||
expect(screen.getAllByText('workflow.common.workflowAsTool')).toHaveLength(2)
|
||||
expect(screen.getByText('common.workflowAsToolPage.description')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/self-host/use-dify/workspace/tools#workflow-tool')
|
||||
expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/use-dify/workspace/tools#workflow-tool')
|
||||
expect(screen.queryByText('common.toolsPage.description')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@ -5,7 +5,6 @@ import type { ModalContextState } from '@/context/modal-context'
|
||||
import type { ProviderContextState } from '@/context/provider-context'
|
||||
import type { IWorkspace } from '@/models/common'
|
||||
import type { InstalledApp } from '@/models/explore'
|
||||
import type { SnippetDetail, SnippetInputField } from '@/models/snippet'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { createStore, Provider as JotaiProvider } from 'jotai'
|
||||
import { createTestQueryClient, renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
|
||||
@ -17,7 +16,6 @@ import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/con
|
||||
import { useAppContext, useSelector as useAppContextSelector } from '@/context/app-context'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
import { usePathname, useRouter } from '@/next/navigation'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { useGetInstalledApps, useUninstallApp, useUpdateAppPinStatus } from '@/service/use-explore'
|
||||
@ -27,29 +25,14 @@ import { DETAIL_SIDEBAR_STORAGE_KEY } from '../storage'
|
||||
|
||||
const activeEdgeClassName = 'before:pointer-events-none'
|
||||
|
||||
type SnippetNavigationTestState = {
|
||||
onFieldsChange?: (fields: SnippetInputField[]) => void
|
||||
readonly: boolean
|
||||
snippet?: SnippetDetail
|
||||
}
|
||||
|
||||
const { mockIsAgentV2Enabled, mockSnippetFieldsChange, mockSwitchWorkspace, mockToastSuccess, hotkeyRegistrations, snippetDraftState, snippetNavigationState } = vi.hoisted(() => ({
|
||||
const { mockIsAgentV2Enabled, mockSwitchWorkspace, mockToastSuccess, hotkeyRegistrations } = vi.hoisted(() => ({
|
||||
mockSwitchWorkspace: vi.fn(),
|
||||
mockSnippetFieldsChange: vi.fn(),
|
||||
mockToastSuccess: vi.fn(),
|
||||
mockIsAgentV2Enabled: vi.fn(() => true),
|
||||
hotkeyRegistrations: new Map<string, {
|
||||
handler: (event: { preventDefault: () => void }) => void
|
||||
options?: { ignoreInputs?: boolean }
|
||||
}>(),
|
||||
snippetDraftState: {
|
||||
inputFields: [],
|
||||
} as { inputFields: SnippetInputField[] },
|
||||
snippetNavigationState: {
|
||||
readonly: true,
|
||||
snippet: undefined,
|
||||
onFieldsChange: undefined,
|
||||
} as SnippetNavigationTestState,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/agent-v2/feature-flag', () => ({
|
||||
@ -201,42 +184,6 @@ vi.mock('@/features/deployments/detail/deployment-sidebar', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/snippets/store', () => ({
|
||||
useSnippetDetailStore: (selector: (state: SnippetNavigationTestState) => unknown) => selector(snippetNavigationState),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/snippets/draft-store', () => ({
|
||||
useSnippetDraftStore: (selector: (state: typeof snippetDraftState) => unknown) => selector(snippetDraftState),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/snippets/components/snippet-sidebar', () => ({
|
||||
SnippetSidebarContent: ({
|
||||
fields,
|
||||
onFieldsChange,
|
||||
readonly,
|
||||
snippet,
|
||||
}: {
|
||||
fields: SnippetInputField[]
|
||||
onFieldsChange: (fields: SnippetInputField[]) => void
|
||||
readonly: boolean
|
||||
snippet: SnippetDetail
|
||||
}) => (
|
||||
<div data-testid="snippet-sidebar-content" data-readonly={String(readonly)}>
|
||||
<span>{snippet.name}</span>
|
||||
<span>{fields.map(field => field.variable).join(',')}</span>
|
||||
<button type="button" onClick={() => onFieldsChange([])}>change snippet fields</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../components/snippet-detail-top', () => ({
|
||||
default: ({ expand, onToggle }: { expand: boolean, onToggle: () => void }) => (
|
||||
<div data-testid="snippet-detail-top" data-expand={expand}>
|
||||
<button type="button" data-testid="snippet-detail-toggle" onClick={onToggle}>Toggle</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useLocale: () => 'en-US',
|
||||
useDocLink: () => (path: string) => `https://docs.dify.ai${path}`,
|
||||
@ -296,24 +243,6 @@ const createInstalledApp = (overrides: Partial<InstalledApp> = {}): InstalledApp
|
||||
},
|
||||
})
|
||||
|
||||
const snippet: SnippetDetail = {
|
||||
id: 'snippet-1',
|
||||
name: 'Snippet',
|
||||
description: 'Description',
|
||||
updatedAt: '2026-03-29 10:00',
|
||||
usage: '0',
|
||||
tags: [],
|
||||
}
|
||||
|
||||
const snippetFields: SnippetInputField[] = [
|
||||
{
|
||||
label: 'Query',
|
||||
variable: 'query',
|
||||
type: PipelineInputVarType.textInput,
|
||||
required: true,
|
||||
},
|
||||
]
|
||||
|
||||
const appContextValue: AppContextValue = {
|
||||
userProfile: {
|
||||
id: 'user-1',
|
||||
@ -443,10 +372,6 @@ describe('MainNav', () => {
|
||||
})
|
||||
mockSwitchWorkspace.mockReturnValue(new Promise(() => {}))
|
||||
hotkeyRegistrations.clear()
|
||||
snippetDraftState.inputFields = []
|
||||
snippetNavigationState.onFieldsChange = undefined
|
||||
snippetNavigationState.readonly = true
|
||||
snippetNavigationState.snippet = undefined
|
||||
useAppStore.getState().setAppDetail()
|
||||
})
|
||||
|
||||
@ -658,24 +583,12 @@ describe('MainNav', () => {
|
||||
expect(screen.getByRole('link', { name: /common.mainNav.home/ })).not.toHaveAttribute('aria-current')
|
||||
})
|
||||
|
||||
it('replaces global navigation with snippet detail navigation on snippet routes', () => {
|
||||
it('hides the main menu on snippet detail routes while keeping account settings available', () => {
|
||||
mockPathname = '/snippets/snippet-1/orchestrate'
|
||||
snippetDraftState.inputFields = snippetFields
|
||||
snippetNavigationState.onFieldsChange = mockSnippetFieldsChange
|
||||
snippetNavigationState.readonly = false
|
||||
snippetNavigationState.snippet = snippet
|
||||
|
||||
renderMainNav()
|
||||
|
||||
expect(screen.getByRole('complementary')).toHaveClass('w-[248px]')
|
||||
expect(screen.getByRole('complementary')).toHaveClass('p-1')
|
||||
expect(screen.getByRole('complementary')).toHaveClass('bg-background-body')
|
||||
expect(screen.getByTestId('snippet-detail-top')).toHaveAttribute('data-expand', 'true')
|
||||
expect(screen.getByTestId('snippet-sidebar-content')).toHaveAttribute('data-readonly', 'false')
|
||||
expect(screen.getByText(snippet.name)).toBeInTheDocument()
|
||||
expect(screen.getByText('query')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'change snippet fields' }))
|
||||
expect(mockSnippetFieldsChange).toHaveBeenCalledWith([])
|
||||
expect(screen.getByRole('complementary')).toHaveClass('w-16')
|
||||
expect(screen.queryByLabelText('Dify')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'common.mainNav.workspace.openMenu' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: /common.mainNav.home/ })).not.toBeInTheDocument()
|
||||
@ -685,24 +598,6 @@ describe('MainNav', () => {
|
||||
expect(screen.getByRole('button', { name: 'common.mainNav.help.openMenu' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('collapses snippet detail navigation from the top-right toggle', () => {
|
||||
mockPathname = '/snippets/snippet-1/orchestrate'
|
||||
snippetDraftState.inputFields = snippetFields
|
||||
snippetNavigationState.onFieldsChange = mockSnippetFieldsChange
|
||||
snippetNavigationState.snippet = snippet
|
||||
|
||||
renderMainNav()
|
||||
fireEvent.click(screen.getByTestId('snippet-detail-toggle'))
|
||||
|
||||
expect(screen.getByRole('complementary')).toHaveClass('w-16')
|
||||
expect(screen.getByRole('complementary')).toHaveClass('p-1')
|
||||
expect(screen.getByTestId('snippet-detail-top')).toHaveAttribute('data-expand', 'false')
|
||||
expect(screen.queryByTestId('snippet-sidebar-content')).not.toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Snippet collapsed preview')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('1 input fields')).toBeInTheDocument()
|
||||
expect(localStorage.getItem(DETAIL_SIDEBAR_STORAGE_KEY)).toBe('collapse')
|
||||
})
|
||||
|
||||
it('replaces global navigation with app detail navigation on app routes', () => {
|
||||
mockPathname = '/app/app-1/overview'
|
||||
|
||||
|
||||
@ -1,107 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { formatForDisplay } from '@tanstack/react-hotkeys'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
|
||||
import { useSetGotoAnythingOpen } from '@/app/components/goto-anything/atoms'
|
||||
import Link from '@/next/link'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import ToggleButton from '../../app-sidebar/toggle-button'
|
||||
|
||||
type SnippetDetailTopProps = {
|
||||
expand?: boolean
|
||||
onToggle?: () => void
|
||||
}
|
||||
|
||||
const SEARCH_SHORTCUT = ['Mod', 'K']
|
||||
|
||||
const SnippetDetailTop = ({
|
||||
expand = true,
|
||||
onToggle,
|
||||
}: SnippetDetailTopProps) => {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const setGotoAnythingOpen = useSetGotoAnythingOpen()
|
||||
|
||||
if (!expand) {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center px-3 pt-2 pb-1">
|
||||
{onToggle && (
|
||||
<ToggleButton
|
||||
expand={expand}
|
||||
handleToggle={onToggle}
|
||||
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
|
||||
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center py-2 pr-2 pl-1">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-px">
|
||||
<div className="flex shrink-0 items-center rounded-lg py-2 pr-1.5 pl-0.5 transition-colors hover:bg-background-default-hover">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('operation.back', { ns: 'common' })}
|
||||
className="flex size-4 items-center justify-center text-text-tertiary hover:text-text-secondary"
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<span aria-hidden className="i-ri-arrow-left-s-line size-4" />
|
||||
</button>
|
||||
<Link
|
||||
href="/"
|
||||
aria-label={t('mainNav.home', { ns: 'common' })}
|
||||
className="flex size-4 items-center justify-center text-text-tertiary hover:text-text-secondary"
|
||||
>
|
||||
<span aria-hidden className="i-custom-vender-main-nav-app-home size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
<span className="shrink-0 system-md-regular text-text-quaternary">
|
||||
/
|
||||
</span>
|
||||
<Link
|
||||
href="/snippets"
|
||||
className="shrink-0 truncate rounded-lg px-1.5 py-2 system-sm-semibold-uppercase text-text-secondary transition-colors hover:bg-background-default-hover hover:text-text-primary"
|
||||
>
|
||||
{t('tabs.snippets', { ns: 'workflow' })}
|
||||
</Link>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('gotoAnything.searchTitle', { ns: 'app' })}
|
||||
className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-[10px] text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary"
|
||||
onClick={() => setGotoAnythingOpen(true)}
|
||||
>
|
||||
<span aria-hidden className="i-custom-vender-main-nav-quick-search size-4" />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent placement="bottom" className="flex items-center gap-1 rounded-lg border-[0.5px] border-components-panel-border bg-components-tooltip-bg p-1.5 system-xs-medium text-text-secondary shadow-lg backdrop-blur-[5px]">
|
||||
<span className="px-0.5">{t('gotoAnything.quickAction', { ns: 'app' })}</span>
|
||||
<KbdGroup>
|
||||
{SEARCH_SHORTCUT.map(key => (
|
||||
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
|
||||
))}
|
||||
</KbdGroup>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{onToggle && (
|
||||
<ToggleButton
|
||||
expand={expand}
|
||||
handleToggle={onToggle}
|
||||
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
|
||||
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SnippetDetailTop
|
||||
@ -14,10 +14,6 @@ import DatasetDetailTop from '@/app/components/app-sidebar/dataset-detail-top'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import DifyLogo from '@/app/components/base/logo/dify-logo'
|
||||
import EnvNav from '@/app/components/header/env-nav'
|
||||
import { SnippetCollapsedPreview } from '@/app/components/snippets/components/snippet-collapsed-preview'
|
||||
import { SnippetSidebarContent } from '@/app/components/snippets/components/snippet-sidebar'
|
||||
import { useSnippetDraftStore } from '@/app/components/snippets/draft-store'
|
||||
import { useSnippetDetailStore } from '@/app/components/snippets/store'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { AgentDetailSection, AgentDetailTop } from '@/features/agent-v2/agent-detail/navigation'
|
||||
import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag'
|
||||
@ -29,7 +25,6 @@ import AccountSection from './components/account-section'
|
||||
import HelpMenu from './components/help-menu'
|
||||
import MainNavLink from './components/nav-link'
|
||||
import { MainNavSearchButton } from './components/search-button'
|
||||
import SnippetDetailTop from './components/snippet-detail-top'
|
||||
import WebAppsSection from './components/web-apps-section'
|
||||
import { WorkspaceCard } from './components/workspace-card'
|
||||
import { isMainNavRouteVisible, MAIN_NAV_ROUTES } from './routes'
|
||||
@ -100,14 +95,8 @@ const MainNav = ({
|
||||
const showDatasetDetailNavigation = isDatasetDetailPathname(pathname)
|
||||
const showAgentDetailNavigation = agentV2Enabled && !isCurrentWorkspaceDatasetOperator && isAgentDetailPathname(pathname)
|
||||
const showDeploymentDetailNavigation = canUseAppDeploy && !isCurrentWorkspaceDatasetOperator && isDeploymentDetailPathname(pathname)
|
||||
const showSnippetDetailNavigation = isSnippetDetailPathname(pathname)
|
||||
const showDetailNavigation = showAppDetailNavigation || showDatasetDetailNavigation || showAgentDetailNavigation || showDeploymentDetailNavigation || showSnippetDetailNavigation
|
||||
const snippetNavigation = useSnippetDetailStore(useShallow(state => ({
|
||||
onFieldsChange: state.onFieldsChange,
|
||||
readonly: state.readonly,
|
||||
snippet: state.snippet,
|
||||
})))
|
||||
const snippetInputFields = useSnippetDraftStore(state => state.inputFields)
|
||||
const showSnippetDetailBottomNavigation = isSnippetDetailPathname(pathname)
|
||||
const showDetailNavigation = showAppDetailNavigation || showDatasetDetailNavigation || showAgentDetailNavigation || showDeploymentDetailNavigation
|
||||
const { hasAppDetail, setAppDetail } = useAppStore(useShallow(state => ({
|
||||
hasAppDetail: !!state.appDetail,
|
||||
setAppDetail: state.setAppDetail,
|
||||
@ -122,7 +111,9 @@ const MainNav = ({
|
||||
const detailNavigationTransitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const isDetailNavigationHoverPreviewOpen = isCollapsedDetailNavigation && detailNavigationHoverPreviewOpen
|
||||
const detailNavigationVisibleExpanded = detailNavigationExpanded || isDetailNavigationHoverPreviewOpen
|
||||
const bottomNavigationExpanded = !showDetailNavigation || detailNavigationVisibleExpanded
|
||||
const bottomNavigationExpanded = showSnippetDetailBottomNavigation
|
||||
? false
|
||||
: !showDetailNavigation || detailNavigationVisibleExpanded
|
||||
const handleToggleDetailNavigation = useCallback(() => {
|
||||
if (isDetailNavigationHoverPreviewOpen) {
|
||||
if (detailNavigationTransitionTimerRef.current)
|
||||
@ -232,7 +223,9 @@ const MainNav = ({
|
||||
? detailNavigationExpanded
|
||||
? 'w-[248px] bg-background-body p-1'
|
||||
: 'w-16 bg-background-body p-1'
|
||||
: 'w-60 flex-col',
|
||||
: showSnippetDetailBottomNavigation
|
||||
? 'w-16 bg-background-body p-1'
|
||||
: 'w-60 flex-col',
|
||||
'bg-background-body',
|
||||
className,
|
||||
)}
|
||||
@ -273,30 +266,25 @@ const MainNav = ({
|
||||
onToggle={handleToggleDetailNavigation}
|
||||
/>
|
||||
)
|
||||
: showDeploymentDetailNavigation
|
||||
? (
|
||||
<DeploymentDetailTop
|
||||
expand={detailNavigationVisibleExpanded}
|
||||
onToggle={handleToggleDetailNavigation}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<SnippetDetailTop
|
||||
expand={detailNavigationVisibleExpanded}
|
||||
onToggle={handleToggleDetailNavigation}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<div className="flex items-center justify-between pt-3 pr-2 pb-2 pl-4">
|
||||
{renderLogo()}
|
||||
<MainNavSearchButton />
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<WorkspaceCard />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
: (
|
||||
<DeploymentDetailTop
|
||||
expand={detailNavigationVisibleExpanded}
|
||||
onToggle={handleToggleDetailNavigation}
|
||||
/>
|
||||
)
|
||||
: showSnippetDetailBottomNavigation
|
||||
? null
|
||||
: (
|
||||
<>
|
||||
<div className="flex items-center justify-between pt-3 pr-2 pb-2 pl-4">
|
||||
{renderLogo()}
|
||||
<MainNavSearchButton />
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<WorkspaceCard />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{showDetailNavigation
|
||||
? showAppDetailNavigation
|
||||
? <AppDetailSection expand={detailNavigationVisibleExpanded} />
|
||||
@ -304,31 +292,20 @@ const MainNav = ({
|
||||
? <DatasetDetailSection expand={detailNavigationVisibleExpanded} />
|
||||
: showAgentDetailNavigation
|
||||
? <AgentDetailSection expand={detailNavigationVisibleExpanded} />
|
||||
: showDeploymentDetailNavigation
|
||||
? <DeploymentDetailSection expand={detailNavigationVisibleExpanded} />
|
||||
: detailNavigationVisibleExpanded
|
||||
? snippetNavigation.snippet && snippetNavigation.onFieldsChange
|
||||
? (
|
||||
<SnippetSidebarContent
|
||||
snippet={snippetNavigation.snippet}
|
||||
fields={snippetInputFields}
|
||||
readonly={snippetNavigation.readonly}
|
||||
onFieldsChange={snippetNavigation.onFieldsChange}
|
||||
/>
|
||||
)
|
||||
: null
|
||||
: <SnippetCollapsedPreview inputFieldCount={snippetInputFields.length} />
|
||||
: (
|
||||
<>
|
||||
<nav className="flex flex-col gap-px p-2">
|
||||
{navItems.map(item => (
|
||||
<MainNavLink key={item.href} item={item} pathname={pathname} />
|
||||
))}
|
||||
</nav>
|
||||
{!isCurrentWorkspaceDatasetOperator && <WebAppsSection />}
|
||||
</>
|
||||
)}
|
||||
{showEnvTag && detailNavigationVisibleExpanded && (
|
||||
: <DeploymentDetailSection expand={detailNavigationVisibleExpanded} />
|
||||
: showSnippetDetailBottomNavigation
|
||||
? null
|
||||
: (
|
||||
<>
|
||||
<nav className="flex flex-col gap-px p-2">
|
||||
{navItems.map(item => (
|
||||
<MainNavLink key={item.href} item={item} pathname={pathname} />
|
||||
))}
|
||||
</nav>
|
||||
{!isCurrentWorkspaceDatasetOperator && <WebAppsSection />}
|
||||
</>
|
||||
)}
|
||||
{showEnvTag && !showSnippetDetailBottomNavigation && detailNavigationVisibleExpanded && (
|
||||
<div className="relative z-30 mt-auto shrink-0 px-3 pb-2">
|
||||
<EnvNav />
|
||||
</div>
|
||||
|
||||
@ -262,7 +262,6 @@ describe('SnippetList', () => {
|
||||
expect(screen.getByRole('link', { name: 'common.menus.apps' })).toHaveAttribute('href', '/apps')
|
||||
expect(screen.getByRole('heading', { name: 'workflow.tabs.snippets' })).toBeInTheDocument()
|
||||
expect(screen.getByText('app.studio.filters.creators')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /workflow\.common\.published \/ snippet\.draft/i })).toBeInTheDocument()
|
||||
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('workflow.tabs.searchSnippets')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'snippet.create' })).toBeInTheDocument()
|
||||
@ -288,42 +287,6 @@ describe('SnippetList', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not pass published state to the snippets list query by default', () => {
|
||||
renderList()
|
||||
|
||||
expect(mockUseInfiniteSnippetList).toHaveBeenCalledWith(expect.not.objectContaining({
|
||||
is_published: expect.any(Boolean),
|
||||
}), {
|
||||
enabled: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('passes published state when selecting the published filter', () => {
|
||||
renderList()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /workflow\.common\.published \/ snippet\.draft/i }))
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: /workflow\.common\.published/i }))
|
||||
|
||||
expect(mockUseInfiniteSnippetList).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
is_published: true,
|
||||
}), {
|
||||
enabled: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('passes draft state when selecting the draft filter', () => {
|
||||
renderList()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /workflow\.common\.published \/ snippet\.draft/i }))
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: /snippet\.draft/i }))
|
||||
|
||||
expect(mockUseInfiniteSnippetList).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
is_published: false,
|
||||
}), {
|
||||
enabled: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('updates the search query state from the search input', () => {
|
||||
renderList()
|
||||
|
||||
|
||||
@ -1,26 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import SnippetPublishStatusFilter from '../snippet-publish-status-filter'
|
||||
|
||||
describe('SnippetPublishStatusFilter', () => {
|
||||
it('should render the default published and draft filter label', () => {
|
||||
render(<SnippetPublishStatusFilter value="all" onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /workflow\.common\.published \/ snippet\.draft/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should emit the selected publish status from the dropdown', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<SnippetPublishStatusFilter value="all" onChange={onChange} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /workflow\.common\.published \/ snippet\.draft/i }))
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: /workflow\.common\.published/i }))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('published')
|
||||
})
|
||||
|
||||
it('should render the selected draft status label', () => {
|
||||
render(<SnippetPublishStatusFilter value="draft" onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /snippet\.draft/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1,78 +0,0 @@
|
||||
'use client'
|
||||
|
||||
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'
|
||||
|
||||
export type SnippetPublishStatus = 'all' | 'published' | 'draft'
|
||||
|
||||
type SnippetPublishStatusFilterProps = {
|
||||
value: SnippetPublishStatus
|
||||
onChange: (value: SnippetPublishStatus) => void
|
||||
}
|
||||
|
||||
const chipClassName = 'flex h-8 items-center rounded-lg border-[0.5px] px-2 text-[13px] leading-4 outline-hidden transition-colors focus-visible:ring-2 focus-visible:ring-state-accent-solid'
|
||||
const snippetPublishStatusValues: SnippetPublishStatus[] = ['all', 'published', 'draft']
|
||||
|
||||
const isSnippetPublishStatus = (value: string): value is SnippetPublishStatus => {
|
||||
return snippetPublishStatusValues.includes(value as SnippetPublishStatus)
|
||||
}
|
||||
|
||||
const SnippetPublishStatusFilter = ({
|
||||
value,
|
||||
onChange,
|
||||
}: SnippetPublishStatusFilterProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const options = useMemo(() => ([
|
||||
{ value: 'all', text: t('types.all', { ns: 'app' }) },
|
||||
{ value: 'published', text: t('common.published', { ns: 'workflow' }) },
|
||||
{ value: 'draft', text: t('draft', { ns: 'snippet' }) },
|
||||
] satisfies Array<{ value: SnippetPublishStatus, text: string }>), [t])
|
||||
|
||||
const activeOption = options.find(option => option.value === value)
|
||||
const isSelected = value !== 'all'
|
||||
const defaultLabel = `${t('common.published', { ns: 'workflow' })} / ${t('draft', { ns: 'snippet' })}`
|
||||
const triggerLabel = isSelected ? activeOption?.text : defaultLabel
|
||||
|
||||
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 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 => isSnippetPublishStatus(nextValue) && onChange(nextValue)}>
|
||||
{options.map(option => (
|
||||
<DropdownMenuRadioItem key={option.value} value={option.value} closeOnClick>
|
||||
<span>{option.text}</span>
|
||||
<DropdownMenuRadioItemIndicator />
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export default SnippetPublishStatusFilter
|
||||
@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import type { SnippetPublishStatus } from './components/snippet-publish-status-filter'
|
||||
import type { SnippetListItem } from '@/types/snippet'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
@ -19,7 +18,6 @@ import { StudioListHeader } from '../apps/studio-list-header'
|
||||
import { canAccessSnippets } from '../snippets/utils/permission'
|
||||
import SnippetCard from './components/snippet-card'
|
||||
import SnippetCreateButton from './components/snippet-create-button'
|
||||
import SnippetPublishStatusFilter from './components/snippet-publish-status-filter'
|
||||
import { SNIPPET_LIST_SEARCH_DEBOUNCE_MS } from './constants'
|
||||
import { useSnippetsQueryState } from './hooks/use-snippets-query-state'
|
||||
|
||||
@ -29,14 +27,6 @@ const TagManagementModal = dynamic(() => import('@/features/tag-management/compo
|
||||
|
||||
const SNIPPET_CARD_SKELETON_KEYS = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth']
|
||||
|
||||
const toSnippetPublishedQuery = (publishStatus: SnippetPublishStatus) => {
|
||||
if (publishStatus === 'published')
|
||||
return true
|
||||
if (publishStatus === 'draft')
|
||||
return false
|
||||
return undefined
|
||||
}
|
||||
|
||||
type SnippetCardSkeletonProps = {
|
||||
count: number
|
||||
}
|
||||
@ -69,22 +59,16 @@ const SnippetList = () => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
const [showTagManagementModal, setShowTagManagementModal] = useState(false)
|
||||
const [publishStatus, setPublishStatus] = useState<SnippetPublishStatus>('all')
|
||||
|
||||
useDocumentTitle(t('tabs.snippets', { ns: 'workflow' }))
|
||||
|
||||
const snippetListQuery = useMemo(() => {
|
||||
const isPublished = toSnippetPublishedQuery(publishStatus)
|
||||
|
||||
return {
|
||||
page: 1,
|
||||
limit: 30,
|
||||
keyword: debouncedKeywords,
|
||||
...(tagIDs.length ? { tag_ids: tagIDs } : {}),
|
||||
...(creatorIDs.length ? { creator_ids: creatorIDs } : {}),
|
||||
...(typeof isPublished === 'boolean' ? { is_published: isPublished } : {}),
|
||||
}
|
||||
}, [creatorIDs, debouncedKeywords, publishStatus, tagIDs])
|
||||
const snippetListQuery = useMemo(() => ({
|
||||
page: 1,
|
||||
limit: 30,
|
||||
keyword: debouncedKeywords,
|
||||
...(tagIDs.length ? { tag_ids: tagIDs } : {}),
|
||||
...(creatorIDs.length ? { creator_ids: creatorIDs } : {}),
|
||||
}), [creatorIDs, debouncedKeywords, tagIDs])
|
||||
const canQuerySnippetList = canAccessSnippets(workspacePermissionKeys)
|
||||
|
||||
const {
|
||||
@ -160,10 +144,6 @@ const SnippetList = () => {
|
||||
value={creatorIDs}
|
||||
onChange={setCreatorIDs}
|
||||
/>
|
||||
<SnippetPublishStatusFilter
|
||||
value={publishStatus}
|
||||
onChange={setPublishStatus}
|
||||
/>
|
||||
<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" />
|
||||
|
||||
@ -0,0 +1,46 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { expectLoadingButton } from '@/test/button'
|
||||
import SaveBeforeLeavingDialog from '../save-before-leaving-dialog'
|
||||
|
||||
describe('SaveBeforeLeavingDialog', () => {
|
||||
it('should render the trigger and call discard or save actions', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onDiscard = vi.fn()
|
||||
const onSave = vi.fn()
|
||||
|
||||
render(
|
||||
<SaveBeforeLeavingDialog
|
||||
open
|
||||
trigger={<button type="button">leave snippet</button>}
|
||||
onDiscard={onDiscard}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('snippet.saveBeforeLeavingTitle')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'snippet.doNotSave' }))
|
||||
await user.click(screen.getByRole('button', { name: 'snippet.saveAndExit' }))
|
||||
|
||||
expect(onDiscard).toHaveBeenCalledTimes(1)
|
||||
expect(onSave).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should disable destructive and save actions according to dialog state', () => {
|
||||
render(
|
||||
<SaveBeforeLeavingDialog
|
||||
open
|
||||
disabled
|
||||
saveDisabled
|
||||
loading
|
||||
onDiscard={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'snippet.continueEditing' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'snippet.doNotSave' })).toBeDisabled()
|
||||
expectLoadingButton(screen.getByRole('button', { name: 'snippet.saveAndExit' }))
|
||||
})
|
||||
})
|
||||
@ -41,14 +41,22 @@ describe('SnippetChildren', () => {
|
||||
|
||||
it('should render snippet header and workflow panel with forwarded props', () => {
|
||||
const callbacks = {
|
||||
onCancel: vi.fn(),
|
||||
onEdit: vi.fn(),
|
||||
onExitEditing: vi.fn(),
|
||||
onExitEditingWithoutSave: vi.fn(),
|
||||
onPublish: vi.fn(),
|
||||
onSaveAndExitEditing: vi.fn(),
|
||||
}
|
||||
|
||||
render(
|
||||
<SnippetChildren
|
||||
snippetId="snippet-1"
|
||||
fields={fields}
|
||||
canDiscardChanges
|
||||
canSave
|
||||
hasDraftChanges
|
||||
isEditing
|
||||
isPublishing={false}
|
||||
{...callbacks}
|
||||
/>,
|
||||
@ -58,7 +66,10 @@ describe('SnippetChildren', () => {
|
||||
expect(screen.getByTestId('snippet-workflow-panel')).toBeInTheDocument()
|
||||
expect(capturedHeaderProps).toEqual(expect.objectContaining({
|
||||
snippetId: 'snippet-1',
|
||||
canDiscardChanges: true,
|
||||
canSave: true,
|
||||
hasDraftChanges: true,
|
||||
isEditing: true,
|
||||
isPublishing: false,
|
||||
...callbacks,
|
||||
}))
|
||||
|
||||
@ -1,20 +1,20 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { WorkflowProps } from '@/app/components/workflow'
|
||||
import type { SnippetDetail, SnippetDetailPayload, SnippetInputField } from '@/models/snippet'
|
||||
import type { SnippetDetailPayload, SnippetInputField } from '@/models/snippet'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
import { useSnippetDraftStore } from '../../draft-store'
|
||||
import SnippetMain from '../snippet-main'
|
||||
|
||||
const mockSyncInputFieldsDraft = vi.fn()
|
||||
const mockDoSyncWorkflowDraft = vi.fn()
|
||||
const mockSyncWorkflowDraftWhenPageClose = vi.fn()
|
||||
const mockReset = vi.fn()
|
||||
const mockSetNavigationState = vi.fn()
|
||||
const mockSetFields = vi.fn()
|
||||
const mockPublishSnippetMutateAsync = vi.fn()
|
||||
const mockUseSnippetPublishedWorkflow = vi.fn()
|
||||
const mockFetchInspectVars = vi.fn()
|
||||
const mockHandleBackupDraft = vi.fn()
|
||||
const mockHandleLoadBackupDraft = vi.fn()
|
||||
@ -24,7 +24,11 @@ const mockHandleStartWorkflowRun = vi.fn()
|
||||
const mockHandleStopRun = vi.fn()
|
||||
const mockHandleWorkflowStartRunInWorkflow = vi.fn()
|
||||
const mockHandleCheckBeforePublish = vi.fn()
|
||||
const mockPush = vi.hoisted(() => vi.fn())
|
||||
const mockUseAvailableNodesMetaData = vi.hoisted(() => vi.fn())
|
||||
const mockWorkspacePermissionKeys = vi.hoisted(() => ({
|
||||
value: ['snippets.create_and_modify'],
|
||||
}))
|
||||
const mockInspectVarsCrud = {
|
||||
hasNodeInspectVars: vi.fn(),
|
||||
hasSetInspectVar: vi.fn(),
|
||||
@ -49,26 +53,35 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useSelector: <T,>(selector: (state: { workspacePermissionKeys: string[] }) => T): T => selector({
|
||||
workspacePermissionKeys: mockWorkspacePermissionKeys.value,
|
||||
}),
|
||||
}))
|
||||
let capturedHooksStore: Record<string, unknown> | undefined
|
||||
let capturedWorkflowNodes: WorkflowProps['nodes'] | undefined
|
||||
let snippetDetailStoreState: {
|
||||
onFieldsChange?: (fields: SnippetInputField[]) => void
|
||||
readonly: boolean
|
||||
fields: SnippetInputField[]
|
||||
reset: typeof mockReset
|
||||
setNavigationState: typeof mockSetNavigationState
|
||||
snippet?: SnippetDetail
|
||||
snippetId?: string
|
||||
setFields: typeof mockSetFields
|
||||
}
|
||||
|
||||
vi.mock('@/app/components/snippets/store', () => ({
|
||||
useSnippetDetailStore: (selector: (state: typeof snippetDetailStoreState) => unknown) => selector(snippetDetailStoreState),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-snippet-workflows', () => ({
|
||||
usePublishSnippetWorkflowMutation: () => ({
|
||||
mutateAsync: mockPublishSnippetMutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
useSnippetPublishedWorkflow: () => mockUseSnippetPublishedWorkflow(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/snippets/hooks/use-configs-map', () => ({
|
||||
@ -151,15 +164,56 @@ vi.mock('@/app/components/workflow', () => ({
|
||||
|
||||
vi.mock('@/app/components/snippets/components/snippet-children', () => ({
|
||||
default: ({
|
||||
onCancel,
|
||||
onEdit,
|
||||
onExitEditingWithoutSave,
|
||||
onPublish,
|
||||
canSave,
|
||||
canEdit,
|
||||
isEditing,
|
||||
}: {
|
||||
canSave: boolean
|
||||
canEdit: boolean
|
||||
isEditing: boolean
|
||||
onCancel: () => void
|
||||
onEdit: () => void
|
||||
onExitEditingWithoutSave: () => void
|
||||
onPublish: () => void
|
||||
}) => (
|
||||
<div>
|
||||
{!isEditing && canEdit && <button type="button" onClick={onEdit}>edit</button>}
|
||||
<a href="/snippets">snippets list</a>
|
||||
<button type="button" onClick={onExitEditingWithoutSave}>exit without save</button>
|
||||
<button type="button" disabled={!canSave} onClick={onPublish}>publish</button>
|
||||
<button type="button" onClick={onCancel}>cancel</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/snippets/components/snippet-sidebar', () => ({
|
||||
default: ({
|
||||
fields,
|
||||
onFieldsChange,
|
||||
}: {
|
||||
fields: SnippetInputField[]
|
||||
onFieldsChange: (fields: SnippetInputField[]) => void
|
||||
}) => (
|
||||
<div>
|
||||
<button type="button" onClick={() => onFieldsChange([])}>remove</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onFieldsChange([
|
||||
...fields,
|
||||
{
|
||||
type: PipelineInputVarType.textInput,
|
||||
label: 'New Field',
|
||||
variable: 'new_field',
|
||||
required: true,
|
||||
},
|
||||
])}
|
||||
>
|
||||
submit
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
@ -248,6 +302,13 @@ describe('SnippetMain', () => {
|
||||
mockDoSyncWorkflowDraft.mockResolvedValue(undefined)
|
||||
mockSyncInputFieldsDraft.mockResolvedValue(undefined)
|
||||
mockPublishSnippetMutateAsync.mockResolvedValue({ created_at: 1_744_000_000 })
|
||||
mockUseSnippetPublishedWorkflow.mockReturnValue({
|
||||
data: {
|
||||
graph: payload.graph,
|
||||
input_fields: payload.inputFields,
|
||||
},
|
||||
refetch: vi.fn(),
|
||||
})
|
||||
const llmNodeMetadata = createNodeMetadata(BlockEnum.LLM)
|
||||
const humanInputNodeMetadata = createNodeMetadata(BlockEnum.HumanInput)
|
||||
const endNodeMetadata = createNodeMetadata(BlockEnum.End)
|
||||
@ -267,24 +328,18 @@ describe('SnippetMain', () => {
|
||||
},
|
||||
})
|
||||
mockHandleCheckBeforePublish.mockResolvedValue(true)
|
||||
mockSetNavigationState.mockImplementation((state) => {
|
||||
snippetDetailStoreState = {
|
||||
...snippetDetailStoreState,
|
||||
...state,
|
||||
}
|
||||
})
|
||||
capturedHooksStore = undefined
|
||||
capturedWorkflowNodes = undefined
|
||||
useSnippetDraftStore.getState().reset()
|
||||
snippetDetailStoreState = {
|
||||
readonly: true,
|
||||
fields: [...payload.inputFields],
|
||||
reset: mockReset,
|
||||
setNavigationState: mockSetNavigationState,
|
||||
setFields: mockSetFields,
|
||||
}
|
||||
mockWorkspacePermissionKeys.value = ['snippets.create_and_modify']
|
||||
})
|
||||
|
||||
describe('Initial Mode', () => {
|
||||
it('should render the draft graph by default when there is no published workflow', () => {
|
||||
it('should enter draft editing mode by default when there is no published workflow', () => {
|
||||
const draftNode = createDraftNode('draft-node')
|
||||
|
||||
renderSnippetMain({
|
||||
@ -296,7 +351,8 @@ describe('SnippetMain', () => {
|
||||
expect(capturedWorkflowNodes?.map(node => node.id)).toEqual(['draft-node'])
|
||||
})
|
||||
|
||||
it('should keep the snippet canvas editable and sync draft changes without permission gating', async () => {
|
||||
it('should stay readonly without snippet create-and-modify permission', async () => {
|
||||
mockWorkspacePermissionKeys.value = []
|
||||
const draftNode = createDraftNode('draft-node')
|
||||
|
||||
renderSnippetMain({
|
||||
@ -305,17 +361,14 @@ describe('SnippetMain', () => {
|
||||
})
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'edit' })).not.toBeInTheDocument()
|
||||
expect(mockSetNavigationState).toHaveBeenCalledWith(expect.objectContaining({
|
||||
readonly: false,
|
||||
}))
|
||||
|
||||
const doSyncWorkflowDraft = capturedHooksStore?.doSyncWorkflowDraft as (() => Promise<void>)
|
||||
await doSyncWorkflowDraft()
|
||||
|
||||
expect(mockDoSyncWorkflowDraft).toHaveBeenCalledTimes(1)
|
||||
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render the draft graph even when a published workflow exists', async () => {
|
||||
it('should enter readonly mode with published graph by default when published workflow exists', async () => {
|
||||
const publishedNode = createDraftNode('published-node')
|
||||
const draftNode = createDraftNode('draft-node')
|
||||
|
||||
@ -325,13 +378,35 @@ describe('SnippetMain', () => {
|
||||
workflowDraftNodes: [draftNode],
|
||||
})
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'edit' })).not.toBeInTheDocument()
|
||||
expect(capturedWorkflowNodes?.map(node => node.id)).toEqual(['draft-node'])
|
||||
expect(screen.getByRole('button', { name: 'edit' })).toBeInTheDocument()
|
||||
expect(capturedWorkflowNodes?.map(node => node.id)).toEqual(['published-node'])
|
||||
|
||||
const doSyncWorkflowDraft = capturedHooksStore?.doSyncWorkflowDraft as (() => Promise<void>)
|
||||
await doSyncWorkflowDraft()
|
||||
|
||||
expect(mockDoSyncWorkflowDraft).toHaveBeenCalledTimes(1)
|
||||
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should switch from readonly published graph to draft graph without forced draft sync', async () => {
|
||||
const publishedNode = createDraftNode('published-node')
|
||||
const draftNode = createDraftNode('draft-node')
|
||||
|
||||
renderSnippetMain({
|
||||
hasPublishedWorkflow: true,
|
||||
workflowNodes: [publishedNode],
|
||||
workflowDraftNodes: [draftNode],
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'edit' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedWorkflowNodes?.map(node => node.id)).toEqual(['draft-node'])
|
||||
})
|
||||
|
||||
const doSyncWorkflowDraft = capturedHooksStore?.doSyncWorkflowDraft as ((notRefreshWhenSyncError?: boolean) => Promise<void>)
|
||||
await doSyncWorkflowDraft(true)
|
||||
|
||||
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -339,12 +414,7 @@ describe('SnippetMain', () => {
|
||||
it('should sync draft input_fields when removing a field from the panel', async () => {
|
||||
renderSnippetMain({ currentNodes: [createDraftNode()] })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(snippetDetailStoreState.onFieldsChange).toEqual(expect.any(Function))
|
||||
})
|
||||
act(() => {
|
||||
snippetDetailStoreState.onFieldsChange?.([])
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'remove' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSyncInputFieldsDraft).toHaveBeenCalledWith([], {
|
||||
@ -356,20 +426,7 @@ describe('SnippetMain', () => {
|
||||
it('should sync draft input_fields when adding a field from the sidebar', async () => {
|
||||
renderSnippetMain({ currentNodes: [createDraftNode()] })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(snippetDetailStoreState.onFieldsChange).toEqual(expect.any(Function))
|
||||
})
|
||||
act(() => {
|
||||
snippetDetailStoreState.onFieldsChange?.([
|
||||
...payload.inputFields,
|
||||
{
|
||||
type: PipelineInputVarType.textInput,
|
||||
label: 'New Field',
|
||||
variable: 'new_field',
|
||||
required: true,
|
||||
},
|
||||
])
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'submit' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSyncInputFieldsDraft).toHaveBeenCalledWith([
|
||||
@ -398,13 +455,94 @@ describe('SnippetMain', () => {
|
||||
expect(mockDoSyncWorkflowDraft).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('should sync workflow draft when the page closes', () => {
|
||||
it('should sync workflow draft before routing without saving changes', async () => {
|
||||
renderSnippetMain({ hasInitialDraftChanges: true })
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'snippets list' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'snippet.doNotSave' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith('/snippets')
|
||||
})
|
||||
expect(mockDoSyncWorkflowDraft).toHaveBeenCalledWith(true)
|
||||
expect(mockDoSyncWorkflowDraft.mock.invocationCallOrder[0]!).toBeLessThan(mockPush.mock.invocationCallOrder[0]!)
|
||||
expect(mockHandleRestoreFromPublishedWorkflow).not.toHaveBeenCalled()
|
||||
expect(mockSyncInputFieldsDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should sync workflow draft before exiting editing without saving changes', async () => {
|
||||
renderSnippetMain({ hasInitialDraftChanges: true })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'exit without save' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'edit' })).toBeInTheDocument()
|
||||
})
|
||||
expect(mockDoSyncWorkflowDraft).toHaveBeenCalledWith(true)
|
||||
expect(mockHandleRestoreFromPublishedWorkflow).not.toHaveBeenCalled()
|
||||
expect(mockSyncInputFieldsDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not sync draft from workflow autosave while readonly', async () => {
|
||||
renderSnippetMain({ hasInitialDraftChanges: true })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'exit without save' }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'edit' })).toBeInTheDocument()
|
||||
})
|
||||
mockDoSyncWorkflowDraft.mockClear()
|
||||
|
||||
const doSyncWorkflowDraft = capturedHooksStore?.doSyncWorkflowDraft as (() => Promise<void>)
|
||||
const syncWorkflowDraftWhenPageClose = capturedHooksStore?.syncWorkflowDraftWhenPageClose as (() => void)
|
||||
await doSyncWorkflowDraft()
|
||||
syncWorkflowDraftWhenPageClose()
|
||||
|
||||
expect(mockSyncWorkflowDraftWhenPageClose).toHaveBeenCalledTimes(1)
|
||||
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
|
||||
expect(mockSyncWorkflowDraftWhenPageClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should skip forced draft sync caused by re-entering editing mode', async () => {
|
||||
renderSnippetMain({ hasInitialDraftChanges: true })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'exit without save' }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'edit' })).toBeInTheDocument()
|
||||
})
|
||||
mockDoSyncWorkflowDraft.mockClear()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'edit' }))
|
||||
const doSyncWorkflowDraft = capturedHooksStore?.doSyncWorkflowDraft as ((notRefreshWhenSyncError?: boolean) => Promise<void>)
|
||||
await doSyncWorkflowDraft(true)
|
||||
|
||||
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use latest synced draft when re-entering editing mode', async () => {
|
||||
const latestDraftNode = {
|
||||
id: 'latest-node',
|
||||
position: { x: 10, y: 20 },
|
||||
data: { type: BlockEnum.Code, title: 'Latest draft node' },
|
||||
} as WorkflowProps['nodes'][number]
|
||||
mockDoSyncWorkflowDraft.mockResolvedValueOnce({
|
||||
graph: {
|
||||
nodes: [latestDraftNode],
|
||||
edges: [],
|
||||
viewport: { x: 30, y: 40, zoom: 1.2 },
|
||||
},
|
||||
input_fields: [payload.inputFields[0]],
|
||||
})
|
||||
renderSnippetMain({ hasInitialDraftChanges: true })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'exit without save' }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'edit' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'edit' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedWorkflowNodes?.map(node => node.id)).toContain('latest-node')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -493,6 +631,74 @@ describe('SnippetMain', () => {
|
||||
})
|
||||
})
|
||||
|
||||
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),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should update local draft state with the published workflow after canceling changes', async () => {
|
||||
const latestDraftNode = {
|
||||
id: 'latest-draft-node',
|
||||
position: { x: 10, y: 20 },
|
||||
data: { type: BlockEnum.Code, title: 'Latest draft node' },
|
||||
} as WorkflowProps['nodes'][number]
|
||||
const publishedNode = {
|
||||
id: 'published-node',
|
||||
position: { x: 30, y: 40 },
|
||||
data: { type: BlockEnum.Code, title: 'Published node' },
|
||||
} as WorkflowProps['nodes'][number]
|
||||
const publishedWorkflow = {
|
||||
graph: {
|
||||
nodes: [publishedNode],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
input_fields: payload.inputFields,
|
||||
}
|
||||
mockUseSnippetPublishedWorkflow.mockReturnValue({
|
||||
data: publishedWorkflow,
|
||||
refetch: vi.fn(),
|
||||
})
|
||||
mockDoSyncWorkflowDraft.mockResolvedValueOnce({
|
||||
graph: {
|
||||
nodes: [latestDraftNode],
|
||||
edges: [],
|
||||
viewport: { x: 30, y: 40, zoom: 1.2 },
|
||||
},
|
||||
input_fields: payload.inputFields,
|
||||
})
|
||||
renderSnippetMain({ hasInitialDraftChanges: true })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'exit without save' }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'edit' })).toBeInTheDocument()
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'edit' }))
|
||||
await waitFor(() => {
|
||||
expect(capturedWorkflowNodes?.map(node => node.id)).toContain('latest-draft-node')
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'cancel' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedWorkflowNodes?.map(node => node.id)).toContain('published-node')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Inspect Vars', () => {
|
||||
it('should pass inspect vars handlers to WorkflowWithInnerContext', () => {
|
||||
renderSnippetMain()
|
||||
|
||||
@ -53,6 +53,20 @@ vi.mock('@/app/components/app/configuration/config-var/config-modal', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/next/link', () => ({
|
||||
default: ({
|
||||
children,
|
||||
href,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
href: string
|
||||
className?: string
|
||||
}) => (
|
||||
<a href={href} className={className}>{children}</a>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/start/components/var-list', () => ({
|
||||
default: (props: {
|
||||
list: InputVar[]
|
||||
@ -141,11 +155,7 @@ describe('SnippetSidebar', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('link', { name: /snippet\.management/i })).not.toBeInTheDocument()
|
||||
expect(screen.getByText(snippet.name)).toHaveAttribute('title', snippet.name)
|
||||
expect(screen.getByText(snippet.name)).toHaveClass('truncate')
|
||||
expect(screen.getByText(snippet.description)).toHaveAttribute('title', snippet.description)
|
||||
expect(screen.getByText(snippet.description)).toHaveClass('truncate')
|
||||
expect(screen.getByRole('link', { name: /snippet\.management/i })).toHaveAttribute('href', '/snippets')
|
||||
expect(screen.queryByRole('button', { name: /common\.operation\.add/i })).not.toBeInTheDocument()
|
||||
expect(capturedVarListProps?.readonly).toBe(true)
|
||||
})
|
||||
|
||||
@ -1,10 +1,15 @@
|
||||
import type { SnippetInputField } from '@/models/snippet'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
import { useSnippetDraftStore } from '../../../draft-store'
|
||||
import { useSnippetInputFieldActions } from '../use-snippet-input-field-actions'
|
||||
|
||||
const mockSyncInputFieldsDraft = vi.fn()
|
||||
const mockSetFields = vi.fn()
|
||||
|
||||
let snippetDetailStoreState: {
|
||||
fields: SnippetInputField[]
|
||||
setFields: typeof mockSetFields
|
||||
}
|
||||
|
||||
vi.mock('../../../hooks/use-nodes-sync-draft', () => ({
|
||||
useNodesSyncDraft: () => ({
|
||||
@ -12,6 +17,10 @@ vi.mock('../../../hooks/use-nodes-sync-draft', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../store', () => ({
|
||||
useSnippetDetailStore: (selector: (state: typeof snippetDetailStoreState) => unknown) => selector(snippetDetailStoreState),
|
||||
}))
|
||||
|
||||
const createField = (overrides: Partial<SnippetInputField> = {}): SnippetInputField => ({
|
||||
type: PipelineInputVarType.textInput,
|
||||
label: 'Blog URL',
|
||||
@ -23,13 +32,19 @@ const createField = (overrides: Partial<SnippetInputField> = {}): SnippetInputFi
|
||||
describe('useSnippetInputFieldActions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useSnippetDraftStore.getState().reset()
|
||||
snippetDetailStoreState = {
|
||||
fields: [],
|
||||
setFields: mockSetFields,
|
||||
}
|
||||
mockSetFields.mockImplementation((fields: SnippetInputField[]) => {
|
||||
snippetDetailStoreState.fields = fields
|
||||
})
|
||||
mockSyncInputFieldsDraft.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
describe('Field sync', () => {
|
||||
it('should update fields and sync the draft', () => {
|
||||
useSnippetDraftStore.getState().setInputFields([createField()])
|
||||
snippetDetailStoreState.fields = [createField()]
|
||||
const { result } = renderHook(() => useSnippetInputFieldActions({
|
||||
snippetId: 'snippet-1',
|
||||
}))
|
||||
@ -45,8 +60,8 @@ describe('useSnippetInputFieldActions', () => {
|
||||
result.current.handleFieldsChange(nextFields)
|
||||
})
|
||||
|
||||
expect(result.current.fields).toEqual(nextFields)
|
||||
expect(useSnippetDraftStore.getState().inputFields).toEqual(nextFields)
|
||||
expect(result.current.fields).toEqual([createField()])
|
||||
expect(mockSetFields).toHaveBeenCalledWith(nextFields)
|
||||
expect(mockSyncInputFieldsDraft).toHaveBeenCalledWith(nextFields, {
|
||||
onRefresh: expect.any(Function),
|
||||
})
|
||||
|
||||
@ -77,7 +77,7 @@ describe('useSnippetPublish', () => {
|
||||
expect(updateSnippetDetail({ is_published: false })).toEqual({ is_published: true })
|
||||
expect(mockSetPublishedAt).toHaveBeenCalledWith(1_712_345_678)
|
||||
expect(mockResetWorkflowVersionHistory).toHaveBeenCalledTimes(1)
|
||||
expect(toast.success).toHaveBeenCalledWith('snippet.publishSuccess')
|
||||
expect(toast.success).toHaveBeenCalledWith('snippet.saveSuccess')
|
||||
})
|
||||
|
||||
it('should not publish the snippet when checklist validation fails', async () => {
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import type { SnippetInputField } from '@/models/snippet'
|
||||
import { useCallback } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useSnippetDraftStore } from '../../draft-store'
|
||||
import { useNodesSyncDraft } from '../../hooks/use-nodes-sync-draft'
|
||||
import { useSnippetDetailStore } from '../../store'
|
||||
|
||||
type UseSnippetInputFieldActionsOptions = {
|
||||
canEdit?: boolean
|
||||
@ -15,25 +15,25 @@ export const useSnippetInputFieldActions = ({
|
||||
}: UseSnippetInputFieldActionsOptions) => {
|
||||
const { syncInputFieldsDraft } = useNodesSyncDraft(snippetId)
|
||||
const {
|
||||
inputFields,
|
||||
setInputFields,
|
||||
} = useSnippetDraftStore(useShallow(state => ({
|
||||
inputFields: state.inputFields,
|
||||
setInputFields: state.setInputFields,
|
||||
fields,
|
||||
setFields,
|
||||
} = useSnippetDetailStore(useShallow(state => ({
|
||||
fields: state.fields,
|
||||
setFields: state.setFields,
|
||||
})))
|
||||
|
||||
const handleFieldsChange = useCallback((newFields: SnippetInputField[]) => {
|
||||
if (!canEdit)
|
||||
return
|
||||
|
||||
setInputFields(newFields)
|
||||
setFields(newFields)
|
||||
void syncInputFieldsDraft(newFields, {
|
||||
onRefresh: setInputFields,
|
||||
onRefresh: setFields,
|
||||
})
|
||||
}, [canEdit, setInputFields, syncInputFieldsDraft])
|
||||
}, [canEdit, setFields, syncInputFieldsDraft])
|
||||
|
||||
return {
|
||||
fields: inputFields,
|
||||
fields,
|
||||
handleFieldsChange,
|
||||
}
|
||||
}
|
||||
|
||||
@ -42,7 +42,7 @@ export const useSnippetPublish = ({
|
||||
)
|
||||
workflowStore.getState().setPublishedAt(publishedWorkflow.created_at)
|
||||
resetWorkflowVersionHistory()
|
||||
toast.success(t('publishSuccess'))
|
||||
toast.success(t('saveSuccess'))
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
|
||||
@ -0,0 +1,78 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactElement } from 'react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type SaveBeforeLeavingDialogProps = {
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
trigger?: ReactElement
|
||||
disabled?: boolean
|
||||
saveDisabled?: boolean
|
||||
loading?: boolean
|
||||
onDiscard: () => void | Promise<void>
|
||||
onSave: () => void | Promise<void>
|
||||
}
|
||||
|
||||
const SaveBeforeLeavingDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
trigger,
|
||||
disabled,
|
||||
saveDisabled,
|
||||
loading,
|
||||
onDiscard,
|
||||
onSave,
|
||||
}: SaveBeforeLeavingDialogProps) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
{trigger && (
|
||||
<AlertDialogTrigger render={trigger} />
|
||||
)}
|
||||
<AlertDialogContent className="w-165">
|
||||
<div className="space-y-2 p-8 pb-12">
|
||||
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
|
||||
{t('saveBeforeLeavingTitle')}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="system-md-regular text-text-secondary">
|
||||
{t('saveBeforeLeavingDescription')}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogActions className="px-8 pt-0">
|
||||
<AlertDialogCancelButton disabled={disabled || loading}>
|
||||
{t('continueEditing')}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton
|
||||
tone="destructive"
|
||||
disabled={disabled || loading}
|
||||
onClick={onDiscard}
|
||||
>
|
||||
{t('doNotSave')}
|
||||
</AlertDialogConfirmButton>
|
||||
<AlertDialogConfirmButton
|
||||
tone="default"
|
||||
loading={loading}
|
||||
disabled={disabled || saveDisabled || loading}
|
||||
onClick={onSave}
|
||||
>
|
||||
{t('saveAndExit')}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default SaveBeforeLeavingDialog
|
||||
@ -7,17 +7,35 @@ import SnippetWorkflowPanel from './workflow-panel'
|
||||
type SnippetChildrenProps = {
|
||||
snippetId: string
|
||||
fields: SnippetInputField[]
|
||||
canDiscardChanges: boolean
|
||||
canEdit?: boolean
|
||||
canSave: boolean
|
||||
hasDraftChanges: boolean
|
||||
isEditing: boolean
|
||||
isPublishing: boolean
|
||||
onCancel: () => void
|
||||
onEdit: () => void
|
||||
onExitEditing: () => void | Promise<void>
|
||||
onExitEditingWithoutSave: () => void | Promise<void>
|
||||
onPublish: () => void
|
||||
onSaveAndExitEditing: () => void | Promise<void>
|
||||
}
|
||||
|
||||
const SnippetChildren = ({
|
||||
snippetId,
|
||||
fields,
|
||||
canDiscardChanges,
|
||||
canEdit = true,
|
||||
canSave,
|
||||
hasDraftChanges,
|
||||
isEditing,
|
||||
isPublishing,
|
||||
onCancel,
|
||||
onEdit,
|
||||
onExitEditing,
|
||||
onExitEditingWithoutSave,
|
||||
onPublish,
|
||||
onSaveAndExitEditing,
|
||||
}: SnippetChildrenProps) => {
|
||||
return (
|
||||
<>
|
||||
@ -25,9 +43,18 @@ const SnippetChildren = ({
|
||||
|
||||
<SnippetHeader
|
||||
snippetId={snippetId}
|
||||
canDiscardChanges={canDiscardChanges}
|
||||
canEdit={canEdit}
|
||||
canSave={canSave}
|
||||
hasDraftChanges={hasDraftChanges}
|
||||
isEditing={isEditing}
|
||||
isPublishing={isPublishing}
|
||||
onCancel={onCancel}
|
||||
onEdit={onEdit}
|
||||
onExitEditing={onExitEditing}
|
||||
onExitEditingWithoutSave={onExitEditingWithoutSave}
|
||||
onPublish={onPublish}
|
||||
onSaveAndExitEditing={onSaveAndExitEditing}
|
||||
/>
|
||||
|
||||
<SnippetWorkflowPanel
|
||||
|
||||
@ -1,28 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { SnippetPlaceholderIcon } from './snippet-placeholder-icon'
|
||||
|
||||
export function SnippetCollapsedPreview({
|
||||
inputFieldCount,
|
||||
}: {
|
||||
inputFieldCount: number
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-0 grow flex-col items-center px-2 pt-4"
|
||||
aria-label="Snippet collapsed preview"
|
||||
>
|
||||
<SnippetPlaceholderIcon />
|
||||
<div className="my-4 h-px w-8 rounded-full bg-divider-subtle" aria-hidden="true" />
|
||||
<div
|
||||
className="relative flex size-8 items-center justify-center rounded-lg border border-divider-subtle bg-background-default-subtle text-text-accent shadow-xs"
|
||||
aria-label={`${inputFieldCount} input fields`}
|
||||
>
|
||||
<span aria-hidden="true" className="i-custom-vender-solid-development-variable-02 size-5" />
|
||||
<span className="absolute -right-1.5 -bottom-1.5 flex size-4 items-center justify-center rounded-full border-2 border-components-panel-bg bg-state-accent-solid text-2xs leading-none text-text-primary-on-surface shadow-xs">
|
||||
{inputFieldCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import CancelChanges from '../cancel-changes'
|
||||
|
||||
describe('CancelChanges', () => {
|
||||
it('should render editing state without discard action when changes cannot be discarded', () => {
|
||||
render(<CancelChanges canDiscardChanges={false} onCancel={vi.fn()} />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'snippet.discardDraft' })).not.toBeInTheDocument()
|
||||
expect(screen.getByText('snippet.editingDraft')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should confirm before discarding draft changes', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onCancel = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
render(<CancelChanges canDiscardChanges onCancel={onCancel} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'snippet.discardDraft' }))
|
||||
|
||||
expect(screen.getByText('snippet.discardChangesTitle')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'snippet.discardChanges' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,8 +1,28 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { HeaderProps } from '@/app/components/workflow/header'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { expectLoadingButton } from '@/test/button'
|
||||
import SnippetHeader from '..'
|
||||
|
||||
vi.mock('@langgenius/dify-ui/alert-dialog', () => ({
|
||||
AlertDialog: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
AlertDialogActions: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
AlertDialogCancelButton: ({ children }: { children: ReactNode }) => <button type="button">{children}</button>,
|
||||
AlertDialogConfirmButton: ({
|
||||
children,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
children: ReactNode
|
||||
disabled?: boolean
|
||||
onClick?: () => void
|
||||
}) => <button type="button" disabled={disabled} onClick={onClick}>{children}</button>,
|
||||
AlertDialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
AlertDialogDescription: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
AlertDialogTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
AlertDialogTrigger: ({ children, render }: { children?: ReactNode, render?: ReactNode }) => render ?? <button type="button">{children}</button>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/header', () => ({
|
||||
default: (props: HeaderProps) => {
|
||||
return (
|
||||
@ -24,71 +44,242 @@ vi.mock('@/app/components/workflow/header', () => ({
|
||||
}))
|
||||
|
||||
describe('SnippetHeader', () => {
|
||||
const mockCancel = vi.fn()
|
||||
const mockEdit = vi.fn()
|
||||
const mockExitEditing = vi.fn()
|
||||
const mockExitEditingWithoutSave = vi.fn()
|
||||
const mockPublish = vi.fn()
|
||||
const mockSaveAndExit = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should configure workflow header slots and hide workflow-only controls', () => {
|
||||
render(
|
||||
<SnippetHeader
|
||||
snippetId="snippet-1"
|
||||
canSave
|
||||
isPublishing={false}
|
||||
onPublish={mockPublish}
|
||||
/>,
|
||||
)
|
||||
// Verifies the wrapper passes the expected workflow header configuration.
|
||||
describe('Rendering', () => {
|
||||
it('should configure workflow header slots and hide workflow-only controls', () => {
|
||||
render(
|
||||
<SnippetHeader
|
||||
snippetId="snippet-1"
|
||||
canDiscardChanges
|
||||
canSave
|
||||
hasDraftChanges={false}
|
||||
isEditing={false}
|
||||
isPublishing={false}
|
||||
onCancel={mockCancel}
|
||||
onEdit={mockEdit}
|
||||
onExitEditing={mockExitEditing}
|
||||
onExitEditingWithoutSave={mockExitEditingWithoutSave}
|
||||
onPublish={mockPublish}
|
||||
onSaveAndExitEditing={mockSaveAndExit}
|
||||
/>,
|
||||
)
|
||||
|
||||
const header = screen.getByTestId('workflow-header')
|
||||
expect(header).toHaveAttribute('data-show-env', 'false')
|
||||
expect(header).toHaveAttribute('data-show-global-variable', 'false')
|
||||
expect(header).toHaveAttribute('data-history-url', '/snippets/snippet-1/workflow-runs')
|
||||
expect(screen.getByRole('button', { name: /snippet\.publishButton/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /snippet\.testRunButton/i })).toBeInTheDocument()
|
||||
expect(screen.queryByText('snippet.viewOnly')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /snippet\.edit/i })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /snippet\.exitEditing/i })).not.toBeInTheDocument()
|
||||
const header = screen.getByTestId('workflow-header')
|
||||
expect(header).toHaveAttribute('data-show-env', 'false')
|
||||
expect(header).toHaveAttribute('data-show-global-variable', 'false')
|
||||
expect(header).toHaveAttribute('data-history-url', '/snippets/snippet-1/workflow-runs')
|
||||
expect(screen.getByText('snippet.viewOnly')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /snippet\.edit/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /snippet\.testRunButton/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should publish from the primary header action', () => {
|
||||
render(
|
||||
<SnippetHeader
|
||||
snippetId="snippet-1"
|
||||
canSave
|
||||
isPublishing={false}
|
||||
onPublish={mockPublish}
|
||||
/>,
|
||||
)
|
||||
// Verifies forwarded callbacks still drive the snippet-specific controls.
|
||||
describe('User Interactions', () => {
|
||||
it('should invoke the snippet callbacks when save and discard are clicked in editing mode', () => {
|
||||
render(
|
||||
<SnippetHeader
|
||||
snippetId="snippet-1"
|
||||
canDiscardChanges
|
||||
canSave
|
||||
hasDraftChanges
|
||||
isEditing
|
||||
isPublishing={false}
|
||||
onCancel={mockCancel}
|
||||
onEdit={mockEdit}
|
||||
onExitEditing={mockExitEditing}
|
||||
onExitEditingWithoutSave={mockExitEditingWithoutSave}
|
||||
onPublish={mockPublish}
|
||||
onSaveAndExitEditing={mockSaveAndExit}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /snippet\.publishButton/i }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /^snippet\.save$/i }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /snippet\.discardChanges/i }))
|
||||
|
||||
expect(mockPublish).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(mockPublish).toHaveBeenCalledTimes(1)
|
||||
expect(mockCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should disable publish when the current graph has no nodes', () => {
|
||||
render(
|
||||
<SnippetHeader
|
||||
snippetId="snippet-1"
|
||||
canSave={false}
|
||||
isPublishing={false}
|
||||
onPublish={mockPublish}
|
||||
/>,
|
||||
)
|
||||
it('should disable save actions when the current graph has no nodes', () => {
|
||||
render(
|
||||
<SnippetHeader
|
||||
snippetId="snippet-1"
|
||||
canDiscardChanges
|
||||
canSave={false}
|
||||
hasDraftChanges
|
||||
isEditing
|
||||
isPublishing={false}
|
||||
onCancel={mockCancel}
|
||||
onEdit={mockEdit}
|
||||
onExitEditing={mockExitEditing}
|
||||
onExitEditingWithoutSave={mockExitEditingWithoutSave}
|
||||
onPublish={mockPublish}
|
||||
onSaveAndExitEditing={mockSaveAndExit}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: /snippet\.publishButton/i })).toBeDisabled()
|
||||
})
|
||||
expect(screen.getByRole('button', { name: /^snippet\.save$/i })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: /snippet\.saveAndExit/i })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: /snippet\.doNotSave/i })).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('should show publish loading state while publishing', () => {
|
||||
render(
|
||||
<SnippetHeader
|
||||
snippetId="snippet-1"
|
||||
canSave
|
||||
isPublishing
|
||||
onPublish={mockPublish}
|
||||
/>,
|
||||
)
|
||||
it('should hide the discard draft action when there is no published workflow', () => {
|
||||
render(
|
||||
<SnippetHeader
|
||||
snippetId="snippet-1"
|
||||
canDiscardChanges={false}
|
||||
canSave
|
||||
hasDraftChanges
|
||||
isEditing
|
||||
isPublishing={false}
|
||||
onCancel={mockCancel}
|
||||
onEdit={mockEdit}
|
||||
onExitEditing={mockExitEditing}
|
||||
onExitEditingWithoutSave={mockExitEditingWithoutSave}
|
||||
onPublish={mockPublish}
|
||||
onSaveAndExitEditing={mockSaveAndExit}
|
||||
/>,
|
||||
)
|
||||
|
||||
expectLoadingButton(screen.getByRole('button', { name: /snippet\.publishButton/i }))
|
||||
expect(screen.queryByText('snippet.discardDraft')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('snippet.editingDraft')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should enter editing mode from the readonly header action', () => {
|
||||
render(
|
||||
<SnippetHeader
|
||||
snippetId="snippet-1"
|
||||
canDiscardChanges
|
||||
canSave
|
||||
hasDraftChanges={false}
|
||||
isEditing={false}
|
||||
isPublishing={false}
|
||||
onCancel={mockCancel}
|
||||
onEdit={mockEdit}
|
||||
onExitEditing={mockExitEditing}
|
||||
onExitEditingWithoutSave={mockExitEditingWithoutSave}
|
||||
onPublish={mockPublish}
|
||||
onSaveAndExitEditing={mockSaveAndExit}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'snippet.edit' }))
|
||||
|
||||
expect(mockEdit).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should exit editing immediately when there are no draft changes', () => {
|
||||
render(
|
||||
<SnippetHeader
|
||||
snippetId="snippet-1"
|
||||
canDiscardChanges
|
||||
canSave
|
||||
hasDraftChanges={false}
|
||||
isEditing
|
||||
isPublishing={false}
|
||||
onCancel={mockCancel}
|
||||
onEdit={mockEdit}
|
||||
onExitEditing={mockExitEditing}
|
||||
onExitEditingWithoutSave={mockExitEditingWithoutSave}
|
||||
onPublish={mockPublish}
|
||||
onSaveAndExitEditing={mockSaveAndExit}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'snippet.exitEditing' }))
|
||||
|
||||
expect(mockExitEditing).toHaveBeenCalledTimes(1)
|
||||
expect(mockExitEditingWithoutSave).not.toHaveBeenCalled()
|
||||
expect(mockSaveAndExit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should disable edit actions while publishing', () => {
|
||||
render(
|
||||
<SnippetHeader
|
||||
snippetId="snippet-1"
|
||||
canDiscardChanges
|
||||
canSave
|
||||
hasDraftChanges
|
||||
isEditing
|
||||
isPublishing
|
||||
onCancel={mockCancel}
|
||||
onEdit={mockEdit}
|
||||
onExitEditing={mockExitEditing}
|
||||
onExitEditingWithoutSave={mockExitEditingWithoutSave}
|
||||
onPublish={mockPublish}
|
||||
onSaveAndExitEditing={mockSaveAndExit}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'snippet.exitEditing' })).toBeDisabled()
|
||||
expectLoadingButton(screen.getByRole('button', { name: /^snippet\.save$/i }))
|
||||
expect(screen.getByRole('button', { name: 'snippet.doNotSave' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should discard changes from the exit confirmation dialog', async () => {
|
||||
render(
|
||||
<SnippetHeader
|
||||
snippetId="snippet-1"
|
||||
canDiscardChanges
|
||||
canSave
|
||||
hasDraftChanges
|
||||
isEditing
|
||||
isPublishing={false}
|
||||
onCancel={mockCancel}
|
||||
onEdit={mockEdit}
|
||||
onExitEditing={mockExitEditing}
|
||||
onExitEditingWithoutSave={mockExitEditingWithoutSave}
|
||||
onPublish={mockPublish}
|
||||
onSaveAndExitEditing={mockSaveAndExit}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'snippet.exitEditing' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'snippet.doNotSave' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockExitEditingWithoutSave).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(mockSaveAndExit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should save and exit from the exit confirmation dialog', async () => {
|
||||
render(
|
||||
<SnippetHeader
|
||||
snippetId="snippet-1"
|
||||
canDiscardChanges
|
||||
canSave
|
||||
hasDraftChanges
|
||||
isEditing
|
||||
isPublishing={false}
|
||||
onCancel={mockCancel}
|
||||
onEdit={mockEdit}
|
||||
onExitEditing={mockExitEditing}
|
||||
onExitEditingWithoutSave={mockExitEditingWithoutSave}
|
||||
onPublish={mockPublish}
|
||||
onSaveAndExitEditing={mockSaveAndExit}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'snippet.exitEditing' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'snippet.saveAndExit' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSaveAndExit).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(mockExitEditingWithoutSave).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -0,0 +1,81 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type CancelChangesProps = {
|
||||
canDiscardChanges: boolean
|
||||
onCancel: () => void | Promise<void>
|
||||
}
|
||||
|
||||
const CancelChanges = ({
|
||||
canDiscardChanges,
|
||||
onCancel,
|
||||
}: CancelChangesProps) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
const [open, setOpen] = useState(false)
|
||||
const [isDiscarding, setIsDiscarding] = useState(false)
|
||||
|
||||
const handleDiscardChanges = useCallback(async () => {
|
||||
setIsDiscarding(true)
|
||||
try {
|
||||
await onCancel()
|
||||
setOpen(false)
|
||||
}
|
||||
finally {
|
||||
setIsDiscarding(false)
|
||||
}
|
||||
}, [onCancel])
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 system-sm-regular">
|
||||
{canDiscardChanges && (
|
||||
<>
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogTrigger
|
||||
className="system-sm-semibold text-text-accent hover:text-text-accent-secondary"
|
||||
>
|
||||
{t('discardDraft')}
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent className="w-160">
|
||||
<div className="space-y-2 p-8 pb-12">
|
||||
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
|
||||
{t('discardChangesTitle')}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="system-md-regular text-text-secondary">
|
||||
{t('discardChangesDescription')}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogActions className="px-8 pt-0">
|
||||
<AlertDialogCancelButton disabled={isDiscarding}>
|
||||
{t('continueEditing')}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton
|
||||
loading={isDiscarding}
|
||||
disabled={isDiscarding}
|
||||
onClick={handleDiscardChanges}
|
||||
>
|
||||
{t('discardChanges')}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<span className="text-text-quaternary">·</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-text-tertiary">{t('editingDraft')}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(CancelChanges)
|
||||
@ -5,42 +5,128 @@ import { Button } from '@langgenius/dify-ui/button'
|
||||
import {
|
||||
memo,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Header from '@/app/components/workflow/header'
|
||||
import SaveBeforeLeavingDialog from '../save-before-leaving-dialog'
|
||||
import CancelChanges from './cancel-changes'
|
||||
import RunMode from './run-mode'
|
||||
|
||||
type SnippetHeaderProps = {
|
||||
snippetId: string
|
||||
canDiscardChanges: boolean
|
||||
canEdit?: boolean
|
||||
canSave: boolean
|
||||
hasDraftChanges: boolean
|
||||
isEditing: boolean
|
||||
isPublishing: boolean
|
||||
onCancel: () => void
|
||||
onEdit: () => void
|
||||
onExitEditing: () => void | Promise<void>
|
||||
onExitEditingWithoutSave: () => void | Promise<void>
|
||||
onPublish: () => void
|
||||
onSaveAndExitEditing: () => void | Promise<void>
|
||||
}
|
||||
|
||||
const PublishAction = ({
|
||||
canSave,
|
||||
isPublishing,
|
||||
onPublish,
|
||||
}: Pick<SnippetHeaderProps, 'canSave' | 'isPublishing' | 'onPublish'>) => {
|
||||
const ViewOnlyBadge = () => {
|
||||
const { t } = useTranslation('snippet')
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="primary"
|
||||
loading={isPublishing}
|
||||
disabled={isPublishing || !canSave}
|
||||
onClick={onPublish}
|
||||
>
|
||||
{t('publishButton')}
|
||||
</Button>
|
||||
<div className="rounded-md border border-components-badge-status-light-normal-border-inner bg-components-badge-bg-blue-light-soft px-1.5 py-0.5 system-xs-semibold-uppercase text-text-accent">
|
||||
{t('viewOnly')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const EditActions = ({
|
||||
canEdit = true,
|
||||
canSave,
|
||||
hasDraftChanges,
|
||||
isEditing,
|
||||
isPublishing,
|
||||
onEdit,
|
||||
onExitEditing,
|
||||
onExitEditingWithoutSave,
|
||||
onPublish,
|
||||
onSaveAndExitEditing,
|
||||
}: Pick<SnippetHeaderProps, 'canEdit' | 'canSave' | 'hasDraftChanges' | 'isEditing' | 'isPublishing' | 'onEdit' | 'onExitEditing' | 'onExitEditingWithoutSave' | 'onPublish' | 'onSaveAndExitEditing'>) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
const [exitConfirmOpen, setExitConfirmOpen] = useState(false)
|
||||
|
||||
if (!isEditing) {
|
||||
if (!canEdit)
|
||||
return null
|
||||
|
||||
return (
|
||||
<Button variant="primary" onClick={onEdit}>
|
||||
{t('edit')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SaveBeforeLeavingDialog
|
||||
open={exitConfirmOpen}
|
||||
onOpenChange={setExitConfirmOpen}
|
||||
trigger={(
|
||||
<Button
|
||||
disabled={isPublishing || !canEdit}
|
||||
onClick={(event) => {
|
||||
if (!canEdit)
|
||||
return
|
||||
|
||||
if (!hasDraftChanges) {
|
||||
event.preventDefault()
|
||||
void onExitEditing()
|
||||
return
|
||||
}
|
||||
|
||||
setExitConfirmOpen(true)
|
||||
}}
|
||||
>
|
||||
{t('exitEditing')}
|
||||
</Button>
|
||||
)}
|
||||
disabled={isPublishing || !canEdit}
|
||||
saveDisabled={!canEdit || !canSave}
|
||||
loading={isPublishing}
|
||||
onDiscard={async () => {
|
||||
await onExitEditingWithoutSave()
|
||||
setExitConfirmOpen(false)
|
||||
}}
|
||||
onSave={async () => {
|
||||
await onSaveAndExitEditing()
|
||||
setExitConfirmOpen(false)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
loading={isPublishing}
|
||||
disabled={isPublishing || !canEdit || !canSave}
|
||||
onClick={onPublish}
|
||||
>
|
||||
{t('save')}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const SnippetHeader = ({
|
||||
snippetId,
|
||||
canDiscardChanges,
|
||||
canEdit = true,
|
||||
canSave,
|
||||
hasDraftChanges,
|
||||
isEditing,
|
||||
isPublishing,
|
||||
onCancel,
|
||||
onEdit,
|
||||
onExitEditing,
|
||||
onExitEditingWithoutSave,
|
||||
onPublish,
|
||||
onSaveAndExitEditing,
|
||||
}: SnippetHeaderProps) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
const viewHistoryProps = useMemo(() => {
|
||||
@ -53,11 +139,21 @@ const SnippetHeader = ({
|
||||
return {
|
||||
normal: {
|
||||
components: {
|
||||
title: isEditing
|
||||
? (hasDraftChanges ? <CancelChanges canDiscardChanges={canDiscardChanges} onCancel={onCancel} /> : <></>)
|
||||
: <ViewOnlyBadge />,
|
||||
left: (
|
||||
<PublishAction
|
||||
<EditActions
|
||||
canEdit={canEdit}
|
||||
canSave={canSave}
|
||||
hasDraftChanges={hasDraftChanges}
|
||||
isEditing={isEditing}
|
||||
isPublishing={isPublishing}
|
||||
onEdit={onEdit}
|
||||
onExitEditing={onExitEditing}
|
||||
onExitEditingWithoutSave={onExitEditingWithoutSave}
|
||||
onPublish={onPublish}
|
||||
onSaveAndExitEditing={onSaveAndExitEditing}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@ -78,7 +174,7 @@ const SnippetHeader = ({
|
||||
viewHistoryProps,
|
||||
},
|
||||
}
|
||||
}, [canSave, isPublishing, onPublish, t, viewHistoryProps])
|
||||
}, [canDiscardChanges, canEdit, canSave, hasDraftChanges, isEditing, isPublishing, onCancel, onEdit, onExitEditing, onExitEditingWithoutSave, onPublish, onSaveAndExitEditing, t, viewHistoryProps])
|
||||
|
||||
return <Header {...headerProps} />
|
||||
}
|
||||
|
||||
@ -8,8 +8,8 @@ import { toast } from '@langgenius/dify-ui/toast'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -23,7 +23,9 @@ import {
|
||||
initialEdges,
|
||||
initialNodes,
|
||||
} from '@/app/components/workflow/utils'
|
||||
import { useSnippetDraftStore } from '../draft-store'
|
||||
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { useSnippetPublishedWorkflow } from '@/service/use-snippet-workflows'
|
||||
import { useConfigsMap } from '../hooks/use-configs-map'
|
||||
import { useGetRunAndTraceUrl } from '../hooks/use-get-run-and-trace-url'
|
||||
import { useInspectVarsCrud } from '../hooks/use-inspect-vars-crud'
|
||||
@ -32,9 +34,12 @@ import { useSnippetRefreshDraft } from '../hooks/use-snippet-refresh-draft'
|
||||
import { useSnippetRun } from '../hooks/use-snippet-run'
|
||||
import { useSnippetStartRun } from '../hooks/use-snippet-start-run'
|
||||
import { useSnippetDetailStore } from '../store'
|
||||
import { canCreateAndModifySnippets } from '../utils/permission'
|
||||
import { useSnippetInputFieldActions } from './hooks/use-snippet-input-field-actions'
|
||||
import { useSnippetPublish } from './hooks/use-snippet-publish'
|
||||
import SaveBeforeLeavingDialog from './save-before-leaving-dialog'
|
||||
import SnippetChildren from './snippet-children'
|
||||
import SnippetSidebar from './snippet-sidebar'
|
||||
|
||||
type SnippetMainProps = {
|
||||
payload: SnippetDetailPayload
|
||||
@ -50,9 +55,19 @@ type SnippetMainProps = {
|
||||
type SnippetMainContentProps = {
|
||||
snippetId: string
|
||||
fields: SnippetInputField[]
|
||||
canDiscardChanges: boolean
|
||||
canEdit: boolean
|
||||
canSave: boolean
|
||||
hasDraftChanges: boolean
|
||||
isEditing: boolean
|
||||
onBeforePublish: () => Promise<Omit<SnippetDraftSyncPayload, 'hash'> | void>
|
||||
onCancel: () => void | Promise<void>
|
||||
onDiscardRoute: () => void | Promise<void>
|
||||
onEdit: () => void
|
||||
onExitEditing: () => void | Promise<void>
|
||||
onExitEditingWithoutSave: () => void | Promise<void>
|
||||
onSaved: (syncedDraftPayload?: Omit<SnippetDraftSyncPayload, 'hash'> | void) => void
|
||||
onSavedAndExitEditing: () => void
|
||||
}
|
||||
|
||||
const unsupportedSnippetBlockTypes = new Set([
|
||||
@ -79,11 +94,23 @@ const hasSnippetDraftNodes = (payload?: Omit<SnippetDraftSyncPayload, 'hash'> |
|
||||
const SnippetMainContent = ({
|
||||
snippetId,
|
||||
fields,
|
||||
canDiscardChanges,
|
||||
canEdit,
|
||||
canSave,
|
||||
hasDraftChanges,
|
||||
isEditing,
|
||||
onBeforePublish,
|
||||
onCancel,
|
||||
onDiscardRoute,
|
||||
onEdit,
|
||||
onExitEditing,
|
||||
onExitEditingWithoutSave,
|
||||
onSaved,
|
||||
onSavedAndExitEditing,
|
||||
}: SnippetMainContentProps) => {
|
||||
const { push } = useRouter()
|
||||
const { t } = useTranslation('snippet')
|
||||
const [pendingHref, setPendingHref] = useState<string>()
|
||||
const {
|
||||
handlePublish,
|
||||
isPublishing,
|
||||
@ -108,42 +135,181 @@ const SnippetMainContent = ({
|
||||
return didSave
|
||||
}, [handlePublish, onBeforePublish, onSaved, t])
|
||||
|
||||
const handleSaveAndExitEditing = useCallback(async () => {
|
||||
const didSave = await handlePublishSnippet()
|
||||
if (didSave)
|
||||
onSavedAndExitEditing()
|
||||
}, [handlePublishSnippet, onSavedAndExitEditing])
|
||||
|
||||
const navigateToPendingHref = useCallback((href: string) => {
|
||||
const url = new URL(href, window.location.href)
|
||||
if (url.origin === window.location.origin)
|
||||
push(`${url.pathname}${url.search}${url.hash}`)
|
||||
else
|
||||
window.location.assign(url.href)
|
||||
}, [push])
|
||||
|
||||
const handleDiscardAndRoute = useCallback(async () => {
|
||||
if (!pendingHref)
|
||||
return
|
||||
|
||||
await onDiscardRoute()
|
||||
navigateToPendingHref(pendingHref)
|
||||
setPendingHref(undefined)
|
||||
}, [navigateToPendingHref, onDiscardRoute, pendingHref])
|
||||
|
||||
const handleSaveAndRoute = useCallback(async () => {
|
||||
if (!pendingHref)
|
||||
return
|
||||
|
||||
const didSave = await handlePublishSnippet()
|
||||
if (!didSave)
|
||||
return
|
||||
|
||||
navigateToPendingHref(pendingHref)
|
||||
setPendingHref(undefined)
|
||||
}, [handlePublishSnippet, navigateToPendingHref, pendingHref])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditing || !hasDraftChanges)
|
||||
return
|
||||
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault()
|
||||
event.returnValue = ''
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}, [hasDraftChanges, isEditing])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditing || !hasDraftChanges)
|
||||
return
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (
|
||||
event.defaultPrevented
|
||||
|| event.button !== 0
|
||||
|| event.metaKey
|
||||
|| event.ctrlKey
|
||||
|| event.shiftKey
|
||||
|| event.altKey
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const anchor = (event.target as Element | null)?.closest?.('a[href]')
|
||||
if (!(anchor instanceof HTMLAnchorElement))
|
||||
return
|
||||
|
||||
if (anchor.target && anchor.target !== '_self')
|
||||
return
|
||||
if (anchor.hasAttribute('download'))
|
||||
return
|
||||
|
||||
const nextUrl = new URL(anchor.href, window.location.href)
|
||||
const currentUrl = new URL(window.location.href)
|
||||
if (nextUrl.href === currentUrl.href)
|
||||
return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setPendingHref(nextUrl.href)
|
||||
}
|
||||
|
||||
document.addEventListener('click', handleClick, true)
|
||||
return () => document.removeEventListener('click', handleClick, true)
|
||||
}, [hasDraftChanges, isEditing])
|
||||
|
||||
return (
|
||||
<SnippetChildren
|
||||
snippetId={snippetId}
|
||||
fields={fields}
|
||||
canSave={canSave}
|
||||
isPublishing={isPublishing}
|
||||
onPublish={handlePublishSnippet}
|
||||
/>
|
||||
<>
|
||||
<SnippetChildren
|
||||
snippetId={snippetId}
|
||||
fields={fields}
|
||||
canDiscardChanges={canDiscardChanges}
|
||||
canEdit={canEdit}
|
||||
canSave={canSave}
|
||||
hasDraftChanges={hasDraftChanges}
|
||||
isEditing={isEditing}
|
||||
isPublishing={isPublishing}
|
||||
onCancel={onCancel}
|
||||
onEdit={onEdit}
|
||||
onExitEditing={onExitEditing}
|
||||
onExitEditingWithoutSave={onExitEditingWithoutSave}
|
||||
onPublish={handlePublishSnippet}
|
||||
onSaveAndExitEditing={handleSaveAndExitEditing}
|
||||
/>
|
||||
<SaveBeforeLeavingDialog
|
||||
open={!!pendingHref}
|
||||
onOpenChange={open => !open && setPendingHref(undefined)}
|
||||
disabled={isPublishing}
|
||||
saveDisabled={!canSave}
|
||||
loading={isPublishing}
|
||||
onDiscard={handleDiscardAndRoute}
|
||||
onSave={handleSaveAndRoute}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const SnippetMain = ({
|
||||
payload,
|
||||
draftPayload,
|
||||
hasInitialDraftChanges,
|
||||
hasPublishedWorkflow,
|
||||
snippetId,
|
||||
nodes,
|
||||
edges,
|
||||
viewport,
|
||||
draftNodes,
|
||||
draftEdges,
|
||||
draftViewport,
|
||||
}: SnippetMainProps) => {
|
||||
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
|
||||
const canCreateAndModifySnippet = canCreateAndModifySnippets(workspacePermissionKeys)
|
||||
const [isEditingState, setIsEditingState] = useState(!hasPublishedWorkflow)
|
||||
const isEditing = canCreateAndModifySnippet && isEditingState
|
||||
const [localDraftState, setLocalDraftState] = useState<LocalDraftState>()
|
||||
const [localDraftSnippetId, setLocalDraftSnippetId] = useState(snippetId)
|
||||
if (localDraftSnippetId !== snippetId) {
|
||||
const [draftChangeState, setDraftChangeState] = useState({
|
||||
initial: hasInitialDraftChanges,
|
||||
snippetId,
|
||||
value: hasInitialDraftChanges,
|
||||
})
|
||||
if (draftChangeState.snippetId !== snippetId || draftChangeState.initial !== hasInitialDraftChanges) {
|
||||
setLocalDraftState(undefined)
|
||||
setLocalDraftSnippetId(snippetId)
|
||||
setDraftChangeState({
|
||||
initial: hasInitialDraftChanges,
|
||||
snippetId,
|
||||
value: hasInitialDraftChanges,
|
||||
})
|
||||
}
|
||||
const hasDraftChanges = draftChangeState.value
|
||||
const currentCanvasNodeCount = useStore(state => state.nodes.filter(node => !node.data?._isTempNode).length)
|
||||
const skipNextForcedDraftSyncRef = useRef(false)
|
||||
const setHasDraftChanges = useCallback((value: boolean) => {
|
||||
setDraftChangeState(prev => ({
|
||||
...prev,
|
||||
value,
|
||||
}))
|
||||
}, [])
|
||||
const effectiveDraftPayload = localDraftState?.payload ?? draftPayload
|
||||
const effectiveDraftNodes = localDraftState?.nodes ?? draftNodes
|
||||
const effectiveDraftEdges = localDraftState?.edges ?? draftEdges
|
||||
const effectiveDraftViewport = localDraftState?.viewport ?? draftViewport
|
||||
const { graph, snippet } = effectiveDraftPayload
|
||||
const displayPayload = isEditing ? effectiveDraftPayload : payload
|
||||
const displayNodes = isEditing ? effectiveDraftNodes : nodes
|
||||
const displayEdges = isEditing ? effectiveDraftEdges : edges
|
||||
const displayViewport = isEditing ? effectiveDraftViewport : viewport
|
||||
const { graph, snippet } = displayPayload
|
||||
const canSave = currentCanvasNodeCount > 0
|
||||
const {
|
||||
doSyncWorkflowDraft: syncWorkflowDraft,
|
||||
syncInputFieldsDraft,
|
||||
syncWorkflowDraftWhenPageClose,
|
||||
} = useNodesSyncDraft(snippetId)
|
||||
const workflowStore = useWorkflowStore()
|
||||
const publishedWorkflowQuery = useSnippetPublishedWorkflow(snippetId)
|
||||
const { handleRefreshWorkflowDraft } = useSnippetRefreshDraft(snippetId)
|
||||
const {
|
||||
handleBackupDraft,
|
||||
@ -173,6 +339,10 @@ const SnippetMain = ({
|
||||
invalidateConversationVarValues,
|
||||
} = useInspectVarsCrud(snippetId)
|
||||
const workflowAvailableNodesMetaData = useAvailableNodesMetaData()
|
||||
const {
|
||||
data: publishedWorkflow,
|
||||
refetch: refetchPublishedWorkflow,
|
||||
} = publishedWorkflowQuery
|
||||
const availableNodesMetaData = useMemo(() => {
|
||||
const nodes = workflowAvailableNodesMetaData.nodes.filter(node =>
|
||||
!unsupportedSnippetBlockTypes.has(node.metaData.type))
|
||||
@ -194,23 +364,16 @@ const SnippetMain = ({
|
||||
}, [workflowAvailableNodesMetaData])
|
||||
const {
|
||||
reset,
|
||||
setNavigationState,
|
||||
setFields,
|
||||
} = useSnippetDetailStore(useShallow(state => ({
|
||||
reset: state.reset,
|
||||
setNavigationState: state.setNavigationState,
|
||||
})))
|
||||
const {
|
||||
hydrateDraft,
|
||||
setInputFields,
|
||||
} = useSnippetDraftStore(useShallow(state => ({
|
||||
hydrateDraft: state.hydrateDraft,
|
||||
setInputFields: state.setInputFields,
|
||||
setFields: state.setFields,
|
||||
})))
|
||||
const {
|
||||
fields,
|
||||
handleFieldsChange: handleSnippetFieldsChange,
|
||||
handleFieldsChange,
|
||||
} = useSnippetInputFieldActions({
|
||||
canEdit: true,
|
||||
canEdit: canCreateAndModifySnippet,
|
||||
snippetId,
|
||||
})
|
||||
const {
|
||||
@ -222,53 +385,67 @@ const SnippetMain = ({
|
||||
const { getWorkflowRunAndTraceUrl } = useGetRunAndTraceUrl(snippetId)
|
||||
useEffect(() => {
|
||||
reset()
|
||||
|
||||
return () => reset()
|
||||
}, [reset, snippetId])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
hydrateDraft({
|
||||
snippetId,
|
||||
inputFields: effectiveDraftPayload.inputFields,
|
||||
})
|
||||
}, [effectiveDraftPayload.inputFields, hydrateDraft, snippetId])
|
||||
useEffect(() => {
|
||||
setFields(displayPayload.inputFields)
|
||||
}, [displayPayload.inputFields, setFields, snippetId])
|
||||
|
||||
useEffect(() => {
|
||||
workflowStore.setState({ canvasReadOnly: false })
|
||||
workflowStore.setState({ canvasReadOnly: !isEditing })
|
||||
|
||||
return () => {
|
||||
workflowStore.setState({ canvasReadOnly: false })
|
||||
}
|
||||
}, [workflowStore])
|
||||
}, [isEditing, workflowStore])
|
||||
|
||||
useEffect(() => {
|
||||
workflowStore.temporal.getState().pause()
|
||||
workflowStore.getState().setWorkflowHistory({
|
||||
nodes: effectiveDraftNodes,
|
||||
edges: effectiveDraftEdges,
|
||||
nodes: displayNodes,
|
||||
edges: displayEdges,
|
||||
workflowHistoryEvent: undefined,
|
||||
workflowHistoryEventMeta: undefined,
|
||||
})
|
||||
workflowStore.temporal.getState().clear()
|
||||
workflowStore.temporal.getState().resume()
|
||||
}, [effectiveDraftEdges, effectiveDraftNodes, workflowStore])
|
||||
}, [displayEdges, displayNodes, workflowStore])
|
||||
|
||||
const doSyncWorkflowDraft = useCallback((
|
||||
...args: Parameters<typeof syncWorkflowDraft>
|
||||
) => syncWorkflowDraft(...args), [syncWorkflowDraft])
|
||||
) => {
|
||||
if (!canCreateAndModifySnippet || !isEditing)
|
||||
return Promise.resolve()
|
||||
|
||||
const handleFieldsChange = useCallback((nextFields: SnippetInputField[]) => {
|
||||
handleSnippetFieldsChange(nextFields)
|
||||
}, [handleSnippetFieldsChange])
|
||||
const [
|
||||
notRefreshWhenSyncError,
|
||||
callback,
|
||||
] = args
|
||||
if (skipNextForcedDraftSyncRef.current && notRefreshWhenSyncError === true && !callback) {
|
||||
skipNextForcedDraftSyncRef.current = false
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setNavigationState({
|
||||
snippetId,
|
||||
snippet,
|
||||
readonly: false,
|
||||
onFieldsChange: handleFieldsChange,
|
||||
})
|
||||
}, [handleFieldsChange, setNavigationState, snippet, snippetId])
|
||||
if (isEditing)
|
||||
setHasDraftChanges(true)
|
||||
|
||||
return syncWorkflowDraft(...args)
|
||||
}, [canCreateAndModifySnippet, isEditing, setHasDraftChanges, syncWorkflowDraft])
|
||||
|
||||
const syncWorkflowDraftWhenPageCloseInEditing = useCallback(() => {
|
||||
if (!canCreateAndModifySnippet || !isEditing)
|
||||
return
|
||||
|
||||
syncWorkflowDraftWhenPageClose()
|
||||
}, [canCreateAndModifySnippet, isEditing, syncWorkflowDraftWhenPageClose])
|
||||
|
||||
const handleFieldsChangeInEditing = useCallback((nextFields: SnippetInputField[]) => {
|
||||
if (!canCreateAndModifySnippet || !isEditing)
|
||||
return
|
||||
|
||||
handleFieldsChange(nextFields)
|
||||
setHasDraftChanges(true)
|
||||
}, [canCreateAndModifySnippet, handleFieldsChange, isEditing, setHasDraftChanges])
|
||||
|
||||
const updateLocalDraftFromSyncPayload = useCallback((
|
||||
syncedDraftPayload?: Omit<SnippetDraftSyncPayload, 'hash'> | void,
|
||||
@ -299,13 +476,70 @@ const SnippetMain = ({
|
||||
edges: initialEdges(draftGraph.edges, draftGraph.nodes),
|
||||
viewport: draftGraph.viewport,
|
||||
})
|
||||
setInputFields(inputFields)
|
||||
}, [draftPayload, fields, setInputFields])
|
||||
setFields(inputFields)
|
||||
}, [draftPayload, fields, setFields])
|
||||
|
||||
const handleCancelChanges = useCallback(async () => {
|
||||
if (!canCreateAndModifySnippet)
|
||||
return
|
||||
|
||||
const workflow = publishedWorkflow ?? (await refetchPublishedWorkflow()).data
|
||||
if (!workflow)
|
||||
return
|
||||
|
||||
handleRestoreFromPublishedWorkflow(workflow as never)
|
||||
|
||||
const publishedInputFields = Array.isArray(workflow.input_fields)
|
||||
? workflow.input_fields as SnippetInputField[]
|
||||
: []
|
||||
updateLocalDraftFromSyncPayload({
|
||||
graph: workflow.graph,
|
||||
input_fields: publishedInputFields,
|
||||
})
|
||||
void syncInputFieldsDraft(publishedInputFields, {
|
||||
onRefresh: setFields,
|
||||
})
|
||||
setHasDraftChanges(false)
|
||||
}, [canCreateAndModifySnippet, handleRestoreFromPublishedWorkflow, publishedWorkflow, refetchPublishedWorkflow, setFields, setHasDraftChanges, syncInputFieldsDraft, updateLocalDraftFromSyncPayload])
|
||||
|
||||
const handleExitEditing = useCallback(async () => {
|
||||
if (!canCreateAndModifySnippet || hasDraftChanges)
|
||||
return
|
||||
|
||||
setIsEditingState(false)
|
||||
}, [canCreateAndModifySnippet, hasDraftChanges])
|
||||
|
||||
const handleExitEditingWithoutSave = useCallback(async () => {
|
||||
if (!canCreateAndModifySnippet)
|
||||
return
|
||||
|
||||
const syncedDraftPayload = await syncWorkflowDraft(true)
|
||||
updateLocalDraftFromSyncPayload(syncedDraftPayload)
|
||||
skipNextForcedDraftSyncRef.current = true
|
||||
setIsEditingState(false)
|
||||
}, [canCreateAndModifySnippet, syncWorkflowDraft, updateLocalDraftFromSyncPayload])
|
||||
|
||||
const handleDiscardAndRoute = useCallback(async () => {
|
||||
if (!canCreateAndModifySnippet)
|
||||
return
|
||||
|
||||
const syncedDraftPayload = await syncWorkflowDraft(true)
|
||||
updateLocalDraftFromSyncPayload(syncedDraftPayload)
|
||||
skipNextForcedDraftSyncRef.current = true
|
||||
}, [canCreateAndModifySnippet, syncWorkflowDraft, updateLocalDraftFromSyncPayload])
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
if (!canCreateAndModifySnippet)
|
||||
return
|
||||
|
||||
skipNextForcedDraftSyncRef.current = true
|
||||
setIsEditingState(true)
|
||||
}, [canCreateAndModifySnippet])
|
||||
|
||||
const hooksStore = useMemo(() => {
|
||||
return {
|
||||
doSyncWorkflowDraft,
|
||||
syncWorkflowDraftWhenPageClose,
|
||||
syncWorkflowDraftWhenPageClose: syncWorkflowDraftWhenPageCloseInEditing,
|
||||
handleRefreshWorkflowDraft,
|
||||
handleBackupDraft,
|
||||
handleLoadBackupDraft,
|
||||
@ -361,25 +595,47 @@ const SnippetMain = ({
|
||||
renameInspectVarName,
|
||||
resetConversationVar,
|
||||
resetToLastRunVar,
|
||||
syncWorkflowDraftWhenPageClose,
|
||||
syncWorkflowDraftWhenPageCloseInEditing,
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full min-h-0 min-w-0">
|
||||
<SnippetSidebar
|
||||
snippet={snippet}
|
||||
fields={fields}
|
||||
readonly={!isEditing}
|
||||
onFieldsChange={handleFieldsChangeInEditing}
|
||||
/>
|
||||
<div className="relative min-h-0 min-w-0 grow">
|
||||
<WorkflowWithInnerContext
|
||||
key={`${snippetId}-draft`}
|
||||
nodes={effectiveDraftNodes}
|
||||
edges={effectiveDraftEdges}
|
||||
viewport={effectiveDraftViewport ?? graph.viewport}
|
||||
key={`${snippetId}-${isEditing ? 'draft' : 'published'}`}
|
||||
nodes={displayNodes}
|
||||
edges={displayEdges}
|
||||
viewport={displayViewport ?? graph.viewport}
|
||||
hooksStore={hooksStore as unknown as Partial<HooksStoreShape>}
|
||||
>
|
||||
<SnippetMainContent
|
||||
snippetId={snippetId}
|
||||
fields={fields}
|
||||
canDiscardChanges={hasPublishedWorkflow}
|
||||
canEdit={canCreateAndModifySnippet}
|
||||
canSave={canSave}
|
||||
hasDraftChanges={hasDraftChanges}
|
||||
isEditing={isEditing}
|
||||
onBeforePublish={() => syncWorkflowDraft(true)}
|
||||
onSaved={updateLocalDraftFromSyncPayload}
|
||||
onCancel={handleCancelChanges}
|
||||
onDiscardRoute={handleDiscardAndRoute}
|
||||
onEdit={handleEdit}
|
||||
onExitEditing={handleExitEditing}
|
||||
onExitEditingWithoutSave={handleExitEditingWithoutSave}
|
||||
onSaved={(syncedDraftPayload) => {
|
||||
updateLocalDraftFromSyncPayload(syncedDraftPayload)
|
||||
setHasDraftChanges(false)
|
||||
}}
|
||||
onSavedAndExitEditing={() => {
|
||||
setHasDraftChanges(false)
|
||||
setIsEditingState(false)
|
||||
}}
|
||||
/>
|
||||
</WorkflowWithInnerContext>
|
||||
</div>
|
||||
|
||||
@ -1,30 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
|
||||
type SnippetPlaceholderIconProps = {
|
||||
className?: string
|
||||
graphicClassName?: string
|
||||
}
|
||||
|
||||
export function SnippetPlaceholderIcon({
|
||||
className,
|
||||
graphicClassName,
|
||||
}: SnippetPlaceholderIconProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-10 items-center justify-center rounded-[10px] border border-divider-subtle bg-background-default-subtle text-text-tertiary shadow-xs',
|
||||
className,
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className={cn('relative block size-8', graphicClassName)}>
|
||||
<span className="absolute top-1/2 left-1/2 h-4 w-0.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-util-colors-blue-blue-500" />
|
||||
<span className="absolute top-0.5 left-0.5 size-2.5 rounded-xs bg-util-colors-blue-blue-300 shadow-xs" />
|
||||
<span className="absolute top-1/2 right-0.5 size-2.5 -translate-y-1/2 rounded-xs bg-util-colors-blue-blue-600 shadow-xs" />
|
||||
<span className="absolute bottom-0.5 left-0.5 size-2.5 rounded-xs bg-util-colors-indigo-indigo-400 shadow-xs" />
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -11,8 +11,8 @@ import SnippetInfoDropdown from '@/app/components/app-sidebar/snippet-info/dropd
|
||||
import ConfigVarModal from '@/app/components/app/configuration/config-var/config-modal'
|
||||
import Field from '@/app/components/workflow/nodes/_base/components/field'
|
||||
import VarList from '@/app/components/workflow/nodes/start/components/var-list'
|
||||
import Link from '@/next/link'
|
||||
import { hasDuplicateStr } from '@/utils/var'
|
||||
import { SnippetPlaceholderIcon } from './snippet-placeholder-icon'
|
||||
|
||||
type SnippetSidebarProps = {
|
||||
snippet: SnippetDetail
|
||||
@ -21,10 +21,6 @@ type SnippetSidebarProps = {
|
||||
onFieldsChange: (fields: SnippetInputField[]) => void
|
||||
}
|
||||
|
||||
type SnippetSidebarContentProps = SnippetSidebarProps & {
|
||||
className?: string
|
||||
}
|
||||
|
||||
const toWorkflowInputVar = (field: SnippetInputField): InputVar => ({
|
||||
...field,
|
||||
type: field.type as unknown as InputVar['type'],
|
||||
@ -36,13 +32,12 @@ const toSnippetInputField = (field: InputVar): SnippetInputField => ({
|
||||
type: field.type as unknown as SnippetInputField['type'],
|
||||
})
|
||||
|
||||
export const SnippetSidebarContent = ({
|
||||
const SnippetSidebar = ({
|
||||
snippet,
|
||||
fields,
|
||||
readonly,
|
||||
onFieldsChange,
|
||||
className,
|
||||
}: SnippetSidebarContentProps) => {
|
||||
}: SnippetSidebarProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [isShowAddVarModal, setIsShowAddVarModal] = useState(false)
|
||||
const workflowInputVars = useMemo(() => fields.map(toWorkflowInputVar), [fields])
|
||||
@ -93,23 +88,32 @@ export const SnippetSidebarContent = ({
|
||||
}, [fields, onFieldsChange])
|
||||
|
||||
return (
|
||||
<div className={cn('flex h-full min-h-0 flex-col overflow-hidden bg-background-default', className)}>
|
||||
<div className="shrink-0 px-3 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<SnippetPlaceholderIcon />
|
||||
<aside className="flex h-full w-90 shrink-0 flex-col overflow-hidden rounded-tl-2xl border-r border-divider-subtle bg-background-default">
|
||||
<div className="shrink-0 px-6 pt-7">
|
||||
<Link
|
||||
href="/snippets"
|
||||
className="inline-flex items-center gap-2 system-sm-semibold-uppercase text-text-primary hover:text-text-accent"
|
||||
>
|
||||
<span aria-hidden className="i-ri-arrow-left-line h-4 w-4" />
|
||||
{t('management', { ns: 'snippet' })}
|
||||
</Link>
|
||||
|
||||
<div className="mt-12 flex items-start gap-3">
|
||||
<div className="min-w-0 grow">
|
||||
<div className="truncate system-xl-semibold text-text-primary" title={snippet.name}>{snippet.name}</div>
|
||||
<div className="system-xl-semibold text-text-primary">{snippet.name}</div>
|
||||
{!!snippet.description && (
|
||||
<div className="mt-3 system-sm-regular text-text-tertiary">
|
||||
{snippet.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SnippetInfoDropdown snippet={snippet} />
|
||||
</div>
|
||||
{!!snippet.description && (
|
||||
<div className="mt-2 truncate system-sm-regular text-text-tertiary" title={snippet.description}>
|
||||
{snippet.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 grow flex-col px-3 pt-6">
|
||||
<div className="mx-6 mt-7 h-px shrink-0 bg-divider-subtle" />
|
||||
|
||||
<div className="flex min-h-0 grow flex-col px-6 pt-7">
|
||||
<Field
|
||||
title={t('inputVariables', { ns: 'snippet' })}
|
||||
operations={!readonly
|
||||
@ -147,14 +151,6 @@ export const SnippetSidebarContent = ({
|
||||
varKeys={fields.map(v => v.variable)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SnippetSidebar = (props: SnippetSidebarProps) => {
|
||||
return (
|
||||
<aside className="flex h-full w-90 shrink-0 flex-col overflow-hidden rounded-tl-2xl border-r border-divider-subtle bg-background-default">
|
||||
<SnippetSidebarContent {...props} />
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,36 +0,0 @@
|
||||
import type { SnippetInputField } from '@/models/snippet'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
import { useSnippetDraftStore } from '..'
|
||||
|
||||
const createField = (variable: string): SnippetInputField => ({
|
||||
label: variable,
|
||||
variable,
|
||||
type: PipelineInputVarType.textInput,
|
||||
required: true,
|
||||
})
|
||||
|
||||
describe('useSnippetDraftStore', () => {
|
||||
beforeEach(() => {
|
||||
useSnippetDraftStore.getState().reset()
|
||||
})
|
||||
|
||||
it('should store and reset snippet input fields', () => {
|
||||
const inputFields = [
|
||||
createField('topic'),
|
||||
createField('audience'),
|
||||
]
|
||||
|
||||
useSnippetDraftStore.getState().hydrateDraft({
|
||||
snippetId: 'snippet-1',
|
||||
inputFields,
|
||||
})
|
||||
|
||||
expect(useSnippetDraftStore.getState().snippetId).toBe('snippet-1')
|
||||
expect(useSnippetDraftStore.getState().inputFields).toEqual(inputFields)
|
||||
|
||||
useSnippetDraftStore.getState().reset()
|
||||
|
||||
expect(useSnippetDraftStore.getState().snippetId).toBeUndefined()
|
||||
expect(useSnippetDraftStore.getState().inputFields).toEqual([])
|
||||
})
|
||||
})
|
||||
@ -1,24 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { SnippetInputField } from '@/models/snippet'
|
||||
import { create } from 'zustand'
|
||||
|
||||
type SnippetDraftState = {
|
||||
snippetId?: string
|
||||
inputFields: SnippetInputField[]
|
||||
hydrateDraft: (payload: { snippetId: string, inputFields: SnippetInputField[] }) => void
|
||||
setInputFields: (inputFields: SnippetInputField[]) => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
snippetId: undefined,
|
||||
inputFields: [] as SnippetInputField[],
|
||||
}
|
||||
|
||||
export const useSnippetDraftStore = create<SnippetDraftState>(set => ({
|
||||
...initialState,
|
||||
hydrateDraft: ({ snippetId, inputFields }) => set({ snippetId, inputFields }),
|
||||
setInputFields: inputFields => set({ inputFields }),
|
||||
reset: () => set(initialState),
|
||||
}))
|
||||
@ -1,7 +1,7 @@
|
||||
import type { SnippetInputField } from '@/models/snippet'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
import { useSnippetDraftStore } from '../../draft-store'
|
||||
import { useSnippetDetailStore } from '../../store'
|
||||
import { useNodesSyncDraft } from '../use-nodes-sync-draft'
|
||||
|
||||
const mockGetNodes = vi.fn()
|
||||
@ -112,7 +112,9 @@ describe('snippet/use-nodes-sync-draft', () => {
|
||||
mockSetSyncWorkflowDraftHash.mockImplementation((hash: string) => {
|
||||
workflowStoreState.syncWorkflowDraftHash = hash
|
||||
})
|
||||
useSnippetDraftStore.getState().setInputFields([createInputField('topic')])
|
||||
useSnippetDetailStore.setState({
|
||||
fields: [createInputField('topic')],
|
||||
})
|
||||
})
|
||||
|
||||
it('should include current input_fields when syncing the draft graph', async () => {
|
||||
@ -137,18 +139,6 @@ describe('snippet/use-nodes-sync-draft', () => {
|
||||
expect(mockUseNodesReadOnlyByCanEdit).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should keep draft input_fields when the navigation store is reset during route leave', () => {
|
||||
const { result } = renderHook(() => useNodesSyncDraft('snippet-1'))
|
||||
|
||||
act(() => {
|
||||
result.current.syncWorkflowDraftWhenPageClose()
|
||||
})
|
||||
|
||||
expect(mockPostWithKeepalive).toHaveBeenCalledWith('/api/snippets/snippet-1/workflows/draft', expect.objectContaining({
|
||||
input_fields: [createInputField('topic')],
|
||||
}))
|
||||
})
|
||||
|
||||
it('should snapshot graph before queued draft sync executes', async () => {
|
||||
deferSerialCallbacks = true
|
||||
const { result } = renderHook(() => useNodesSyncDraft('snippet-1'))
|
||||
|
||||
@ -31,8 +31,8 @@ vi.mock('@/app/components/workflow/store', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../draft-store', () => ({
|
||||
useSnippetDraftStore: {
|
||||
vi.mock('../../store', () => ({
|
||||
useSnippetDetailStore: {
|
||||
setState: (...args: unknown[]) => mockSnippetSetState(...args),
|
||||
},
|
||||
}))
|
||||
@ -75,7 +75,7 @@ describe('useSnippetRefreshDraft', () => {
|
||||
})
|
||||
expect(mockFetchSnippetDraftWorkflow).toHaveBeenCalledWith('snippet-1')
|
||||
expect(mockSnippetSetState).toHaveBeenCalledWith({
|
||||
inputFields: [],
|
||||
fields: [],
|
||||
})
|
||||
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('draft-hash')
|
||||
expect(mockSetDraftUpdatedAt).toHaveBeenCalledWith(1_712_345_678)
|
||||
|
||||
@ -4,7 +4,7 @@ import { act } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
import { useSnippetDraftStore } from '../../draft-store'
|
||||
import { useSnippetDetailStore } from '../../store'
|
||||
import { useSnippetStartRun } from '../use-snippet-start-run'
|
||||
|
||||
const mockWorkflowStoreGetState = vi.fn()
|
||||
@ -39,7 +39,7 @@ const inputFields: SnippetInputField[] = [
|
||||
describe('useSnippetStartRun', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useSnippetDraftStore.getState().reset()
|
||||
useSnippetDetailStore.getState().reset()
|
||||
mockWorkflowStoreGetState.mockReturnValue({
|
||||
workflowRunningData: undefined,
|
||||
showDebugAndPreviewPanel: false,
|
||||
@ -51,7 +51,7 @@ describe('useSnippetStartRun', () => {
|
||||
})
|
||||
|
||||
it('should open the debug panel and input form when snippet has input fields', () => {
|
||||
useSnippetDraftStore.getState().setInputFields(inputFields)
|
||||
useSnippetDetailStore.setState({ fields: inputFields })
|
||||
|
||||
const { result } = renderHook(() => useSnippetStartRun({
|
||||
handleRun: mockHandleRun,
|
||||
@ -83,7 +83,7 @@ describe('useSnippetStartRun', () => {
|
||||
})
|
||||
|
||||
it('should use current snippet input fields from the store before starting a run', () => {
|
||||
useSnippetDraftStore.getState().setInputFields(inputFields)
|
||||
useSnippetDetailStore.setState({ fields: inputFields })
|
||||
|
||||
const { result } = renderHook(() => useSnippetStartRun({
|
||||
handleRun: mockHandleRun,
|
||||
@ -99,7 +99,7 @@ describe('useSnippetStartRun', () => {
|
||||
})
|
||||
|
||||
it('should close the panel when debug panel is already open', () => {
|
||||
useSnippetDraftStore.getState().setInputFields(inputFields)
|
||||
useSnippetDetailStore.setState({ fields: inputFields })
|
||||
|
||||
mockWorkflowStoreGetState.mockReturnValue({
|
||||
workflowRunningData: undefined,
|
||||
@ -122,7 +122,7 @@ describe('useSnippetStartRun', () => {
|
||||
})
|
||||
|
||||
it('should do nothing when workflow is already running', () => {
|
||||
useSnippetDraftStore.getState().setInputFields(inputFields)
|
||||
useSnippetDetailStore.setState({ fields: inputFields })
|
||||
|
||||
mockWorkflowStoreGetState.mockReturnValue({
|
||||
workflowRunningData: {
|
||||
|
||||
@ -8,9 +8,8 @@ import { useNodesReadOnlyByCanEdit } from '@/app/components/workflow/hooks/use-w
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { API_PREFIX } from '@/config'
|
||||
import { consoleClient } from '@/service/client'
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { postWithKeepalive } from '@/service/fetch'
|
||||
import { useSnippetDraftStore } from '../draft-store'
|
||||
import { useSnippetDetailStore } from '../store'
|
||||
import { useSnippetRefreshDraft } from './use-snippet-refresh-draft'
|
||||
|
||||
const isSyncConflictError = (error: unknown): error is { bodyUsed: boolean, json: () => Promise<{ code?: string }> } => {
|
||||
@ -52,7 +51,7 @@ export const useNodesSyncDraft = (snippetId: string) => {
|
||||
|
||||
const getInputFieldsSyncPayload = useCallback((inputFields?: SnippetInputField[]) => {
|
||||
return {
|
||||
input_fields: inputFields ?? useSnippetDraftStore.getState().inputFields,
|
||||
input_fields: inputFields ?? useSnippetDetailStore.getState().fields,
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ import { useCallback } from 'react'
|
||||
import { useWorkflowUpdate } from '@/app/components/workflow/hooks'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { fetchSnippetDraftWorkflow } from '@/service/use-snippet-workflows'
|
||||
import { useSnippetDraftStore } from '../draft-store'
|
||||
import { useSnippetDetailStore } from '../store'
|
||||
|
||||
export const useSnippetRefreshDraft = (snippetId: string) => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
@ -36,8 +36,8 @@ export const useSnippetRefreshDraft = (snippetId: string) => {
|
||||
edges: response.graph?.edges || [],
|
||||
viewport: response.graph?.viewport || { x: 0, y: 0, zoom: 1 },
|
||||
} as WorkflowDataUpdater)
|
||||
useSnippetDraftStore.setState({
|
||||
inputFields,
|
||||
useSnippetDetailStore.setState({
|
||||
fields: inputFields,
|
||||
})
|
||||
setSyncWorkflowDraftHash(response.hash)
|
||||
setDraftUpdatedAt(response.updated_at)
|
||||
|
||||
@ -3,7 +3,7 @@ import { useCallback } from 'react'
|
||||
import { useWorkflowInteractions } from '@/app/components/workflow/hooks'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import { useSnippetDraftStore } from '../draft-store'
|
||||
import { useSnippetDetailStore } from '../store'
|
||||
|
||||
type UseSnippetStartRunOptions = {
|
||||
handleRun: (params: SnippetDraftRunPayload) => void
|
||||
@ -38,7 +38,7 @@ export const useSnippetStartRun = ({
|
||||
|
||||
setShowDebugAndPreviewPanel(true)
|
||||
|
||||
const currentInputFields = useSnippetDraftStore.getState().inputFields
|
||||
const currentInputFields = useSnippetDetailStore.getState().fields
|
||||
|
||||
if (currentInputFields.length > 0) {
|
||||
setShowInputsPanel(true)
|
||||
|
||||
@ -1,44 +1,31 @@
|
||||
import type { SnippetDetail } from '@/models/snippet'
|
||||
import type { SnippetInputField } from '@/models/snippet'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
import { useSnippetDetailStore } from '..'
|
||||
|
||||
const snippet: SnippetDetail = {
|
||||
id: 'snippet-1',
|
||||
name: 'Snippet',
|
||||
description: 'Description',
|
||||
updatedAt: '2026-03-29 10:00',
|
||||
usage: '0',
|
||||
tags: [],
|
||||
}
|
||||
const createField = (variable: string): SnippetInputField => ({
|
||||
label: variable,
|
||||
variable,
|
||||
type: PipelineInputVarType.textInput,
|
||||
required: true,
|
||||
})
|
||||
|
||||
describe('useSnippetDetailStore', () => {
|
||||
beforeEach(() => {
|
||||
useSnippetDetailStore.getState().reset()
|
||||
})
|
||||
|
||||
it('should store and reset snippet navigation state', () => {
|
||||
const onFieldsChange = vi.fn()
|
||||
it('should store and reset snippet input fields', () => {
|
||||
const fields = [
|
||||
createField('topic'),
|
||||
createField('audience'),
|
||||
]
|
||||
|
||||
useSnippetDetailStore.getState().setNavigationState({
|
||||
snippetId: 'snippet-1',
|
||||
snippet,
|
||||
readonly: false,
|
||||
onFieldsChange,
|
||||
})
|
||||
useSnippetDetailStore.getState().setFields(fields)
|
||||
|
||||
expect(useSnippetDetailStore.getState()).toMatchObject({
|
||||
snippetId: 'snippet-1',
|
||||
snippet,
|
||||
readonly: false,
|
||||
onFieldsChange,
|
||||
})
|
||||
expect(useSnippetDetailStore.getState().fields).toEqual(fields)
|
||||
|
||||
useSnippetDetailStore.getState().reset()
|
||||
|
||||
expect(useSnippetDetailStore.getState()).toMatchObject({
|
||||
readonly: true,
|
||||
snippet: undefined,
|
||||
snippetId: undefined,
|
||||
onFieldsChange: undefined,
|
||||
})
|
||||
expect(useSnippetDetailStore.getState().fields).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,29 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import type { SnippetDetail, SnippetInputField } from '@/models/snippet'
|
||||
import type { SnippetInputField } from '@/models/snippet'
|
||||
import { create } from 'zustand'
|
||||
|
||||
type SnippetNavigationState = {
|
||||
snippet?: SnippetDetail
|
||||
snippetId?: string
|
||||
readonly: boolean
|
||||
onFieldsChange?: (fields: SnippetInputField[]) => void
|
||||
type SnippetDetailUIState = {
|
||||
fields: SnippetInputField[]
|
||||
setFields: (fields: SnippetInputField[]) => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
type SnippetDetailUIState = {
|
||||
setNavigationState: (state: SnippetNavigationState) => void
|
||||
reset: () => void
|
||||
} & SnippetNavigationState
|
||||
|
||||
const initialState = {
|
||||
readonly: true,
|
||||
snippet: undefined,
|
||||
snippetId: undefined,
|
||||
onFieldsChange: undefined,
|
||||
fields: [] as SnippetInputField[],
|
||||
}
|
||||
|
||||
export const useSnippetDetailStore = create<SnippetDetailUIState>(set => ({
|
||||
...initialState,
|
||||
setNavigationState: state => set(state),
|
||||
setFields: fields => set({ fields }),
|
||||
reset: () => set(initialState),
|
||||
}))
|
||||
|
||||
@ -15,7 +15,7 @@ vi.mock('@/context/app-context', () => ({
|
||||
// Mock useLocale and useDocLink
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useLocale: () => 'en-US',
|
||||
useDocLink: () => (path?: string) => `https://docs.dify.ai/en${path?.startsWith('/use-dify/') ? `/cloud${path}` : path || ''}`,
|
||||
useDocLink: () => (path: string) => `https://docs.dify.ai/en/${path?.startsWith('/') ? path.slice(1) : path}`,
|
||||
}))
|
||||
|
||||
// Mock getLanguage
|
||||
@ -132,7 +132,7 @@ describe('CustomCreateCard', () => {
|
||||
render(<CustomCreateCard onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
const docLink = screen.getByText('tools.swaggerAPIAsToolTip').closest('a')
|
||||
expect(docLink).toHaveAttribute('href', 'https://docs.dify.ai/en/cloud/use-dify/workspace/tools#custom-tool')
|
||||
expect(docLink).toHaveAttribute('href', 'https://docs.dify.ai/en/use-dify/workspace/tools#custom-tool')
|
||||
expect(docLink).toHaveAttribute('target', '_blank')
|
||||
expect(docLink).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
|
||||
@ -84,7 +84,7 @@ describe('Empty', () => {
|
||||
|
||||
expect(screen.getByRole('link', { name: /tools\.workflowToolEmpty\.goToStudio/i })).toHaveAttribute('href', '/apps')
|
||||
expect(screen.getByRole('link', { name: /tools\.workflowToolEmpty\.learnMore/i })).toHaveAttribute('target', '_blank')
|
||||
expect(screen.getByRole('link', { name: /tools\.workflowToolEmpty\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/self-host/use-dify/workspace/tools#workflow-tool')
|
||||
expect(screen.getByRole('link', { name: /tools\.workflowToolEmpty\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/use-dify/workspace/tools#workflow-tool')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -54,18 +54,6 @@ describe('useAvailableNodesMetaData', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should use explicit docs pages and skip nodes without generated docs pages', () => {
|
||||
mockUseIsChatMode.mockReturnValue(false)
|
||||
|
||||
const { result } = renderHook(() => useAvailableNodesMetaData())
|
||||
|
||||
expect(result.current.nodesMap?.[BlockEnum.End]?.metaData.helpLinkUri).toBe('/docs/use-dify/nodes/output')
|
||||
expect(result.current.nodesMap?.[BlockEnum.IterationStart]?.metaData.helpLinkUri).toBeUndefined()
|
||||
expect(result.current.nodesMap?.[BlockEnum.LoopStart]?.metaData.helpLinkUri).toBeUndefined()
|
||||
expect(result.current.nodesMap?.[BlockEnum.LoopEnd]?.metaData.helpLinkUri).toBeUndefined()
|
||||
expect(result.current.nodesMap?.[BlockEnum.Start]?.metaData.helpLinkUri).toBe('/docs/use-dify/nodes/user-input')
|
||||
})
|
||||
|
||||
it('should expose Agent v2 instead of legacy Agent when Agent v2 is enabled', () => {
|
||||
mockUseIsChatMode.mockReturnValue(false)
|
||||
|
||||
|
||||
@ -14,20 +14,8 @@ import TriggerWebhookDefault from '@/app/components/workflow/nodes/trigger-webho
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag'
|
||||
import { docPathProductAvailability } from '@/types/doc-paths'
|
||||
import { useIsChatMode } from './use-is-chat-mode'
|
||||
|
||||
const getNodeHelpLinkPath = (helpLinkUri?: string): DocPathWithoutLang | undefined => {
|
||||
if (!helpLinkUri)
|
||||
return undefined
|
||||
|
||||
const helpLinkPath = `/use-dify/nodes/${helpLinkUri}`
|
||||
if (!docPathProductAvailability[helpLinkPath])
|
||||
return undefined
|
||||
|
||||
return helpLinkPath as DocPathWithoutLang
|
||||
}
|
||||
|
||||
export const useAvailableNodesMetaData = () => {
|
||||
const { t } = useTranslation()
|
||||
const isChatMode = useIsChatMode()
|
||||
@ -69,14 +57,14 @@ export const useAvailableNodesMetaData = () => {
|
||||
const { metaData } = node
|
||||
const title = t(`blocks.${metaData.type}`, { ns: 'workflow' })
|
||||
const description = t(`blocksAbout.${metaData.type}` as I18nKeysWithPrefix<'workflow', 'blocksAbout.'>, { ns: 'workflow' })
|
||||
const helpLinkPath = getNodeHelpLinkPath(metaData.helpLinkUri)
|
||||
const helpLinkPath = `/use-dify/nodes/${metaData.helpLinkUri}` as DocPathWithoutLang
|
||||
return {
|
||||
...node,
|
||||
metaData: {
|
||||
...metaData,
|
||||
title,
|
||||
description,
|
||||
helpLinkUri: helpLinkPath ? docLink(helpLinkPath) : undefined,
|
||||
helpLinkUri: docLink(helpLinkPath),
|
||||
},
|
||||
defaultValue: {
|
||||
...node.defaultValue,
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { ContextMenu } from '@langgenius/dify-ui/context-menu'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { fullWorkflowAccessControl } from '../hooks-store'
|
||||
import { PanelContextmenu } from '../panel-contextmenu'
|
||||
import { BlockEnum } from '../types'
|
||||
@ -148,24 +147,6 @@ describe('PanelContextmenu', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should hide import app on snippet canvases', async () => {
|
||||
renderPanelContextmenu({
|
||||
initialStoreState: {
|
||||
contextMenuTarget: { type: 'panel' },
|
||||
},
|
||||
hooksStoreProps: {
|
||||
configsMap: {
|
||||
flowId: 'snippet-1',
|
||||
flowType: FlowType.snippet,
|
||||
fileSettings: {},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(await screen.findByText('export')).toBeInTheDocument()
|
||||
expect(screen.queryByText('importApp')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render preview action in chat mode', async () => {
|
||||
mockUseIsChatMode.mockReturnValue(true)
|
||||
|
||||
|
||||
@ -3,10 +3,8 @@ import { ContextMenu } from '@langgenius/dify-ui/context-menu'
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { useEffect } from 'react'
|
||||
import { useNodes } from 'reactflow'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
import { SelectionContextmenu } from '../selection-contextmenu'
|
||||
import { useWorkflowStore } from '../store'
|
||||
import { BlockEnum } from '../types'
|
||||
import { useWorkflowHistoryStore } from '../workflow-history-store'
|
||||
import { createEdge, createNode } from './fixtures'
|
||||
import { renderWorkflowFlowComponent } from './workflow-test-env'
|
||||
@ -17,48 +15,6 @@ const mockGetNodesReadOnly = vi.fn()
|
||||
const mockHandleNodesCopy = vi.fn()
|
||||
const mockHandleNodesDuplicate = vi.fn()
|
||||
const mockHandleNodesDelete = vi.fn()
|
||||
const mockHandleCreateSnippet = vi.fn()
|
||||
const mockCreateSnippetDialogRender = vi.fn()
|
||||
const mockWorkspacePermissionKeys = vi.hoisted(() => ({
|
||||
value: ['snippets.create_and_modify'] as string[],
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useSelector: <T,>(selector: (state: { workspacePermissionKeys: string[] }) => T): T => selector({
|
||||
workspacePermissionKeys: mockWorkspacePermissionKeys.value,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/snippets/hooks/use-create-snippet', async () => {
|
||||
const React = await vi.importActual<typeof import('react')>('react')
|
||||
|
||||
return {
|
||||
useCreateSnippet: () => {
|
||||
const [isOpen, setIsOpen] = React.useState(false)
|
||||
|
||||
return {
|
||||
createSnippetMutation: { isPending: false },
|
||||
handleCloseCreateSnippetDialog: () => setIsOpen(false),
|
||||
handleCreateSnippet: mockHandleCreateSnippet,
|
||||
handleOpenCreateSnippetDialog: () => setIsOpen(true),
|
||||
isCreateSnippetDialogOpen: isOpen,
|
||||
isCreatingSnippet: false,
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({
|
||||
default: (props: {
|
||||
isOpen: boolean
|
||||
selectedGraph?: { nodes: Node[], edges: Edge[], viewport: { x: number, y: number, zoom: number } }
|
||||
inputFields?: Array<{ variable: string }>
|
||||
}) => {
|
||||
mockCreateSnippetDialogRender(props)
|
||||
|
||||
return props.isOpen ? <div data-testid="create-snippet-dialog" /> : null
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../hooks', async () => {
|
||||
const actual = await vi.importActual<typeof import('../hooks')>('../hooks')
|
||||
@ -142,9 +98,6 @@ describe('SelectionContextmenu', () => {
|
||||
mockHandleNodesCopy.mockReset()
|
||||
mockHandleNodesDuplicate.mockReset()
|
||||
mockHandleNodesDelete.mockReset()
|
||||
mockHandleCreateSnippet.mockReset()
|
||||
mockCreateSnippetDialogRender.mockReset()
|
||||
mockWorkspacePermissionKeys.value = ['snippets.create_and_modify']
|
||||
})
|
||||
|
||||
it('should not render when selection context menu target is absent', () => {
|
||||
@ -203,41 +156,7 @@ describe('SelectionContextmenu', () => {
|
||||
expect(store.getState().contextMenuTarget).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should open create snippet dialog with selected graph from the top menu item', async () => {
|
||||
const nodes = [
|
||||
createNode({ id: 'n1', selected: true, width: 80, height: 40 }),
|
||||
createNode({ id: 'n2', selected: true, position: { x: 140, y: 0 }, width: 80, height: 40 }),
|
||||
createNode({ id: 'n3', selected: false, position: { x: 260, y: 0 }, width: 80, height: 40 }),
|
||||
]
|
||||
const edges = [
|
||||
createEdge({ source: 'n1', target: 'n2' }),
|
||||
createEdge({ source: 'n2', target: 'n3' }),
|
||||
]
|
||||
const { store } = renderSelectionMenu({ nodes, edges })
|
||||
|
||||
act(() => {
|
||||
store.setState({ contextMenuTarget: { type: 'selection' } })
|
||||
})
|
||||
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: /Create Snippet|snippet\.createDialogTitle/ }))
|
||||
|
||||
expect(screen.getByTestId('create-snippet-dialog')).toBeInTheDocument()
|
||||
expect(store.getState().contextMenuTarget).toBeUndefined()
|
||||
|
||||
const dialogProps = mockCreateSnippetDialogRender.mock.calls.at(-1)?.[0]
|
||||
expect(dialogProps.selectedGraph.nodes.map((node: Node) => node.id)).toEqual(['n1', 'n2'])
|
||||
expect(dialogProps.selectedGraph.nodes.every((node: Node) => node.selected === false)).toBe(true)
|
||||
expect(dialogProps.selectedGraph.edges).toHaveLength(1)
|
||||
expect(dialogProps.selectedGraph.viewport).toEqual({ x: 490, y: 380, zoom: 1 })
|
||||
expect(dialogProps.selectedGraph.edges[0]).toEqual(expect.objectContaining({
|
||||
source: 'n1',
|
||||
target: 'n2',
|
||||
selected: false,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should hide create snippet action without snippets create-and-modify permission', async () => {
|
||||
mockWorkspacePermissionKeys.value = []
|
||||
it('should hide create snippet action for selected nodes', async () => {
|
||||
const nodes = [
|
||||
createNode({ id: 'n1', selected: true, width: 80, height: 40 }),
|
||||
createNode({ id: 'n2', selected: true, position: { x: 140, y: 0 }, width: 80, height: 40 }),
|
||||
@ -252,76 +171,7 @@ describe('SelectionContextmenu', () => {
|
||||
expect(screen.getByRole('menuitem', { name: /common.copy/ })).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByRole('menuitem', { name: /Create Snippet|snippet\.createDialogTitle/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should add input fields for variable references outside of the selected graph', async () => {
|
||||
const nodes = [
|
||||
createNode({
|
||||
id: 'n1',
|
||||
selected: true,
|
||||
width: 80,
|
||||
height: 40,
|
||||
data: {
|
||||
prompt_template: 'Use {{#source-node.topic#}} and {{#n2.answer#}}',
|
||||
query_variable_selector: ['source-node', 'topic'],
|
||||
env_reference: '{{#env.API_KEY#}}',
|
||||
},
|
||||
}),
|
||||
createNode({
|
||||
id: 'n2',
|
||||
selected: true,
|
||||
position: { x: 140, y: 0 },
|
||||
width: 80,
|
||||
height: 40,
|
||||
}),
|
||||
]
|
||||
const { store } = renderSelectionMenu({ nodes })
|
||||
|
||||
act(() => {
|
||||
store.setState({ contextMenuTarget: { type: 'selection' } })
|
||||
})
|
||||
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: /Create Snippet|snippet\.createDialogTitle/ }))
|
||||
|
||||
const dialogProps = mockCreateSnippetDialogRender.mock.calls.at(-1)?.[0]
|
||||
expect(dialogProps.inputFields).toEqual([
|
||||
{
|
||||
label: 'topic',
|
||||
variable: 'topic',
|
||||
type: PipelineInputVarType.textInput,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
label: 'API_KEY',
|
||||
variable: 'API_KEY',
|
||||
type: PipelineInputVarType.textInput,
|
||||
required: true,
|
||||
},
|
||||
])
|
||||
expect(dialogProps.selectedGraph.nodes[0].data.prompt_template).toBe('Use {{#start.topic#}} and {{#n2.answer#}}')
|
||||
expect(dialogProps.selectedGraph.nodes[0].data.query_variable_selector).toEqual(['start', 'topic'])
|
||||
expect(dialogProps.selectedGraph.nodes[0].data.env_reference).toBe('{{#start.API_KEY#}}')
|
||||
})
|
||||
|
||||
it.each([
|
||||
BlockEnum.Answer,
|
||||
BlockEnum.End,
|
||||
BlockEnum.Start,
|
||||
])('should hide create snippet when selection contains %s node', async (nodeType) => {
|
||||
const nodes = [
|
||||
createNode({ id: 'n1', selected: true, width: 80, height: 40, data: { type: nodeType } }),
|
||||
createNode({ id: 'n2', selected: true, position: { x: 140, y: 0 }, width: 80, height: 40 }),
|
||||
]
|
||||
const { store } = renderSelectionMenu({ nodes })
|
||||
|
||||
act(() => {
|
||||
store.setState({ contextMenuTarget: { type: 'selection' } })
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('menuitem', { name: /common.copy/ })).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByRole('menuitem', { name: /Create Snippet|snippet\.createDialogTitle/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('create-snippet-dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should stay hidden when only one node is selected', async () => {
|
||||
|
||||
@ -106,6 +106,7 @@ describe('NodeSelector', () => {
|
||||
await user.click(trigger)
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('workflow.tabs.searchBlock')
|
||||
expect(screen.queryByText('workflow.tabs.snippets')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('LLM')).toBeInTheDocument()
|
||||
expect(screen.getByText('End')).toBeInTheDocument()
|
||||
|
||||
@ -317,7 +318,7 @@ describe('NodeSelector', () => {
|
||||
expect(await screen.findByText('workflow.tabs.unconfiguredStartDisabledTip')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'workflow.tabs.startDisabledTipLearnMore' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://docs.dify.ai/en/self-host/use-dify/nodes/trigger/overview',
|
||||
'https://docs.dify.ai/en/use-dify/nodes/trigger/overview',
|
||||
)
|
||||
expect(screen.getByPlaceholderText('workflow.tabs.searchBlock')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -132,7 +132,7 @@ function NodeSelector({
|
||||
const defaultAllowUserInputSelection = !hasUserInputNode && !hasTriggerNode
|
||||
const canSelectUserInput = allowUserInputSelection ?? defaultAllowUserInputSelection
|
||||
const disableStartTab = flowType === FlowType.snippet
|
||||
const disableSnippetsTab = flowType === FlowType.snippet
|
||||
const disableSnippetsTab = true
|
||||
const {
|
||||
activeTab,
|
||||
resetActiveTab,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { Node, NodeOutPutVar, Var } from '../../types'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useSnippetDraftStore } from '@/app/components/snippets/draft-store'
|
||||
import { useSnippetDetailStore } from '@/app/components/snippets/store'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { BlockEnum, VarType } from '../../types'
|
||||
@ -83,7 +83,7 @@ describe('useNodesAvailableVarList', () => {
|
||||
vi.clearAllMocks()
|
||||
mockFlowType.value = undefined
|
||||
globalThis.history.pushState({}, '', '/')
|
||||
useSnippetDraftStore.getState().reset()
|
||||
useSnippetDetailStore.getState().reset()
|
||||
mockGetBeforeNodesInSameBranchIncludeParent.mockImplementation((nodeId: string) => [createNode({ id: `before-${nodeId}` })])
|
||||
mockGetTreeLeafNodes.mockImplementation((nodeId: string) => [createNode({ id: `leaf-${nodeId}` })])
|
||||
mockGetNodeAvailableVars.mockReturnValue(outputVars)
|
||||
@ -130,7 +130,7 @@ describe('useNodesAvailableVarList', () => {
|
||||
|
||||
it('adds snippet input fields as virtual start variables on snippet canvases', () => {
|
||||
globalThis.history.pushState({}, '', '/snippets/snippet-1/orchestrate')
|
||||
useSnippetDraftStore.getState().setInputFields([{
|
||||
useSnippetDetailStore.getState().setFields([{
|
||||
type: PipelineInputVarType.textInput,
|
||||
label: 'Topic',
|
||||
variable: 'topic',
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { Node, NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSnippetDraftStore } from '@/app/components/snippets/draft-store'
|
||||
import { useSnippetDetailStore } from '@/app/components/snippets/store'
|
||||
import {
|
||||
useIsChatMode,
|
||||
useWorkflow,
|
||||
@ -51,7 +51,7 @@ const useNodesAvailableVarList = (nodes: Node[], {
|
||||
filterVar: () => true,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const snippetInputFields = useSnippetDraftStore(s => s.inputFields)
|
||||
const snippetInputFields = useSnippetDetailStore(s => s.fields)
|
||||
const { getTreeLeafNodes, getBeforeNodesInSameBranchIncludeParent } = useWorkflow()
|
||||
const { getNodeAvailableVars } = useWorkflowVariables()
|
||||
const isChatMode = useIsChatMode()
|
||||
@ -97,7 +97,7 @@ const useNodesAvailableVarList = (nodes: Node[], {
|
||||
|
||||
export const useGetNodesAvailableVarList = () => {
|
||||
const { t } = useTranslation()
|
||||
const snippetInputFields = useSnippetDraftStore(s => s.inputFields)
|
||||
const snippetInputFields = useSnippetDetailStore(s => s.fields)
|
||||
const { getTreeLeafNodes, getBeforeNodesInSameBranchIncludeParent } = useWorkflow()
|
||||
const { getNodeAvailableVars } = useWorkflowVariables()
|
||||
const isChatMode = useIsChatMode()
|
||||
|
||||
@ -1,34 +0,0 @@
|
||||
import type { Node } from '@/app/components/workflow/types'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { appendSnippetInputFieldVars } from '../snippet-input-field-vars'
|
||||
|
||||
const createNode = (id = 'node-1'): Node => ({
|
||||
id,
|
||||
type: 'custom',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
type: BlockEnum.LLM,
|
||||
title: 'Node',
|
||||
desc: '',
|
||||
},
|
||||
} as Node)
|
||||
|
||||
describe('appendSnippetInputFieldVars', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.history.pushState({}, '', '/')
|
||||
})
|
||||
|
||||
it('should treat missing snippet input fields as empty on snippet canvases', () => {
|
||||
globalThis.history.pushState({}, '', '/snippets/snippet-1/orchestrate')
|
||||
const availableNodes = [createNode()]
|
||||
|
||||
expect(appendSnippetInputFieldVars({
|
||||
availableNodes,
|
||||
fields: undefined,
|
||||
title: 'Snippet',
|
||||
})).toEqual({
|
||||
availableNodes,
|
||||
availableVars: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -12,8 +12,8 @@ const mockFlowType = vi.hoisted(() => ({
|
||||
value: undefined as FlowType | undefined,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/snippets/draft-store', () => ({
|
||||
useSnippetDraftStore: (selector: (state: { inputFields: unknown[] }) => unknown) => selector({ inputFields: [] }),
|
||||
vi.mock('@/app/components/snippets/store', () => ({
|
||||
useSnippetDetailStore: (selector: (state: { fields: unknown[] }) => unknown) => selector({ fields: [] }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
|
||||
@ -98,18 +98,17 @@ export const appendSnippetInputFieldVars = ({
|
||||
title,
|
||||
}: {
|
||||
availableNodes: Node[]
|
||||
fields?: SnippetInputField[]
|
||||
fields: SnippetInputField[]
|
||||
title: string
|
||||
}) => {
|
||||
const inputFields = fields ?? []
|
||||
const shouldAppendSnippetInputFields = isSnippetCanvas()
|
||||
&& inputFields.length > 0
|
||||
&& fields.length > 0
|
||||
&& !availableNodes.some(node => node.data.type === BlockEnum.Start)
|
||||
const snippetInputFieldNode = shouldAppendSnippetInputFields
|
||||
? buildSnippetInputFieldNode(inputFields, title)
|
||||
? buildSnippetInputFieldNode(fields, title)
|
||||
: undefined
|
||||
const snippetInputFieldVars = shouldAppendSnippetInputFields
|
||||
? buildSnippetInputFieldVars(inputFields, title)
|
||||
? buildSnippetInputFieldVars(fields, title)
|
||||
: undefined
|
||||
|
||||
return {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { Node, NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSnippetDraftStore } from '@/app/components/snippets/draft-store'
|
||||
import { useSnippetDetailStore } from '@/app/components/snippets/store'
|
||||
import {
|
||||
useIsChatMode,
|
||||
useWorkflow,
|
||||
@ -34,7 +34,7 @@ const useAvailableVarList = (nodeId: string, {
|
||||
filterVar: () => true,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const snippetInputFields = useSnippetDraftStore(s => s.inputFields)
|
||||
const snippetInputFields = useSnippetDetailStore(s => s.fields)
|
||||
const { getTreeLeafNodes, getNodeById, getBeforeNodesInSameBranchIncludeParent } = useWorkflow()
|
||||
const { getNodeAvailableVars } = useWorkflowVariables()
|
||||
const isChatMode = useIsChatMode()
|
||||
|
||||
@ -6,7 +6,6 @@ import { genNodeMetaData } from '@/app/components/workflow/utils'
|
||||
const metaData = genNodeMetaData({
|
||||
sort: 2.1,
|
||||
type: BlockEnum.End,
|
||||
helpLinkUri: 'output',
|
||||
isRequired: false,
|
||||
})
|
||||
const nodeDefault: NodeDefault<EndNodeType> = {
|
||||
|
||||
@ -9,7 +9,6 @@ import {
|
||||
useCallback,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { TEST_RUN_MENU_HOTKEY } from './header/shortcuts'
|
||||
import {
|
||||
useDSL,
|
||||
@ -19,7 +18,6 @@ import {
|
||||
useWorkflowStartRun,
|
||||
} from './hooks'
|
||||
import { useHooksStore } from './hooks-store'
|
||||
import { isSnippetCanvas } from './nodes/_base/hooks/snippet-input-field-vars'
|
||||
import AddBlock from './operator/add-block'
|
||||
import { useOperator } from './operator/hooks'
|
||||
import { ShortcutKbd } from './shortcuts/shortcut-kbd'
|
||||
@ -50,7 +48,6 @@ export function PanelContextmenu({
|
||||
const { isCommentModeAvailable } = useWorkflowMoveMode()
|
||||
const { exportCheck } = useDSL()
|
||||
const accessControl = useHooksStore(s => s.accessControl)
|
||||
const flowType = useHooksStore(s => s.configsMap?.flowType)
|
||||
const isChatMode = useIsChatMode()
|
||||
const workflowOperationReadOnly = !!(
|
||||
workflowRunningData?.result.status === WorkflowRunningStatus.Running
|
||||
@ -60,7 +57,6 @@ export function PanelContextmenu({
|
||||
)
|
||||
const canEditWorkflow = accessControl.canEdit && !workflowOperationReadOnly
|
||||
const canCommentWorkflow = accessControl.canComment && !workflowOperationReadOnly
|
||||
const shouldHideImportApp = flowType === FlowType.snippet || isSnippetCanvas()
|
||||
|
||||
const renderAddBlockTrigger = useCallback(() => {
|
||||
return (
|
||||
@ -181,14 +177,12 @@ export function PanelContextmenu({
|
||||
>
|
||||
{t('export', { ns: 'app' })}
|
||||
</ContextMenuItem>
|
||||
{!shouldHideImportApp && (
|
||||
<ContextMenuItem
|
||||
className="justify-between gap-4 px-3 text-text-secondary"
|
||||
onClick={() => setShowImportDSLModal(true)}
|
||||
>
|
||||
{t('importApp', { ns: 'app' })}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem
|
||||
className="justify-between gap-4 px-3 text-text-secondary"
|
||||
onClick={() => setShowImportDSLModal(true)}
|
||||
>
|
||||
{t('importApp', { ns: 'app' })}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuGroup>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -12,15 +12,11 @@ import {
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStore as useReactFlowStore } from 'reactflow'
|
||||
import { useCreateSnippetFromSelection } from '@/app/components/snippets/hooks/use-create-snippet-from-selection'
|
||||
import { canCreateAndModifySnippets } from '@/app/components/snippets/utils/permission'
|
||||
import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow'
|
||||
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
|
||||
import { useNodesInteractions, useNodesReadOnly, useNodesSyncDraft } from './hooks'
|
||||
import { useWorkflowHistory, WorkflowHistoryEvent } from './hooks/use-workflow-history'
|
||||
import { ShortcutKbd } from './shortcuts/shortcut-kbd'
|
||||
import { useStore, useWorkflowStore } from './store'
|
||||
import { BlockEnum } from './types'
|
||||
|
||||
const AlignType = {
|
||||
Bottom: 'bottom',
|
||||
@ -75,14 +71,6 @@ const menuSections: MenuSection[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const unsupportedSnippetNodeTypes = new Set([
|
||||
BlockEnum.Answer,
|
||||
BlockEnum.End,
|
||||
BlockEnum.Start,
|
||||
BlockEnum.HumanInput,
|
||||
BlockEnum.KnowledgeRetrieval,
|
||||
])
|
||||
|
||||
const getAlignableNodes = (nodes: Node[], selectedNodes: Node[]) => {
|
||||
const selectedNodeIds = new Set(selectedNodes.map(node => node.id))
|
||||
const childNodeIds = new Set<string>()
|
||||
@ -235,7 +223,6 @@ export function SelectionContextmenu({
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { getNodesReadOnly } = useNodesReadOnly()
|
||||
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
|
||||
const { handleNodesCopy, handleNodesDelete, handleNodesDuplicate } = useNodesInteractions()
|
||||
const isSelectionContextMenu = useStore(s => s.contextMenuTarget?.type === 'selection')
|
||||
|
||||
@ -247,20 +234,8 @@ export function SelectionContextmenu({
|
||||
const selectedNodes = useReactFlowStore(state =>
|
||||
state.getNodes().filter(node => node.selected),
|
||||
)
|
||||
const edges = useReactFlowStore(state => state.edges)
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const { saveStateToHistory } = useWorkflowHistory()
|
||||
const {
|
||||
createSnippetDialog,
|
||||
handleOpenCreateSnippet,
|
||||
isCreateSnippetDialogOpen,
|
||||
} = useCreateSnippetFromSelection({
|
||||
edges,
|
||||
selectedNodes,
|
||||
onClose,
|
||||
})
|
||||
const canCreateSnippet = canCreateAndModifySnippets(workspacePermissionKeys)
|
||||
&& selectedNodes.every(node => !unsupportedSnippetNodeTypes.has(node.data.type))
|
||||
|
||||
const handleCopyNodes = useCallback(() => {
|
||||
handleNodesCopy()
|
||||
@ -370,24 +345,11 @@ export function SelectionContextmenu({
|
||||
}, [collaborativeWorkflow, workflowStore, selectedNodes, getNodesReadOnly, handleSyncWorkflowDraft, saveStateToHistory, onClose])
|
||||
|
||||
if (!isSelectionContextMenu || selectedNodes.length <= 1)
|
||||
return isCreateSnippetDialogOpen ? createSnippetDialog : null
|
||||
return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenuContent popupClassName="w-[240px]" sideOffset={4}>
|
||||
{canCreateSnippet && (
|
||||
<>
|
||||
<ContextMenuGroup>
|
||||
<ContextMenuItem
|
||||
className="px-3 text-text-secondary"
|
||||
onClick={handleOpenCreateSnippet}
|
||||
>
|
||||
<span>{t('snippet.createDialogTitle', { defaultValue: 'Create Snippet', ns: 'workflow' })}</span>
|
||||
</ContextMenuItem>
|
||||
</ContextMenuGroup>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<ContextMenuGroup>
|
||||
<ContextMenuItem
|
||||
className="justify-between px-3 text-text-secondary"
|
||||
@ -436,7 +398,6 @@ export function SelectionContextmenu({
|
||||
</ContextMenuGroup>
|
||||
))}
|
||||
</ContextMenuContent>
|
||||
{createSnippetDialog}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -5,10 +5,6 @@ import { useTranslation } from '#i18n'
|
||||
import { getDocLanguage } from '@/i18n-config/language'
|
||||
import { defaultDocBaseUrl, useDocLink } from './i18n'
|
||||
|
||||
const mockConfig = vi.hoisted(() => ({
|
||||
IS_CLOUD_EDITION: true,
|
||||
}))
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('#i18n', () => ({
|
||||
useTranslation: vi.fn(() => ({
|
||||
@ -16,12 +12,6 @@ vi.mock('#i18n', () => ({
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
get IS_CLOUD_EDITION() {
|
||||
return mockConfig.IS_CLOUD_EDITION
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n-config/language', () => ({
|
||||
getDocLanguage: vi.fn((locale: string) => {
|
||||
const map: Record<string, string> = {
|
||||
@ -38,7 +28,6 @@ vi.mock('@/i18n-config/language', () => ({
|
||||
describe('useDocLink', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockConfig.IS_CLOUD_EDITION = true
|
||||
vi.mocked(useTranslation).mockReturnValue({
|
||||
i18n: { language: 'en-US' },
|
||||
} as ReturnType<typeof useTranslation>)
|
||||
@ -56,28 +45,28 @@ describe('useDocLink', () => {
|
||||
it('should use default base URL when no baseUrl provided', () => {
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url = result.current()
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en/home`)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en`)
|
||||
})
|
||||
|
||||
it('should use custom base URL when provided', () => {
|
||||
const customBaseUrl = 'https://custom.docs.com'
|
||||
const { result } = renderHook(() => useDocLink(customBaseUrl))
|
||||
const url = result.current()
|
||||
expect(url).toBe(`${customBaseUrl}/en/home`)
|
||||
expect(url).toBe(`${customBaseUrl}/en`)
|
||||
})
|
||||
|
||||
it('should remove trailing slash from base URL', () => {
|
||||
const baseUrlWithSlash = 'https://docs.dify.ai/'
|
||||
const { result } = renderHook(() => useDocLink(baseUrlWithSlash))
|
||||
const url = result.current('/use-dify/getting-started/introduction')
|
||||
expect(url).toBe('https://docs.dify.ai/en/cloud/use-dify/getting-started/introduction')
|
||||
expect(url).toBe('https://docs.dify.ai/en/use-dify/getting-started/introduction')
|
||||
})
|
||||
|
||||
it('should handle base URL without trailing slash', () => {
|
||||
const baseUrlWithoutSlash = 'https://docs.dify.ai'
|
||||
const { result } = renderHook(() => useDocLink(baseUrlWithoutSlash))
|
||||
const url = result.current('/use-dify/getting-started/introduction')
|
||||
expect(url).toBe('https://docs.dify.ai/en/cloud/use-dify/getting-started/introduction')
|
||||
expect(url).toBe('https://docs.dify.ai/en/use-dify/getting-started/introduction')
|
||||
})
|
||||
})
|
||||
|
||||
@ -85,31 +74,19 @@ describe('useDocLink', () => {
|
||||
it('should handle path parameter', () => {
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url = result.current('/use-dify/getting-started/introduction')
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en/cloud/use-dify/getting-started/introduction`)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en/use-dify/getting-started/introduction`)
|
||||
})
|
||||
|
||||
it('should handle empty path', () => {
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url = result.current()
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en/home`)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en`)
|
||||
})
|
||||
|
||||
it('should handle undefined path', () => {
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url = result.current(undefined)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en/home`)
|
||||
})
|
||||
|
||||
it('should keep common docs path without product prefix', () => {
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url = result.current('/learn/key-concepts' as DocPathWithoutLang)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en/learn/key-concepts`)
|
||||
})
|
||||
|
||||
it('should keep explicit product docs path without adding another product prefix', () => {
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url = result.current('/cloud/use-dify/build/mcp' as DocPathWithoutLang)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en/cloud/use-dify/build/mcp`)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en`)
|
||||
})
|
||||
})
|
||||
|
||||
@ -122,12 +99,12 @@ describe('useDocLink', () => {
|
||||
|
||||
const pathMap: DocPathMap = {
|
||||
'zh-Hans': '/use-dify/getting-started/introduction',
|
||||
'en-US': '/use-dify/build/mcp',
|
||||
'en-US': '/use-dify/getting-started/quick-start',
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url = result.current('/use-dify/build/mcp', pathMap)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/zh/cloud/use-dify/getting-started/introduction`)
|
||||
const url = result.current('/use-dify/getting-started/quick-start' as DocPathWithoutLang, pathMap)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/zh/use-dify/getting-started/introduction`)
|
||||
})
|
||||
|
||||
it('should use default path when locale not in pathMap', () => {
|
||||
@ -138,76 +115,18 @@ describe('useDocLink', () => {
|
||||
|
||||
const pathMap: DocPathMap = {
|
||||
'zh-Hans': '/use-dify/getting-started/introduction',
|
||||
'en-US': '/use-dify/build/mcp',
|
||||
'en-US': '/use-dify/getting-started/quick-start',
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url = result.current('/use-dify/build/mcp', pathMap)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/ja/cloud/use-dify/build/mcp`)
|
||||
const url = result.current('/use-dify/getting-started/quick-start' as DocPathWithoutLang, pathMap)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/ja/use-dify/getting-started/quick-start`)
|
||||
})
|
||||
|
||||
it('should handle undefined pathMap', () => {
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url = result.current('/use-dify/getting-started/introduction', undefined)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en/cloud/use-dify/getting-started/introduction`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Product prefix handling', () => {
|
||||
it('should add cloud product prefix for product docs available in both editions', () => {
|
||||
mockConfig.IS_CLOUD_EDITION = true
|
||||
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url = result.current('/use-dify/build/mcp')
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en/cloud/use-dify/build/mcp`)
|
||||
})
|
||||
|
||||
it('should add self-host product prefix for product docs available in both editions outside cloud edition', () => {
|
||||
mockConfig.IS_CLOUD_EDITION = false
|
||||
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url = result.current('/use-dify/build/mcp')
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en/self-host/use-dify/build/mcp`)
|
||||
})
|
||||
|
||||
it('should use the existing cloud docs path for cloud-only product docs outside cloud edition', () => {
|
||||
mockConfig.IS_CLOUD_EDITION = false
|
||||
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url = result.current('/use-dify/workspace/subscription-management#dify-for-education')
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en/cloud/use-dify/workspace/subscription-management#dify-for-education`)
|
||||
})
|
||||
|
||||
it('should use the existing self-host docs path for self-host-only product docs in cloud edition', () => {
|
||||
mockConfig.IS_CLOUD_EDITION = true
|
||||
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url = result.current('/deploy/overview')
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en/self-host/deploy/overview`)
|
||||
})
|
||||
|
||||
it('should not add a product prefix for unknown productless paths', () => {
|
||||
mockConfig.IS_CLOUD_EDITION = false
|
||||
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url = result.current('/use-dify/unknown-page' as DocPathWithoutLang)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en/use-dify/unknown-page`)
|
||||
})
|
||||
|
||||
it('should open shared docs home when no path is provided outside cloud edition', () => {
|
||||
mockConfig.IS_CLOUD_EDITION = false
|
||||
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url = result.current()
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en/home`)
|
||||
})
|
||||
|
||||
it('should keep self-host deploy paths without adding use-dify product prefix', () => {
|
||||
mockConfig.IS_CLOUD_EDITION = true
|
||||
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url = result.current('/self-host/deploy/overview' as DocPathWithoutLang)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en/self-host/deploy/overview`)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en/use-dify/getting-started/introduction`)
|
||||
})
|
||||
})
|
||||
|
||||
@ -313,7 +232,7 @@ describe('useDocLink', () => {
|
||||
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url = result.current('/use-dify/getting-started/introduction')
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/zh/cloud/use-dify/getting-started/introduction`)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/zh/use-dify/getting-started/introduction`)
|
||||
})
|
||||
})
|
||||
|
||||
@ -321,15 +240,15 @@ describe('useDocLink', () => {
|
||||
it('should handle path with anchor', () => {
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url = result.current('/use-dify/getting-started/introduction#overview' as DocPathWithoutLang)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en/cloud/use-dify/getting-started/introduction#overview`)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en/use-dify/getting-started/introduction#overview`)
|
||||
})
|
||||
|
||||
it('should handle multiple calls with same hook instance', () => {
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url1 = result.current('/use-dify/getting-started/introduction')
|
||||
const url2 = result.current('/use-dify/build/mcp')
|
||||
expect(url1).toBe(`${defaultDocBaseUrl}/en/cloud/use-dify/getting-started/introduction`)
|
||||
expect(url2).toBe(`${defaultDocBaseUrl}/en/cloud/use-dify/build/mcp`)
|
||||
const url2 = result.current('/use-dify/getting-started/quick-start')
|
||||
expect(url1).toBe(`${defaultDocBaseUrl}/en/use-dify/getting-started/introduction`)
|
||||
expect(url2).toBe(`${defaultDocBaseUrl}/en/use-dify/getting-started/quick-start`)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
import type { Locale } from '@/i18n-config/language'
|
||||
import type { DocPathWithoutLang, DocsProduct } from '@/types/doc-paths'
|
||||
import type { DocPathWithoutLang } from '@/types/doc-paths'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from '#i18n'
|
||||
import { IS_CLOUD_EDITION } from '@/config'
|
||||
import { getDocLanguage, getLanguage, getPricingPageLanguage } from '@/i18n-config/language'
|
||||
import { apiReferencePathTranslations, docPathProductAvailability } from '@/types/doc-paths'
|
||||
import { apiReferencePathTranslations } from '@/types/doc-paths'
|
||||
|
||||
export const useLocale = () => {
|
||||
const { i18n } = useTranslation()
|
||||
@ -25,44 +24,6 @@ export const useGetPricingPageLanguage = () => {
|
||||
export const defaultDocBaseUrl = 'https://docs.dify.ai'
|
||||
export type DocPathMap = Partial<Record<Locale, DocPathWithoutLang>>
|
||||
|
||||
export const getDocHomePath = () => '/home'
|
||||
|
||||
const getCurrentDocsProduct = (): DocsProduct => {
|
||||
return IS_CLOUD_EDITION ? 'cloud' : 'self-host'
|
||||
}
|
||||
|
||||
const splitPathHash = (path: string) => {
|
||||
const hashIndex = path.indexOf('#')
|
||||
if (hashIndex === -1) {
|
||||
return {
|
||||
pathname: path,
|
||||
hash: '',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
pathname: path.slice(0, hashIndex),
|
||||
hash: path.slice(hashIndex),
|
||||
}
|
||||
}
|
||||
|
||||
const getProductAwarePath = (path: string): string => {
|
||||
const { pathname, hash } = splitPathHash(path)
|
||||
const availableProducts = docPathProductAvailability[pathname]
|
||||
if (!availableProducts?.length)
|
||||
return path
|
||||
|
||||
const currentProduct = getCurrentDocsProduct()
|
||||
const targetProduct = availableProducts.includes(currentProduct)
|
||||
? currentProduct
|
||||
: availableProducts[0]
|
||||
|
||||
if (!targetProduct)
|
||||
return path
|
||||
|
||||
return `/${targetProduct}${pathname}${hash}`
|
||||
}
|
||||
|
||||
export const useDocLink = (baseUrl?: string): ((path?: DocPathWithoutLang, pathMap?: DocPathMap) => string) => {
|
||||
let baseDocUrl = baseUrl || defaultDocBaseUrl
|
||||
baseDocUrl = (baseDocUrl.endsWith('/')) ? baseDocUrl.slice(0, -1) : baseDocUrl
|
||||
@ -83,12 +44,6 @@ export const useDocLink = (baseUrl?: string): ((path?: DocPathWithoutLang, pathM
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (!targetPath) {
|
||||
targetPath = getDocHomePath()
|
||||
}
|
||||
else {
|
||||
targetPath = getProductAwarePath(targetPath)
|
||||
}
|
||||
|
||||
return `${baseDocUrl}${languagePrefix}${targetPath}`
|
||||
},
|
||||
|
||||
@ -84,7 +84,7 @@ export function AgentAccessPage({
|
||||
<p className="mt-1 flex min-w-0 flex-wrap items-center gap-x-0.5 system-xs-regular text-text-tertiary">
|
||||
<span>{t('agentDetail.access.description')}</span>
|
||||
<a
|
||||
href={docLink('/use-dify/publish/webapp/web-app-settings')}
|
||||
href={docLink('/use-dify/publish/webapp/web-app-access')}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex shrink-0 items-center gap-0.5 rounded-sm text-text-accent hover:underline focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
{
|
||||
"cancel": "إلغاء",
|
||||
"continueEditing": "متابعة التحرير",
|
||||
"create": "إنشاء مقتطف",
|
||||
"createFailed": "فشل إنشاء المقتطف",
|
||||
"createFrom": "إنشاء من",
|
||||
@ -9,6 +11,11 @@
|
||||
"deleteConfirmTitle": "هل تريد حذف المقتطف؟",
|
||||
"deleteFailed": "فشل حذف المقتطف",
|
||||
"deleted": "تم حذف المقتطف",
|
||||
"discardChanges": "تجاهل التغييرات",
|
||||
"discardChangesDescription": "سيتم تجاهل مسودّة تغييراتك وسيعود المقتطف إلى آخر نسخة محفوظة.",
|
||||
"discardChangesTitle": "هل تريد تجاهل مسودة التغييرات؟",
|
||||
"discardDraft": "تجاهل المسودة",
|
||||
"doNotSave": "اترك كمسودة",
|
||||
"draft": "مسودة",
|
||||
"dslVersionMismatchDescription": "تم اكتشاف اختلاف كبير في إصدارات DSL. قد يؤدي فرض الاستيراد إلى حدوث خلل في المقتطف.",
|
||||
"dslVersionMismatchQuestion": "هل تريد الاستمرار؟",
|
||||
@ -17,7 +24,9 @@
|
||||
"editDialogTitle": "تحرير معلومات المقتطف",
|
||||
"editDone": "تم تحديث معلومات المقتطف",
|
||||
"editFailed": "فشل تحديث معلومات المقتطف",
|
||||
"emptyGraphSaveError": "أضف عقدة واحدة على الأقل قبل النشر.",
|
||||
"editingDraft": "أنت تقوم بتحرير مسودة.",
|
||||
"emptyGraphSaveError": "أضف عقدة واحدة على الأقل قبل الحفظ.",
|
||||
"exitEditing": "الخروج من التحرير",
|
||||
"exportFailed": "فشل تصدير المقتطف.",
|
||||
"importDSLFile": "استيراد ملف دي اس ال",
|
||||
"importDialogTitle": "استيراد مقتطف",
|
||||
@ -43,11 +52,18 @@
|
||||
"publishFailed": "فشل نشر المقتطف",
|
||||
"publishMenuCurrentDraft": "المسودة الحالية غير منشورة",
|
||||
"publishSuccess": "تم نشر المقتطف",
|
||||
"save": "حفظ",
|
||||
"saveAndExit": "حفظ والخروج",
|
||||
"saveBeforeLeavingDescription": "احفظ لجعل هذا الإصدار متاحًا للاستخدام في مهام سير العمل. أو احتفظ بتعديلاتك كمسودة في الوقت الحالي.",
|
||||
"saveBeforeLeavingTitle": "هل تريد حفظ التغييرات قبل المغادرة؟",
|
||||
"saveSuccess": "تم حفظ المقتطف",
|
||||
"sectionOrchestrate": "نسق",
|
||||
"testRunButton": "تشغيل تجريبي",
|
||||
"typeLabel": "مقتطف",
|
||||
"unknownUser": "المستخدم",
|
||||
"unsavedChanges": "لا يتم حفظ التغييرات الحالية.",
|
||||
"updatedBy": "{{name}} تم التحديث {{time}}",
|
||||
"usageCount": "تم الاستخدام {{count}} مرات",
|
||||
"variableInspect": "فحص متغير"
|
||||
"variableInspect": "فحص متغير",
|
||||
"viewOnly": "عرض فقط"
|
||||
}
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
{
|
||||
"cancel": "Abbrechen",
|
||||
"continueEditing": "Bearbeiten Sie weiter",
|
||||
"create": "SNIPPET ERSTELLEN",
|
||||
"createFailed": "Snippet konnte nicht erstellt werden",
|
||||
"createFrom": "ERSTELLEN AUS",
|
||||
@ -9,6 +11,11 @@
|
||||
"deleteConfirmTitle": "Snippet löschen?",
|
||||
"deleteFailed": "Snippet konnte nicht gelöscht werden",
|
||||
"deleted": "Snippet gelöscht",
|
||||
"discardChanges": "Änderungen verwerfen",
|
||||
"discardChangesDescription": "Ihre Entwurfsänderungen werden verworfen und das Snippet kehrt zur zuletzt gespeicherten Version zurück.",
|
||||
"discardChangesTitle": "Entwurfsänderungen verwerfen?",
|
||||
"discardDraft": "Entwurf verwerfen",
|
||||
"doNotSave": "Als Entwurf belassen",
|
||||
"draft": "Entwurf",
|
||||
"dslVersionMismatchDescription": "Es wurde ein erheblicher Unterschied zwischen den DSL-Versionen festgestellt. Das Erzwingen des Imports kann zu Fehlfunktionen des Snippets führen.",
|
||||
"dslVersionMismatchQuestion": "Möchten Sie fortfahren?",
|
||||
@ -17,7 +24,9 @@
|
||||
"editDialogTitle": "Bearbeiten Sie die Snippet-Informationen",
|
||||
"editDone": "Snippet-Informationen aktualisiert",
|
||||
"editFailed": "Snippet-Informationen konnten nicht aktualisiert werden",
|
||||
"emptyGraphSaveError": "Fügen Sie vor dem Veröffentlichen mindestens einen Knoten hinzu.",
|
||||
"editingDraft": "Sie bearbeiten einen Entwurf.",
|
||||
"emptyGraphSaveError": "Fügen Sie vor dem Speichern mindestens einen Knoten hinzu.",
|
||||
"exitEditing": "Bearbeiten beenden",
|
||||
"exportFailed": "Der Export des Snippets ist fehlgeschlagen.",
|
||||
"importDSLFile": "DSL-Datei importieren",
|
||||
"importDialogTitle": "Snippet importieren",
|
||||
@ -43,11 +52,18 @@
|
||||
"publishFailed": "Snippet konnte nicht veröffentlicht werden",
|
||||
"publishMenuCurrentDraft": "Aktueller Entwurf unveröffentlicht",
|
||||
"publishSuccess": "Snippet veröffentlicht",
|
||||
"save": "Speichern",
|
||||
"saveAndExit": "Speichern und beenden",
|
||||
"saveBeforeLeavingDescription": "Speichern Sie, um diese Version für die Verwendung in Workflows verfügbar zu machen. Oder bewahren Sie Ihre Änderungen vorerst als Entwurf auf.",
|
||||
"saveBeforeLeavingTitle": "Änderungen vor dem Verlassen speichern?",
|
||||
"saveSuccess": "Snippet gespeichert",
|
||||
"sectionOrchestrate": "Orchestrieren",
|
||||
"testRunButton": "Testlauf",
|
||||
"typeLabel": "Ausschnitt",
|
||||
"unknownUser": "Benutzer",
|
||||
"unsavedChanges": "Aktuelle Änderungen werden nicht gespeichert.",
|
||||
"updatedBy": "{{name}} aktualisiert {{time}}",
|
||||
"usageCount": "{{count}} Mal verwendet",
|
||||
"variableInspect": "Variablenprüfung"
|
||||
"variableInspect": "Variablenprüfung",
|
||||
"viewOnly": "Nur ansehen"
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user