Compare commits

..

7 Commits

Author SHA1 Message Date
31f436a251 fix(cli): simplify prerelease channel check and soften warning text
Replace explicit set of prerelease channels with != stable check so new
channels are covered automatically. Update warning to say "install or
wait for the stable channel".
2026-06-23 00:50:42 -07:00
38b12106fb fix(cli): update remaining hardcoded compat/warning strings in tests 2026-06-23 00:45:52 -07:00
a993998fdc style(cli): fix import order in render.ts 2026-06-23 00:35:34 -07:00
1e324233f4 fix(cli): fix tests for alpha compat window; add prerelease channel warnings
Update release-naming tests to reflect new compat window (1.15.0..1.15.0)
and difyctlTag matching 0.1.0-alpha. Replace hardcoded RC_WARNING_LINES with
a template-based prereleaseWarning() covering alpha, rc, and edge channels.
Update render.test.ts accordingly.
2026-06-23 00:28:02 -07:00
f92c6e68e2 fix(cli): add alpha and edge to BUILD_CHANNELS and Channel type 2026-06-23 00:18:09 -07:00
8ff692f524 style(cli): remove alignment spaces in CHANNELS array 2026-06-23 00:11:52 -07:00
0cff18e59b feat(cli): add alpha release channel
Adds alpha channel to release-naming.mjs with optional numeric suffix
(0.1.0-alpha or 0.1.0-alpha.1). Bumps package.json to 0.1.0-alpha,
channel alpha, compat window 1.15.0..1.15.0 for Dify 1.15.0 launch.
2026-06-23 00:04:50 -07:00
123 changed files with 2148 additions and 2147 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
from .pubsub_channel import BroadcastChannel
from .channel import BroadcastChannel
from .sharded_channel import ShardedRedisBroadcastChannel
__all__ = ["BroadcastChannel", "ShardedRedisBroadcastChannel"]

View File

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

View File

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

View File

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

View File

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

View File

@ -1 +0,0 @@
SIG_CLOSE = b"__closed__"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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}$/ },
]

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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": "عرض فقط"
}

View File

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