feat: refine snippet siderbar and support RBAC. (#38134)

Co-authored-by: JzoNg <jzongcode@gmail.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
FFXN 2026-06-29 14:03:40 +08:00 committed by GitHub
parent d2216fe181
commit ca2755e0c1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
118 changed files with 2525 additions and 1825 deletions

View File

@ -8,6 +8,7 @@ from pydantic import BaseModel, Field
from sqlalchemy.orm import Session, sessionmaker
from werkzeug.exceptions import BadRequest, InternalServerError, NotFound
from controllers.common.controller_schemas import WorkflowUpdatePayload
from controllers.common.fields import GeneratedAppResponse, SimpleResultResponse
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.console import console_ns
@ -96,6 +97,7 @@ register_schema_models(
SnippetLoopNodeRunPayload,
SnippetWorkflowListQuery,
WorkflowRunQuery,
WorkflowUpdatePayload,
PublishWorkflowPayload,
)
register_response_schema_models(
@ -165,7 +167,6 @@ class SnippetDraftWorkflowApi(Resource):
@account_initialization_required
@get_snippet
@edit_permission_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False)
def get(self, snippet: CustomizedSnippet):
"""Get draft workflow for snippet."""
snippet_service = _snippet_service()
@ -234,7 +235,6 @@ class SnippetDraftConfigApi(Resource):
@account_initialization_required
@get_snippet
@edit_permission_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False)
def get(self, snippet: CustomizedSnippet):
"""Get snippet draft workflow configuration limits."""
return {
@ -256,7 +256,6 @@ class SnippetPublishedWorkflowApi(Resource):
@account_initialization_required
@get_snippet
@edit_permission_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False)
def get(self, snippet: CustomizedSnippet):
"""Get published workflow for snippet."""
if not snippet.is_published:
@ -321,7 +320,6 @@ class SnippetDefaultBlockConfigsApi(Resource):
@account_initialization_required
@get_snippet
@edit_permission_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False)
def get(self, snippet: CustomizedSnippet):
"""Get default block configurations for snippet workflow."""
snippet_service = _snippet_service()
@ -344,7 +342,9 @@ class SnippetPublishedAllWorkflowApi(Resource):
@account_initialization_required
@get_snippet
@edit_permission_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False)
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
)
def get(self, snippet: CustomizedSnippet):
"""Get all published workflow versions for snippet."""
args = SnippetWorkflowListQuery.model_validate(request.args.to_dict(flat=True))
@ -413,6 +413,49 @@ class SnippetDraftWorkflowRestoreApi(Resource):
}
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/<string:workflow_id>")
class SnippetWorkflowByIdApi(Resource):
@console_ns.doc("update_snippet_workflow_by_id")
@console_ns.doc(description="Update published snippet workflow attributes")
@console_ns.doc(params={"snippet_id": "Snippet ID", "workflow_id": "Workflow ID"})
@console_ns.expect(console_ns.models[WorkflowUpdatePayload.__name__])
@console_ns.response(200, "Workflow updated successfully", console_ns.models[SnippetWorkflowResponse.__name__])
@console_ns.response(400, "No valid fields to update")
@console_ns.response(404, "Workflow not found")
@setup_required
@login_required
@account_initialization_required
@with_current_user
@get_snippet
@edit_permission_required
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
)
def patch(self, current_user: Account, snippet: CustomizedSnippet, workflow_id: str):
"""Update a published snippet workflow version's display metadata."""
payload = WorkflowUpdatePayload.model_validate(console_ns.payload or {})
update_data = payload.model_dump(exclude_unset=True)
if not update_data:
return {"message": "No valid fields to update"}, 400
snippet_service = _snippet_service()
with _snippet_session_maker().begin() as session:
workflow = snippet_service.update_workflow(
session=session,
snippet=snippet,
workflow_id=workflow_id,
account=current_user,
data=update_data,
)
if not workflow:
raise NotFound("Workflow not found")
response = SnippetWorkflowResponse.model_validate(workflow, from_attributes=True).model_dump(mode="json")
response["input_fields"] = snippet.input_fields_list
return response
@console_ns.route("/snippets/<uuid:snippet_id>/workflow-runs")
class SnippetWorkflowRunsApi(Resource):
@console_ns.doc("list_snippet_workflow_runs")
@ -514,9 +557,6 @@ class SnippetDraftNodeRunApi(Resource):
@with_current_user
@get_snippet
@edit_permission_required
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
)
def post(self, current_user: Account, snippet: CustomizedSnippet, node_id: str):
"""
Run a single node in snippet draft workflow.
@ -605,9 +645,6 @@ class SnippetDraftRunIterationNodeApi(Resource):
@with_current_user
@get_snippet
@edit_permission_required
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
)
def post(self, current_user: Account, snippet: CustomizedSnippet, node_id: str):
"""
Run a draft workflow iteration node for snippet.
@ -653,9 +690,6 @@ class SnippetDraftRunLoopNodeApi(Resource):
@with_current_user
@get_snippet
@edit_permission_required
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
)
def post(self, current_user: Account, snippet: CustomizedSnippet, node_id: str):
"""
Run a draft workflow loop node for snippet.
@ -699,9 +733,6 @@ class SnippetDraftWorkflowRunApi(Resource):
@with_current_user
@get_snippet
@edit_permission_required
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
)
def post(self, current_user: Account, snippet: CustomizedSnippet):
"""
Run draft workflow for snippet.
@ -740,9 +771,6 @@ class SnippetWorkflowTaskStopApi(Resource):
@account_initialization_required
@get_snippet
@edit_permission_required
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
)
def post(self, snippet: CustomizedSnippet, task_id: str):
"""
Stop a running snippet workflow task.

View File

@ -34,11 +34,8 @@ from controllers.console.app.workflow_draft_variable import (
)
from controllers.console.snippets.snippet_workflow import get_snippet
from controllers.console.wraps import (
RBACPermission,
RBACResourceScope,
account_initialization_required,
edit_permission_required,
rbac_permission_required,
setup_required,
with_current_user,
)
@ -105,7 +102,6 @@ class SnippetWorkflowVariableCollectionApi(Resource):
)
@_snippet_draft_var_prerequisite
@marshal_with(workflow_draft_variable_list_without_value_model)
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False)
def get(self, current_user: Account, snippet: CustomizedSnippet) -> WorkflowDraftVariableList:
args = WorkflowDraftVariableListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
@ -129,9 +125,6 @@ class SnippetWorkflowVariableCollectionApi(Resource):
@console_ns.doc(description="Delete all draft workflow variables for the current user (snippet scope)")
@console_ns.response(204, "Workflow variables deleted successfully")
@_snippet_draft_var_prerequisite
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
)
def delete(self, current_user: Account, snippet: CustomizedSnippet) -> Response:
draft_var_srv = WorkflowDraftVariableService(session=db.session())
draft_var_srv.delete_user_workflow_variables(snippet.id, user_id=current_user.id)

View File

@ -4,12 +4,17 @@ from uuid import UUID
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from werkzeug.exceptions import Forbidden
from configs import dify_config
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.common.wraps import enforce_rbac_access
from controllers.console import console_ns
from controllers.console.wraps import (
RBACPermission,
RBACResourceScope,
account_initialization_required,
edit_permission_required,
setup_required,
@ -18,9 +23,10 @@ from controllers.console.wraps import (
)
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.login import login_required
from libs.login import current_account_with_tenant, login_required
from models import Account
from models.enums import TagType
from models.model import Tag
from services.tag_service import (
SaveTagPayload,
TagBindingCreatePayload,
@ -91,6 +97,30 @@ register_schema_models(
register_response_schema_models(console_ns, SimpleResultResponse)
def _enforce_snippet_tag_rbac_if_needed(tag_type: TagType | str | None) -> None:
if tag_type != TagType.SNIPPET:
return
if not dify_config.RBAC_ENABLED:
return
current_user, current_tenant_id = current_account_with_tenant()
enforce_rbac_access(
tenant_id=current_tenant_id,
account_id=current_user.id,
resource_type=RBACResourceScope.WORKSPACE,
scene=RBACPermission.SNIPPETS_CREATE_AND_MODIFY,
resource_required=False,
)
def _enforce_snippet_tag_rbac_by_tag_id(tag_id: str) -> None:
if not dify_config.RBAC_ENABLED:
return
tag_type = db.session.scalar(select(Tag.type).where(Tag.id == tag_id).limit(1))
_enforce_snippet_tag_rbac_if_needed(tag_type)
@console_ns.route("/tags")
class TagListApi(Resource):
@setup_required
@ -122,6 +152,7 @@ class TagListApi(Resource):
raise Forbidden()
payload = TagBasePayload.model_validate(console_ns.payload or {})
_enforce_snippet_tag_rbac_if_needed(payload.type)
tag = TagService.save_tags(SaveTagPayload(name=payload.name, type=payload.type), db.session)
response = TagResponse.model_validate(
@ -146,6 +177,7 @@ class TagUpdateDeleteApi(Resource):
raise Forbidden()
payload = TagUpdateRequestPayload.model_validate(console_ns.payload or {})
_enforce_snippet_tag_rbac_by_tag_id(tag_id_str)
tag = TagService.update_tags(UpdateTagPayload(name=payload.name), tag_id_str, db.session)
binding_count = TagService.get_tag_binding_count(tag_id_str, db.session)
@ -164,6 +196,7 @@ class TagUpdateDeleteApi(Resource):
def delete(self, tag_id: UUID):
tag_id_str = str(tag_id)
_enforce_snippet_tag_rbac_by_tag_id(tag_id_str)
TagService.delete_tag(tag_id_str, db.session)
return "", 204
@ -184,6 +217,7 @@ def _create_tag_bindings(current_user: Account) -> tuple[dict[str, str], int]:
_require_tag_binding_edit_permission(current_user)
payload = TagBindingPayload.model_validate(console_ns.payload or {})
_enforce_snippet_tag_rbac_if_needed(payload.type)
TagService.save_tag_binding(
TagBindingCreatePayload(
tag_ids=payload.tag_ids,
@ -199,6 +233,7 @@ def _remove_tag_bindings(current_user: Account) -> tuple[dict[str, str], int]:
_require_tag_binding_edit_permission(current_user)
payload = TagBindingRemovePayload.model_validate(console_ns.payload or {})
_enforce_snippet_tag_rbac_if_needed(payload.type)
TagService.delete_tag_binding(
TagBindingDeletePayload(
tag_ids=payload.tag_ids,

View File

@ -455,9 +455,6 @@ class CustomizedSnippetUseCountIncrementApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
)
@with_current_tenant_id
def post(self, current_tenant_id: str, snippet_id: str):
"""Increment snippet use count when it is inserted into a workflow."""

View File

@ -8700,6 +8700,32 @@ Reset a draft workflow variable to its default value (snippet scope)
| 200 | Workflow published successfully | **application/json**: [WorkflowPublishResponse](#workflowpublishresponse)<br> |
| 400 | No draft workflow found | |
### [PATCH] /snippets/{snippet_id}/workflows/{workflow_id}
**Update a published snippet workflow version's display metadata**
Update published snippet workflow attributes
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| snippet_id | path | Snippet ID | Yes | string (uuid) |
| workflow_id | path | Workflow ID | Yes | string |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [WorkflowUpdatePayload](#workflowupdatepayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workflow updated successfully | **application/json**: [SnippetWorkflowResponse](#snippetworkflowresponse)<br> |
| 400 | No valid fields to update | |
| 404 | Workflow not found | |
### [POST] /snippets/{snippet_id}/workflows/{workflow_id}/restore
**Restore a published snippet workflow version into the draft workflow**

View File

@ -680,6 +680,46 @@ class SnippetService:
return workflows, has_more
def update_workflow(
self,
*,
session: Session,
snippet: CustomizedSnippet,
workflow_id: str,
account: Account,
data: dict[str, Any],
) -> Workflow | None:
"""
Update a published snippet workflow version's display metadata.
:param session: Database session
:param snippet: CustomizedSnippet instance
:param workflow_id: Workflow ID
:param account: Account making the change
:param data: Dictionary containing fields to update
:return: Updated workflow or None if not found
"""
stmt = select(Workflow).where(
Workflow.id == workflow_id,
Workflow.tenant_id == snippet.tenant_id,
Workflow.app_id == snippet.id,
self._snippet_kind_filter(),
Workflow.version != Workflow.VERSION_DRAFT,
)
workflow = session.scalar(stmt)
if not workflow:
return None
allowed_fields = {"marked_name", "marked_comment"}
for field, value in data.items():
if field in allowed_fields:
setattr(workflow, field, value)
workflow.updated_by = account.id
workflow.updated_at = datetime.now(UTC).replace(tzinfo=None)
session.add(workflow)
return workflow
# --- Default Block Configs ---
def get_default_block_configs(self) -> list[dict]:

View File

@ -361,6 +361,117 @@ def test_restore_published_snippet_workflow_to_draft_returns_400_for_invalid_gra
assert exc.value.description == "invalid snippet workflow graph"
def test_update_published_snippet_workflow_returns_updated_workflow(
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="v1",
marked_comment="first version",
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=[],
)
user = _account("account-1")
input_fields = [{"variable": "query", "type": "text"}]
snippet = _snippet(input_fields=json.dumps(input_fields))
session = SimpleNamespace()
update_workflow = Mock(return_value=workflow)
class TransactionContext:
def __enter__(self):
return session
def __exit__(self, exc_type, exc, tb):
return False
class SessionMaker:
def begin(self):
return TransactionContext()
monkeypatch.setattr(snippet_workflow_module, "_snippet_session_maker", Mock(return_value=SessionMaker()))
monkeypatch.setattr(
snippet_workflow_module,
"SnippetService",
lambda: SimpleNamespace(update_workflow=update_workflow),
)
api = snippet_workflow_module.SnippetWorkflowByIdApi()
handler = unwrap(api.patch)
with app.test_request_context(
"/snippets/snippet-1/workflows/workflow-1",
method="PATCH",
json={"marked_name": "v1", "marked_comment": "first version"},
):
response = handler(api, user, snippet, workflow_id="workflow-1")
update_workflow.assert_called_once_with(
session=session,
snippet=snippet,
workflow_id="workflow-1",
account=user,
data={"marked_name": "v1", "marked_comment": "first version"},
)
assert response["marked_name"] == "v1"
assert response["marked_comment"] == "first version"
assert response["input_fields"] == input_fields
def test_update_published_snippet_workflow_returns_400_when_no_fields(app: Flask) -> None:
api = snippet_workflow_module.SnippetWorkflowByIdApi()
handler = unwrap(api.patch)
with app.test_request_context("/snippets/snippet-1/workflows/workflow-1", method="PATCH", json={}):
response, status_code = handler(api, _account("account-1"), _snippet(), workflow_id="workflow-1")
assert status_code == 400
assert response == {"message": "No valid fields to update"}
def test_update_published_snippet_workflow_raises_not_found(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
user = _account("account-1")
snippet = _snippet()
class TransactionContext:
def __enter__(self):
return SimpleNamespace()
def __exit__(self, exc_type, exc, tb):
return False
class SessionMaker:
def begin(self):
return TransactionContext()
monkeypatch.setattr(snippet_workflow_module, "_snippet_session_maker", Mock(return_value=SessionMaker()))
monkeypatch.setattr(
snippet_workflow_module,
"SnippetService",
lambda: SimpleNamespace(update_workflow=Mock(return_value=None)),
)
api = snippet_workflow_module.SnippetWorkflowByIdApi()
handler = unwrap(api.patch)
with app.test_request_context(
"/snippets/snippet-1/workflows/missing-workflow",
method="PATCH",
json={"marked_name": "v1"},
):
with pytest.raises(NotFound, match="Workflow not found"):
handler(api, user, snippet, workflow_id="missing-workflow")
def test_workflow_run_detail_raises_not_found_when_run_missing(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
snippet = _snippet()
monkeypatch.setattr(

View File

@ -155,6 +155,36 @@ class TestTagListApi:
assert result["name"] == "test-tag"
assert result["binding_count"] == "0"
def test_post_snippet_tag_checks_snippet_rbac_when_enabled(self, app: Flask, admin_user, tag, payload_patch):
api = TagListApi()
method = unwrap(api.post)
payload = {"name": "snippet-tag", "type": "snippet"}
with app.test_request_context("/", json=payload):
with (
payload_patch(payload),
patch("controllers.console.tag.tags.dify_config.RBAC_ENABLED", True),
patch(
"controllers.console.tag.tags.current_account_with_tenant",
return_value=(SimpleNamespace(id="user-1"), "tenant-1"),
),
patch("controllers.console.tag.tags.enforce_rbac_access") as enforce_mock,
patch(
"controllers.console.tag.tags.TagService.save_tags",
return_value=tag,
),
):
method(api, admin_user)
enforce_mock.assert_called_once_with(
tenant_id="tenant-1",
account_id="user-1",
resource_type=module.RBACResourceScope.WORKSPACE,
scene=module.RBACPermission.SNIPPETS_CREATE_AND_MODIFY,
resource_required=False,
)
def test_post_forbidden(self, app: Flask, readonly_user, payload_patch):
api = TagListApi()
method = unwrap(api.post)

View File

@ -273,6 +273,45 @@ def test_sync_draft_workflow_updates_existing_draft_and_clears_variables(monkeyp
session.commit.assert_called_once()
def test_update_workflow_updates_marked_fields() -> None:
service = SnippetService.__new__(SnippetService)
workflow = SimpleNamespace(marked_name="", marked_comment="", updated_by=None, updated_at=None)
session = SimpleNamespace(scalar=Mock(return_value=workflow), add=Mock())
snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1")
account = SimpleNamespace(id="account-1")
result = service.update_workflow(
session=session,
snippet=snippet,
workflow_id="workflow-1",
account=account,
data={"marked_name": "v1", "marked_comment": "first version", "ignored": "value"},
)
assert result is workflow
assert workflow.marked_name == "v1"
assert workflow.marked_comment == "first version"
assert workflow.updated_by == "account-1"
session.scalar.assert_called_once()
session.add.assert_called_once_with(workflow)
def test_update_workflow_returns_none_when_missing() -> None:
service = SnippetService.__new__(SnippetService)
session = SimpleNamespace(scalar=Mock(return_value=None), add=Mock())
result = service.update_workflow(
session=session,
snippet=SimpleNamespace(id="snippet-1", tenant_id="tenant-1"),
workflow_id="missing-workflow",
account=SimpleNamespace(id="account-1"),
data={"marked_name": "v1"},
)
assert result is None
session.add.assert_not_called()
def test_get_default_block_configs_skips_empty_defaults(monkeypatch: pytest.MonkeyPatch) -> None:
node_with_default = SimpleNamespace(get_default_config=Mock(return_value={"type": "llm"}))
node_without_default = SimpleNamespace(get_default_config=Mock(return_value=None))

View File

@ -4365,14 +4365,6 @@
"count": 1
}
},
"web/app/components/snippet-list/components/snippet-card.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/snippets/components/snippet-run-panel.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 5
@ -4389,11 +4381,6 @@
"count": 1
}
},
"web/app/components/snippets/hooks/use-nodes-sync-draft.ts": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/snippets/hooks/use-snippet-run.ts": {
"no-restricted-imports": {
"count": 2

View File

@ -43,6 +43,9 @@ import {
zGetSnippetsBySnippetIdWorkflowsPublishResponse,
zGetSnippetsBySnippetIdWorkflowsQuery,
zGetSnippetsBySnippetIdWorkflowsResponse,
zPatchSnippetsBySnippetIdWorkflowsByWorkflowIdBody,
zPatchSnippetsBySnippetIdWorkflowsByWorkflowIdPath,
zPatchSnippetsBySnippetIdWorkflowsByWorkflowIdResponse,
zPatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdBody,
zPatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdPath,
zPatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponse,
@ -710,7 +713,31 @@ export const restore = {
post: post8,
}
/**
* Update a published snippet workflow version's display metadata
*
* Update published snippet workflow attributes
*/
export const patch2 = oc
.route({
description: 'Update published snippet workflow attributes',
inputStructure: 'detailed',
method: 'PATCH',
operationId: 'patchSnippetsBySnippetIdWorkflowsByWorkflowId',
path: '/snippets/{snippet_id}/workflows/{workflow_id}',
summary: 'Update a published snippet workflow version\'s display metadata',
tags: ['console'],
})
.input(
z.object({
body: zPatchSnippetsBySnippetIdWorkflowsByWorkflowIdBody,
params: zPatchSnippetsBySnippetIdWorkflowsByWorkflowIdPath,
}),
)
.output(zPatchSnippetsBySnippetIdWorkflowsByWorkflowIdResponse)
export const byWorkflowId = {
patch: patch2,
restore,
}

View File

@ -208,6 +208,11 @@ export type WorkflowPublishResponse = {
result: string
}
export type WorkflowUpdatePayload = {
marked_comment?: string | null
marked_name?: string | null
}
export type WorkflowRunForListResponse = {
created_at?: number | null
created_by_account?: SimpleAccount | null
@ -823,6 +828,28 @@ export type PostSnippetsBySnippetIdWorkflowsPublishResponses = {
export type PostSnippetsBySnippetIdWorkflowsPublishResponse
= PostSnippetsBySnippetIdWorkflowsPublishResponses[keyof PostSnippetsBySnippetIdWorkflowsPublishResponses]
export type PatchSnippetsBySnippetIdWorkflowsByWorkflowIdData = {
body: WorkflowUpdatePayload
path: {
snippet_id: string
workflow_id: string
}
query?: never
url: '/snippets/{snippet_id}/workflows/{workflow_id}'
}
export type PatchSnippetsBySnippetIdWorkflowsByWorkflowIdErrors = {
400: unknown
404: unknown
}
export type PatchSnippetsBySnippetIdWorkflowsByWorkflowIdResponses = {
200: SnippetWorkflowResponse
}
export type PatchSnippetsBySnippetIdWorkflowsByWorkflowIdResponse
= PatchSnippetsBySnippetIdWorkflowsByWorkflowIdResponses[keyof PatchSnippetsBySnippetIdWorkflowsByWorkflowIdResponses]
export type PostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreData = {
body?: never
path: {

View File

@ -133,6 +133,14 @@ export const zWorkflowPublishResponse = z.object({
result: z.string(),
})
/**
* WorkflowUpdatePayload
*/
export const zWorkflowUpdatePayload = z.object({
marked_comment: z.string().max(100).nullish(),
marked_name: z.string().max(20).nullish(),
})
/**
* SimpleAccount
*/
@ -667,6 +675,18 @@ export const zPostSnippetsBySnippetIdWorkflowsPublishPath = z.object({
*/
export const zPostSnippetsBySnippetIdWorkflowsPublishResponse = zWorkflowPublishResponse
export const zPatchSnippetsBySnippetIdWorkflowsByWorkflowIdBody = zWorkflowUpdatePayload
export const zPatchSnippetsBySnippetIdWorkflowsByWorkflowIdPath = z.object({
snippet_id: z.uuid(),
workflow_id: z.string(),
})
/**
* Workflow updated successfully
*/
export const zPatchSnippetsBySnippetIdWorkflowsByWorkflowIdResponse = zSnippetWorkflowResponse
export const zPostSnippetsBySnippetIdWorkflowsByWorkflowIdRestorePath = z.object({
snippet_id: z.uuid(),
workflow_id: z.string(),

View File

@ -192,7 +192,7 @@ describe('SnippetInfoDropdown', () => {
await user.click(screen.getByRole('button'))
expect(screen.getByText('snippet.menu.editInfo')).toBeInTheDocument()
expect(screen.queryByText('snippet.menu.exportSnippet')).not.toBeInTheDocument()
expect(screen.getByText('snippet.menu.exportSnippet')).toBeInTheDocument()
expect(screen.queryByText('snippet.menu.deleteSnippet')).not.toBeInTheDocument()
unmount()
@ -201,7 +201,7 @@ describe('SnippetInfoDropdown', () => {
await user.click(screen.getByRole('button'))
expect(screen.queryByText('snippet.menu.editInfo')).not.toBeInTheDocument()
expect(screen.getByText('snippet.menu.exportSnippet')).toBeInTheDocument()
expect(screen.queryByText('snippet.menu.exportSnippet')).not.toBeInTheDocument()
expect(screen.getByText('snippet.menu.deleteSnippet')).toBeInTheDocument()
})
})
@ -244,7 +244,7 @@ describe('SnippetInfoDropdown', () => {
describe('Export Snippet', () => {
it('should export and download the snippet yaml', async () => {
const user = userEvent.setup()
mockWorkspacePermissionKeys = ['snippets.management']
mockWorkspacePermissionKeys = ['snippets.create_and_modify']
mockExportMutateAsync.mockResolvedValue('yaml: content')
render(<SnippetInfoDropdown snippet={mockSnippet} />)
@ -264,7 +264,7 @@ describe('SnippetInfoDropdown', () => {
it('should show an error toast when export fails', async () => {
const user = userEvent.setup()
mockWorkspacePermissionKeys = ['snippets.management']
mockWorkspacePermissionKeys = ['snippets.create_and_modify']
mockExportMutateAsync.mockRejectedValue(new Error('export failed'))
render(<SnippetInfoDropdown snippet={mockSnippet} />)

View File

@ -58,7 +58,7 @@ const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => {
}, [])
const handleExportSnippet = React.useCallback(async () => {
if (!canManageSnippet)
if (!canCreateAndModifySnippet)
return
setOpen(false)
@ -70,7 +70,7 @@ const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => {
catch {
toast.error(t('exportFailed'))
}
}, [canManageSnippet, exportSnippetMutation, snippet.id, snippet.name, t])
}, [canCreateAndModifySnippet, exportSnippetMutation, snippet.id, snippet.name, t])
const handleEditSnippet = React.useCallback(async ({ name, description }: {
name: string
@ -125,18 +125,20 @@ const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => {
popupClassName="w-[180px] p-1"
>
{canCreateAndModifySnippet && (
<DropdownMenuItem className="mx-0 gap-2" onClick={handleOpenEditDialog}>
<span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" />
<span className="grow">{t('menu.editInfo')}</span>
</DropdownMenuItem>
)}
{canManageSnippet && (
<>
<DropdownMenuItem className="mx-0 gap-2" onClick={handleOpenEditDialog}>
<span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" />
<span className="grow">{t('menu.editInfo')}</span>
</DropdownMenuItem>
<DropdownMenuItem className="mx-0 gap-2" onClick={handleExportSnippet}>
<span aria-hidden className="i-ri-download-2-line size-4 shrink-0 text-text-tertiary" />
<span className="grow">{t('menu.exportSnippet')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator className="my-1! bg-divider-subtle" />
</>
)}
{canManageSnippet && (
<>
{canCreateAndModifySnippet && <DropdownMenuSeparator className="my-1! bg-divider-subtle" />}
<DropdownMenuItem
className="mx-0 gap-2"
variant="destructive"

View File

@ -426,16 +426,18 @@ describe('List', () => {
expect(screen.getByRole('button', { name: 'common.operation.create' }))!.toBeInTheDocument()
})
it('should render sort filter before search and hide the snippets link', () => {
it('should render sort filter before search and 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(createButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(screen.queryByRole('link', { name: 'app.studio.viewSnippets' })).not.toBeInTheDocument()
expect(searchInput.compareDocumentPosition(snippetsLink) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(snippetsLink.compareDocumentPosition(createButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
})
it('should render app cards when apps exist', () => {

View File

@ -8,6 +8,7 @@ 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'
@ -70,6 +71,12 @@ 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

@ -5,6 +5,7 @@ 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'
@ -16,6 +17,7 @@ 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'
@ -26,14 +28,29 @@ import { DETAIL_SIDEBAR_STORAGE_KEY } from '../storage'
const activeGradientMaskClassName = 'aria-[current=page]:dify-blue-glass-surface'
const activeStackingClassName = 'aria-[current=page]:z-1'
const { mockIsAgentV2Enabled, mockSwitchWorkspace, mockToastSuccess, hotkeyRegistrations } = vi.hoisted(() => ({
type SnippetNavigationTestState = {
onFieldsChange?: (fields: SnippetInputField[]) => void
readonly: boolean
snippet?: SnippetDetail
}
const { mockIsAgentV2Enabled, mockSnippetFieldsChange, mockSwitchWorkspace, mockToastSuccess, hotkeyRegistrations, snippetDraftState, snippetNavigationState } = 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', () => ({
@ -185,6 +202,42 @@ 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}`,
@ -244,6 +297,24 @@ 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',
@ -373,6 +444,10 @@ describe('MainNav', () => {
})
mockSwitchWorkspace.mockReturnValue(new Promise(() => {}))
hotkeyRegistrations.clear()
snippetDraftState.inputFields = []
snippetNavigationState.onFieldsChange = undefined
snippetNavigationState.readonly = true
snippetNavigationState.snippet = undefined
useAppStore.getState().setAppDetail()
})
@ -584,12 +659,24 @@ describe('MainNav', () => {
expect(screen.getByRole('link', { name: /common.mainNav.home/ })).not.toHaveAttribute('aria-current')
})
it('hides the main menu on snippet detail routes while keeping account settings available', () => {
it('replaces global navigation with snippet detail navigation on snippet routes', () => {
mockPathname = '/snippets/snippet-1/orchestrate'
snippetDraftState.inputFields = snippetFields
snippetNavigationState.onFieldsChange = mockSnippetFieldsChange
snippetNavigationState.readonly = false
snippetNavigationState.snippet = snippet
renderMainNav()
expect(screen.getByRole('complementary')).toHaveClass('w-16')
expect(screen.getByRole('complementary')).toHaveClass('w-62')
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.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()
@ -599,6 +686,24 @@ 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

@ -0,0 +1,107 @@
'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

@ -15,6 +15,10 @@ import { useStore as useAppStore } from '@/app/components/app/store'
import Badge from '@/app/components/base/badge'
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'
@ -26,6 +30,7 @@ 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'
@ -96,8 +101,14 @@ export function MainNav({
const showDatasetDetailNavigation = isDatasetDetailPathname(pathname)
const showAgentDetailNavigation = agentV2Enabled && !isCurrentWorkspaceDatasetOperator && isAgentDetailPathname(pathname)
const showDeploymentDetailNavigation = canUseAppDeploy && !isCurrentWorkspaceDatasetOperator && isDeploymentDetailPathname(pathname)
const showSnippetDetailBottomNavigation = isSnippetDetailPathname(pathname)
const showDetailNavigation = showAppDetailNavigation || showDatasetDetailNavigation || showAgentDetailNavigation || showDeploymentDetailNavigation
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 { hasAppDetail, setAppDetail } = useAppStore(useShallow(state => ({
hasAppDetail: !!state.appDetail,
setAppDetail: state.setAppDetail,
@ -112,9 +123,7 @@ export function MainNav({
const detailNavigationTransitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const isDetailNavigationHoverPreviewOpen = isCollapsedDetailNavigation && detailNavigationHoverPreviewOpen
const detailNavigationVisibleExpanded = detailNavigationExpanded || isDetailNavigationHoverPreviewOpen
const bottomNavigationExpanded = showSnippetDetailBottomNavigation
? false
: !showDetailNavigation || detailNavigationVisibleExpanded
const bottomNavigationExpanded = !showDetailNavigation || detailNavigationVisibleExpanded
const handleToggleDetailNavigation = useCallback(() => {
if (isDetailNavigationHoverPreviewOpen) {
if (detailNavigationTransitionTimerRef.current)
@ -224,9 +233,7 @@ export function MainNav({
? detailNavigationExpanded
? 'w-62 bg-background-body p-1'
: 'w-16 bg-background-body p-1'
: showSnippetDetailBottomNavigation
? 'w-16 bg-background-body p-1'
: 'w-60 flex-col',
: 'w-60 flex-col',
'bg-background-body',
className,
)}
@ -267,25 +274,30 @@ export function MainNav({
onToggle={handleToggleDetailNavigation}
/>
)
: (
<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>
</>
)}
: 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>
</>
)}
{showDetailNavigation
? showAppDetailNavigation
? <AppDetailSection expand={detailNavigationVisibleExpanded} />
@ -293,29 +305,40 @@ export function MainNav({
? <DatasetDetailSection expand={detailNavigationVisibleExpanded} />
: showAgentDetailNavigation
? <AgentDetailSection expand={detailNavigationVisibleExpanded} />
: <DeploymentDetailSection expand={detailNavigationVisibleExpanded} />
: showSnippetDetailBottomNavigation
? null
: (
<>
<nav className="isolate flex flex-col gap-px p-2">
{navItems.map(item => (
<MainNavLink key={item.href} item={item} pathname={pathname}>
{item.href === '/roster' && (
<Badge
size="xs"
variant="dimm"
text={t('menus.status', { ns: 'common' })}
className="ml-auto shrink-0"
: showDeploymentDetailNavigation
? <DeploymentDetailSection expand={detailNavigationVisibleExpanded} />
: detailNavigationVisibleExpanded
? snippetNavigation.snippet && snippetNavigation.onFieldsChange
? (
<SnippetSidebarContent
snippet={snippetNavigation.snippet}
fields={snippetInputFields}
readonly={snippetNavigation.readonly}
onFieldsChange={snippetNavigation.onFieldsChange}
/>
)}
</MainNavLink>
))}
</nav>
{!isCurrentWorkspaceDatasetOperator && <WebAppsSection />}
</>
)}
{showEnvTag && !showSnippetDetailBottomNavigation && detailNavigationVisibleExpanded && (
)
: null
: <SnippetCollapsedPreview inputFieldCount={snippetInputFields.length} />
: (
<>
<nav className="isolate flex flex-col gap-px p-2">
{navItems.map(item => (
<MainNavLink key={item.href} item={item} pathname={pathname}>
{item.href === '/roster' && (
<Badge
size="xs"
variant="dimm"
text={t('menus.status', { ns: 'common' })}
className="ml-auto shrink-0"
/>
)}
</MainNavLink>
))}
</nav>
{!isCurrentWorkspaceDatasetOperator && <WebAppsSection />}
</>
)}
{showEnvTag && detailNavigationVisibleExpanded && (
<div className="relative z-30 mt-auto shrink-0 px-3 pb-2">
<EnvNav />
</div>

View File

@ -262,6 +262,7 @@ 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()
@ -287,6 +288,42 @@ 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()
@ -322,6 +359,18 @@ describe('SnippetList', () => {
expect(screen.queryByRole('button', { name: 'snippet.create' })).not.toBeInTheDocument()
})
it('fetches snippets without create action for users with snippet management permission', () => {
mockWorkspacePermissionKeys.mockReturnValue(['snippets.management'])
renderList()
expect(mockUseInfiniteSnippetList).toHaveBeenCalledWith(expect.any(Object), {
enabled: true,
})
expect(screen.getByRole('link', { name: /Sales Snippet/ })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'snippet.create' })).not.toBeInTheDocument()
})
it('does not fetch or render snippets without snippet list permissions', () => {
mockWorkspacePermissionKeys.mockReturnValue([])

View File

@ -181,11 +181,11 @@ describe('SnippetCard', () => {
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
expect(await screen.findByRole('menuitem', { name: 'snippet.menu.editInfo' })).toBeInTheDocument()
expect(screen.queryByRole('menuitem', { name: 'snippet.menu.exportSnippet' })).not.toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: 'snippet.menu.exportSnippet' })).toBeInTheDocument()
expect(screen.queryByRole('menuitem', { name: 'snippet.menu.deleteSnippet' })).not.toBeInTheDocument()
})
it('should show export and delete with snippet management permission without edit info', async () => {
it('should show delete with snippet management permission without create-and-modify actions', async () => {
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
mockWorkspacePermissionKeys.mockReturnValue(['snippets.management'])
@ -194,7 +194,7 @@ describe('SnippetCard', () => {
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
expect(screen.queryByRole('menuitem', { name: 'snippet.menu.editInfo' })).not.toBeInTheDocument()
expect(await screen.findByRole('menuitem', { name: 'snippet.menu.exportSnippet' })).toBeInTheDocument()
expect(screen.queryByRole('menuitem', { name: 'snippet.menu.exportSnippet' })).not.toBeInTheDocument()
expect(await screen.findByRole('menuitem', { name: 'snippet.menu.deleteSnippet' })).toBeInTheDocument()
})
@ -243,7 +243,7 @@ describe('SnippetCard', () => {
})
it('should export a snippet from the operations menu', async () => {
mockWorkspacePermissionKeys.mockReturnValue(['snippets.management'])
mockWorkspacePermissionKeys.mockReturnValue(['snippets.create_and_modify'])
mockExportMutateAsync.mockResolvedValue('snippet-yaml')
render(<SnippetCard snippet={createSnippet()} />)
@ -260,7 +260,7 @@ describe('SnippetCard', () => {
})
it('should show an error toast when snippet export fails', async () => {
mockWorkspacePermissionKeys.mockReturnValue(['snippets.management'])
mockWorkspacePermissionKeys.mockReturnValue(['snippets.create_and_modify'])
mockExportMutateAsync.mockRejectedValue(new Error('export failed'))
render(<SnippetCard snippet={createSnippet()} />)

View File

@ -0,0 +1,26 @@
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

@ -82,7 +82,7 @@ const SnippetCard = ({
}
const handleExportSnippet = async () => {
if (!canManageSnippet)
if (!canCreateAndModifySnippet)
return
setIsOperationsMenuOpen(false)
@ -155,13 +155,7 @@ const SnippetCard = ({
</Link>
<div className="absolute right-0 bottom-1 left-0 flex h-10.5 shrink-0 items-center pt-1 pr-1.5 pb-1.5 pl-3.5">
<div
className="flex w-0 grow items-center gap-1"
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
}}
>
<div className="flex w-0 grow items-center gap-1">
<div className="mr-10.25 min-w-0 grow overflow-hidden">
<TagSelector
placement="bottom-start"
@ -204,16 +198,18 @@ const SnippetCard = ({
popupClassName="w-[216px]"
>
{canCreateAndModifySnippet && (
<DropdownMenuItem className="gap-2 px-3" onClick={handleOpenEditDialog}>
<span className="system-sm-regular text-text-secondary">{t('menu.editInfo')}</span>
</DropdownMenuItem>
)}
{canManageSnippet && (
<>
<DropdownMenuItem className="gap-2 px-3" onClick={handleOpenEditDialog}>
<span className="system-sm-regular text-text-secondary">{t('menu.editInfo')}</span>
</DropdownMenuItem>
<DropdownMenuItem className="gap-2 px-3" onClick={handleExportSnippet}>
<span className="system-sm-regular text-text-secondary">{t('menu.exportSnippet')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{canManageSnippet && (
<>
{canCreateAndModifySnippet && <DropdownMenuSeparator />}
<DropdownMenuItem
variant="destructive"
className="gap-2 px-3"

View File

@ -0,0 +1,78 @@
'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,5 +1,6 @@
'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'
@ -18,6 +19,7 @@ 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'
@ -27,6 +29,14 @@ 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
}
@ -59,16 +69,22 @@ 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(() => ({
page: 1,
limit: 30,
keyword: debouncedKeywords,
...(tagIDs.length ? { tag_ids: tagIDs } : {}),
...(creatorIDs.length ? { creator_ids: creatorIDs } : {}),
}), [creatorIDs, debouncedKeywords, tagIDs])
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 canQuerySnippetList = canAccessSnippets(workspacePermissionKeys)
const {
@ -144,6 +160,10 @@ 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

@ -1,46 +0,0 @@
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,22 +41,15 @@ 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
canEdit
isPublishing={false}
{...callbacks}
/>,
@ -66,10 +59,8 @@ 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,
canEdit: true,
isPublishing: false,
...callbacks,
}))

View File

@ -1,20 +1,20 @@
import type { ReactNode } from 'react'
import type { WorkflowProps } from '@/app/components/workflow'
import type { SnippetDetailPayload, SnippetInputField } from '@/models/snippet'
import type { SnippetDetail, SnippetDetailPayload, SnippetInputField } from '@/models/snippet'
import { toast } from '@langgenius/dify-ui/toast'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { act, 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 mockSetFields = vi.fn()
const mockSetNavigationState = vi.fn()
const mockPublishSnippetMutateAsync = vi.fn()
const mockUseSnippetPublishedWorkflow = vi.fn()
const mockFetchInspectVars = vi.fn()
const mockHandleBackupDraft = vi.fn()
const mockHandleLoadBackupDraft = vi.fn()
@ -24,10 +24,9 @@ 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 mockAppContext = vi.hoisted(() => ({
workspacePermissionKeys: ['snippets.create_and_modify'] as string[],
}))
const mockInspectVarsCrud = {
hasNodeInspectVars: vi.fn(),
@ -55,33 +54,30 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
vi.mock('@/context/app-context', () => ({
useSelector: <T,>(selector: (state: { workspacePermissionKeys: string[] }) => T): T => selector({
workspacePermissionKeys: mockWorkspacePermissionKeys.value,
workspacePermissionKeys: mockAppContext.workspacePermissionKeys,
}),
}))
let capturedHooksStore: Record<string, unknown> | undefined
let capturedWorkflowNodes: WorkflowProps['nodes'] | undefined
let snippetDetailStoreState: {
fields: SnippetInputField[]
onFieldsChange?: (fields: SnippetInputField[]) => void
readonly: boolean
reset: typeof mockReset
setFields: typeof mockSetFields
setNavigationState: typeof mockSetNavigationState
snippet?: SnippetDetail
snippetId?: string
}
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', () => ({
@ -164,56 +160,17 @@ 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>
{canEdit && <button type="button" disabled={!canSave} onClick={onPublish}>publish</button>}
</div>
),
}))
@ -299,16 +256,10 @@ const createDraftNode = (id = 'draft-node') => ({
describe('SnippetMain', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAppContext.workspacePermissionKeys = ['snippets.create_and_modify']
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)
@ -328,18 +279,24 @@ describe('SnippetMain', () => {
},
})
mockHandleCheckBeforePublish.mockResolvedValue(true)
mockSetNavigationState.mockImplementation((state) => {
snippetDetailStoreState = {
...snippetDetailStoreState,
...state,
}
})
capturedHooksStore = undefined
capturedWorkflowNodes = undefined
useSnippetDraftStore.getState().reset()
snippetDetailStoreState = {
fields: [...payload.inputFields],
readonly: true,
reset: mockReset,
setFields: mockSetFields,
setNavigationState: mockSetNavigationState,
}
mockWorkspacePermissionKeys.value = ['snippets.create_and_modify']
})
describe('Initial Mode', () => {
it('should enter draft editing mode by default when there is no published workflow', () => {
it('should render the draft graph by default when there is no published workflow', () => {
const draftNode = createDraftNode('draft-node')
renderSnippetMain({
@ -351,24 +308,60 @@ describe('SnippetMain', () => {
expect(capturedWorkflowNodes?.map(node => node.id)).toEqual(['draft-node'])
})
it('should stay readonly without snippet create-and-modify permission', async () => {
mockWorkspacePermissionKeys.value = []
it('should keep the snippet canvas editable and sync draft changes with create-and-modify permission', async () => {
const draftNode = createDraftNode('draft-node')
renderSnippetMain({
const { store } = renderSnippetMain({
hasPublishedWorkflow: false,
workflowDraftNodes: [draftNode],
})
expect(screen.queryByRole('button', { name: 'edit' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'publish' })).toBeInTheDocument()
expect(store.getState().canvasReadOnly).toBe(false)
expect(mockSetNavigationState).toHaveBeenCalledWith(expect.objectContaining({
readonly: false,
}))
const doSyncWorkflowDraft = capturedHooksStore?.doSyncWorkflowDraft as (() => Promise<void>)
await doSyncWorkflowDraft()
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
expect(mockDoSyncWorkflowDraft).toHaveBeenCalledTimes(1)
})
it('should enter readonly mode with published graph by default when published workflow exists', async () => {
it('should make the snippet canvas readonly and skip draft sync without create-and-modify permission', async () => {
mockAppContext.workspacePermissionKeys = ['snippets.management']
const { store } = renderSnippetMain({
hasPublishedWorkflow: false,
workflowDraftNodes: [createDraftNode('draft-node')],
})
expect(screen.queryByRole('button', { name: 'publish' })).not.toBeInTheDocument()
expect(store.getState().canvasReadOnly).toBe(true)
expect(mockSetNavigationState).toHaveBeenCalledWith(expect.objectContaining({
readonly: true,
}))
const doSyncWorkflowDraft = capturedHooksStore?.doSyncWorkflowDraft as (() => Promise<void>)
await doSyncWorkflowDraft()
const syncWorkflowDraftWhenPageClose = capturedHooksStore?.syncWorkflowDraftWhenPageClose as (() => void)
syncWorkflowDraftWhenPageClose()
snippetDetailStoreState.onFieldsChange?.([
{
type: PipelineInputVarType.textInput,
label: 'Question',
variable: 'question',
required: false,
},
])
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
expect(mockSyncWorkflowDraftWhenPageClose).not.toHaveBeenCalled()
expect(mockSyncInputFieldsDraft).not.toHaveBeenCalled()
})
it('should render the draft graph even when a published workflow exists', async () => {
const publishedNode = createDraftNode('published-node')
const draftNode = createDraftNode('draft-node')
@ -378,35 +371,13 @@ describe('SnippetMain', () => {
workflowDraftNodes: [draftNode],
})
expect(screen.getByRole('button', { name: 'edit' })).toBeInTheDocument()
expect(capturedWorkflowNodes?.map(node => node.id)).toEqual(['published-node'])
expect(screen.queryByRole('button', { name: 'edit' })).not.toBeInTheDocument()
expect(capturedWorkflowNodes?.map(node => node.id)).toEqual(['draft-node'])
const doSyncWorkflowDraft = capturedHooksStore?.doSyncWorkflowDraft as (() => Promise<void>)
await doSyncWorkflowDraft()
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()
expect(mockDoSyncWorkflowDraft).toHaveBeenCalledTimes(1)
})
})
@ -414,7 +385,12 @@ describe('SnippetMain', () => {
it('should sync draft input_fields when removing a field from the panel', async () => {
renderSnippetMain({ currentNodes: [createDraftNode()] })
fireEvent.click(screen.getByRole('button', { name: 'remove' }))
await waitFor(() => {
expect(snippetDetailStoreState.onFieldsChange).toEqual(expect.any(Function))
})
act(() => {
snippetDetailStoreState.onFieldsChange?.([])
})
await waitFor(() => {
expect(mockSyncInputFieldsDraft).toHaveBeenCalledWith([], {
@ -426,7 +402,20 @@ describe('SnippetMain', () => {
it('should sync draft input_fields when adding a field from the sidebar', async () => {
renderSnippetMain({ currentNodes: [createDraftNode()] })
fireEvent.click(screen.getByRole('button', { name: 'submit' }))
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,
},
])
})
await waitFor(() => {
expect(mockSyncInputFieldsDraft).toHaveBeenCalledWith([
@ -455,94 +444,13 @@ describe('SnippetMain', () => {
expect(mockDoSyncWorkflowDraft).toHaveBeenCalledWith()
})
it('should sync workflow draft before routing without saving changes', async () => {
it('should sync workflow draft when the page closes', () => {
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(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')
})
expect(mockSyncWorkflowDraftWhenPageClose).toHaveBeenCalledTimes(1)
})
})
@ -631,74 +539,6 @@ 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

@ -3,7 +3,7 @@ import type { SnippetDetail, SnippetInputField } from '@/models/snippet'
import { toast } from '@langgenius/dify-ui/toast'
import { fireEvent, render, screen } from '@testing-library/react'
import { PipelineInputVarType } from '@/models/pipeline'
import SnippetSidebar from '../snippet-sidebar'
import { SnippetSidebarContent } from '../snippet-sidebar'
let capturedVarListProps: {
list: InputVar[]
@ -53,20 +53,6 @@ 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[]
@ -96,7 +82,7 @@ const fields: SnippetInputField[] = [
},
]
describe('SnippetSidebar', () => {
describe('SnippetSidebarContent', () => {
beforeEach(() => {
vi.clearAllMocks()
capturedVarListProps = undefined
@ -106,7 +92,7 @@ describe('SnippetSidebar', () => {
it('should ignore unchanged VarList updates', () => {
const onFieldsChange = vi.fn()
render(
<SnippetSidebar
<SnippetSidebarContent
snippet={snippet}
fields={fields}
readonly={false}
@ -122,7 +108,7 @@ describe('SnippetSidebar', () => {
it('should forward changed VarList updates', () => {
const onFieldsChange = vi.fn()
render(
<SnippetSidebar
<SnippetSidebarContent
snippet={snippet}
fields={fields}
readonly={false}
@ -147,7 +133,7 @@ describe('SnippetSidebar', () => {
it('should hide input field operations when readonly', () => {
render(
<SnippetSidebar
<SnippetSidebarContent
snippet={snippet}
fields={fields}
readonly
@ -155,7 +141,11 @@ describe('SnippetSidebar', () => {
/>,
)
expect(screen.getByRole('link', { name: /snippet\.management/i })).toHaveAttribute('href', '/snippets')
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.queryByRole('button', { name: /common\.operation\.add/i })).not.toBeInTheDocument()
expect(capturedVarListProps?.readonly).toBe(true)
})
@ -163,7 +153,7 @@ describe('SnippetSidebar', () => {
it('should add a new input field from the config variable modal', () => {
const onFieldsChange = vi.fn()
render(
<SnippetSidebar
<SnippetSidebarContent
snippet={snippet}
fields={fields}
readonly={false}
@ -192,7 +182,7 @@ describe('SnippetSidebar', () => {
it('should reject duplicate variable and label updates', () => {
const onFieldsChange = vi.fn()
render(
<SnippetSidebar
<SnippetSidebarContent
snippet={snippet}
fields={fields}
readonly={false}

View File

@ -1,15 +1,10 @@
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: () => ({
@ -17,10 +12,6 @@ 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',
@ -32,19 +23,13 @@ const createField = (overrides: Partial<SnippetInputField> = {}): SnippetInputFi
describe('useSnippetInputFieldActions', () => {
beforeEach(() => {
vi.clearAllMocks()
snippetDetailStoreState = {
fields: [],
setFields: mockSetFields,
}
mockSetFields.mockImplementation((fields: SnippetInputField[]) => {
snippetDetailStoreState.fields = fields
})
useSnippetDraftStore.getState().reset()
mockSyncInputFieldsDraft.mockResolvedValue(undefined)
})
describe('Field sync', () => {
it('should update fields and sync the draft', () => {
snippetDetailStoreState.fields = [createField()]
useSnippetDraftStore.getState().setInputFields([createField()])
const { result } = renderHook(() => useSnippetInputFieldActions({
snippetId: 'snippet-1',
}))
@ -60,8 +45,8 @@ describe('useSnippetInputFieldActions', () => {
result.current.handleFieldsChange(nextFields)
})
expect(result.current.fields).toEqual([createField()])
expect(mockSetFields).toHaveBeenCalledWith(nextFields)
expect(result.current.fields).toEqual(nextFields)
expect(useSnippetDraftStore.getState().inputFields).toEqual(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.saveSuccess')
expect(toast.success).toHaveBeenCalledWith('snippet.publishSuccess')
})
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 {
fields,
setFields,
} = useSnippetDetailStore(useShallow(state => ({
fields: state.fields,
setFields: state.setFields,
inputFields,
setInputFields,
} = useSnippetDraftStore(useShallow(state => ({
inputFields: state.inputFields,
setInputFields: state.setInputFields,
})))
const handleFieldsChange = useCallback((newFields: SnippetInputField[]) => {
if (!canEdit)
return
setFields(newFields)
setInputFields(newFields)
void syncInputFieldsDraft(newFields, {
onRefresh: setFields,
onRefresh: setInputFields,
})
}, [canEdit, setFields, syncInputFieldsDraft])
}, [canEdit, setInputFields, syncInputFieldsDraft])
return {
fields,
fields: inputFields,
handleFieldsChange,
}
}

View File

@ -42,7 +42,7 @@ export const useSnippetPublish = ({
)
workflowStore.getState().setPublishedAt(publishedWorkflow.created_at)
resetWorkflowVersionHistory()
toast.success(t('saveSuccess'))
toast.success(t('publishSuccess'))
return true
}
catch (error) {

View File

@ -1,78 +0,0 @@
'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,35 +7,19 @@ import SnippetWorkflowPanel from './workflow-panel'
type SnippetChildrenProps = {
snippetId: string
fields: SnippetInputField[]
canDiscardChanges: boolean
canEdit?: boolean
canSave: boolean
hasDraftChanges: boolean
isEditing: boolean
canEdit: 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,
canEdit,
isPublishing,
onCancel,
onEdit,
onExitEditing,
onExitEditingWithoutSave,
onPublish,
onSaveAndExitEditing,
}: SnippetChildrenProps) => {
return (
<>
@ -43,18 +27,10 @@ const SnippetChildren = ({
<SnippetHeader
snippetId={snippetId}
canDiscardChanges={canDiscardChanges}
canEdit={canEdit}
canSave={canSave}
hasDraftChanges={hasDraftChanges}
isEditing={isEditing}
canEdit={canEdit}
isPublishing={isPublishing}
onCancel={onCancel}
onEdit={onEdit}
onExitEditing={onExitEditing}
onExitEditingWithoutSave={onExitEditingWithoutSave}
onPublish={onPublish}
onSaveAndExitEditing={onSaveAndExitEditing}
/>
<SnippetWorkflowPanel

View File

@ -0,0 +1,28 @@
'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

@ -1,29 +0,0 @@
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,28 +1,8 @@
import type { ReactNode } from 'react'
import type { HeaderProps } from '@/app/components/workflow/header'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fireEvent, render, screen } 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 (
@ -44,242 +24,75 @@ 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()
})
// 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}
/>,
)
it('should configure workflow header slots and hide workflow-only controls', () => {
render(
<SnippetHeader
snippetId="snippet-1"
canSave
canEdit
isPublishing={false}
onPublish={mockPublish}
/>,
)
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()
})
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()
})
// 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}
/>,
)
it('should publish from the primary header action', () => {
render(
<SnippetHeader
snippetId="snippet-1"
canSave
canEdit
isPublishing={false}
onPublish={mockPublish}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /^snippet\.save$/i }))
fireEvent.click(screen.getByRole('button', { name: /snippet\.discardChanges/i }))
fireEvent.click(screen.getByRole('button', { name: /snippet\.publishButton/i }))
expect(mockPublish).toHaveBeenCalledTimes(1)
expect(mockCancel).toHaveBeenCalledTimes(1)
})
expect(mockPublish).toHaveBeenCalledTimes(1)
})
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}
/>,
)
it('should disable publish when the current graph has no nodes', () => {
render(
<SnippetHeader
snippetId="snippet-1"
canSave={false}
canEdit
isPublishing={false}
onPublish={mockPublish}
/>,
)
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()
})
expect(screen.getByRole('button', { name: /snippet\.publishButton/i })).toBeDisabled()
})
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}
/>,
)
it('should show publish loading state while publishing', () => {
render(
<SnippetHeader
snippetId="snippet-1"
canSave
canEdit
isPublishing
onPublish={mockPublish}
/>,
)
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()
})
expectLoadingButton(screen.getByRole('button', { name: /snippet\.publishButton/i }))
})
})

View File

@ -1,81 +0,0 @@
'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,128 +5,44 @@ 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
canEdit: boolean
isPublishing: boolean
onCancel: () => void
onEdit: () => void
onExitEditing: () => void | Promise<void>
onExitEditingWithoutSave: () => void | Promise<void>
onPublish: () => void
onSaveAndExitEditing: () => void | Promise<void>
}
const ViewOnlyBadge = () => {
const { t } = useTranslation('snippet')
return (
<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,
const PublishAction = ({
canSave,
hasDraftChanges,
isEditing,
isPublishing,
onEdit,
onExitEditing,
onExitEditingWithoutSave,
onPublish,
onSaveAndExitEditing,
}: Pick<SnippetHeaderProps, 'canEdit' | 'canSave' | 'hasDraftChanges' | 'isEditing' | 'isPublishing' | 'onEdit' | 'onExitEditing' | 'onExitEditingWithoutSave' | 'onPublish' | 'onSaveAndExitEditing'>) => {
}: Pick<SnippetHeaderProps, 'canSave' | 'isPublishing' | 'onPublish'>) => {
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>
</>
<Button
variant="primary"
loading={isPublishing}
disabled={isPublishing || !canSave}
onClick={onPublish}
>
{t('publishButton')}
</Button>
)
}
const SnippetHeader = ({
snippetId,
canDiscardChanges,
canEdit = true,
canSave,
hasDraftChanges,
isEditing,
canEdit,
isPublishing,
onCancel,
onEdit,
onExitEditing,
onExitEditingWithoutSave,
onPublish,
onSaveAndExitEditing,
}: SnippetHeaderProps) => {
const { t } = useTranslation('snippet')
const viewHistoryProps = useMemo(() => {
@ -139,22 +55,16 @@ const SnippetHeader = ({
return {
normal: {
components: {
title: isEditing
? (hasDraftChanges ? <CancelChanges canDiscardChanges={canDiscardChanges} onCancel={onCancel} /> : <></>)
: <ViewOnlyBadge />,
left: (
<EditActions
canEdit={canEdit}
canSave={canSave}
hasDraftChanges={hasDraftChanges}
isEditing={isEditing}
isPublishing={isPublishing}
onEdit={onEdit}
onExitEditing={onExitEditing}
onExitEditingWithoutSave={onExitEditingWithoutSave}
onPublish={onPublish}
onSaveAndExitEditing={onSaveAndExitEditing}
/>
canEdit
? (
<PublishAction
canSave={canSave}
isPublishing={isPublishing}
onPublish={onPublish}
/>
)
: null
),
},
controls: {
@ -174,7 +84,7 @@ const SnippetHeader = ({
viewHistoryProps,
},
}
}, [canDiscardChanges, canEdit, canSave, hasDraftChanges, isEditing, isPublishing, onCancel, onEdit, onExitEditing, onExitEditingWithoutSave, onPublish, onSaveAndExitEditing, t, viewHistoryProps])
}, [canEdit, canSave, isPublishing, onPublish, 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'
@ -24,8 +24,7 @@ import {
initialNodes,
} from '@/app/components/workflow/utils'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { useRouter } from '@/next/navigation'
import { useSnippetPublishedWorkflow } from '@/service/use-snippet-workflows'
import { useSnippetDraftStore } from '../draft-store'
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'
@ -37,9 +36,7 @@ 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
@ -55,19 +52,10 @@ type SnippetMainProps = {
type SnippetMainContentProps = {
snippetId: string
fields: SnippetInputField[]
canDiscardChanges: boolean
canEdit: boolean
canSave: boolean
hasDraftChanges: boolean
isEditing: boolean
canEdit: 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([
@ -94,23 +82,12 @@ const hasSnippetDraftNodes = (payload?: Omit<SnippetDraftSyncPayload, 'hash'> |
const SnippetMainContent = ({
snippetId,
fields,
canDiscardChanges,
canEdit,
canSave,
hasDraftChanges,
isEditing,
canEdit,
onBeforePublish,
onCancel,
onDiscardRoute,
onEdit,
onExitEditing,
onExitEditingWithoutSave,
onSaved,
onSavedAndExitEditing,
}: SnippetMainContentProps) => {
const { push } = useRouter()
const { t } = useTranslation('snippet')
const [pendingHref, setPendingHref] = useState<string>()
const {
handlePublish,
isPublishing,
@ -135,181 +112,45 @@ 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}
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}
/>
</>
<SnippetChildren
snippetId={snippetId}
fields={fields}
canSave={canSave}
canEdit={canEdit}
isPublishing={isPublishing}
onPublish={handlePublishSnippet}
/>
)
}
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 [draftChangeState, setDraftChangeState] = useState({
initial: hasInitialDraftChanges,
snippetId,
value: hasInitialDraftChanges,
})
if (draftChangeState.snippetId !== snippetId || draftChangeState.initial !== hasInitialDraftChanges) {
const [localDraftSnippetId, setLocalDraftSnippetId] = useState(snippetId)
if (localDraftSnippetId !== snippetId) {
setLocalDraftState(undefined)
setDraftChangeState({
initial: hasInitialDraftChanges,
snippetId,
value: hasInitialDraftChanges,
})
setLocalDraftSnippetId(snippetId)
}
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 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 { graph, snippet } = effectiveDraftPayload
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
const canEditSnippet = canCreateAndModifySnippets(workspacePermissionKeys)
const canSave = canEditSnippet && currentCanvasNodeCount > 0
const {
doSyncWorkflowDraft: syncWorkflowDraft,
syncInputFieldsDraft,
syncWorkflowDraftWhenPageClose,
} = useNodesSyncDraft(snippetId)
const workflowStore = useWorkflowStore()
const publishedWorkflowQuery = useSnippetPublishedWorkflow(snippetId)
const { handleRefreshWorkflowDraft } = useSnippetRefreshDraft(snippetId)
const {
handleBackupDraft,
@ -339,10 +180,6 @@ 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))
@ -364,16 +201,23 @@ const SnippetMain = ({
}, [workflowAvailableNodesMetaData])
const {
reset,
setFields,
setNavigationState,
} = useSnippetDetailStore(useShallow(state => ({
reset: state.reset,
setFields: state.setFields,
setNavigationState: state.setNavigationState,
})))
const {
hydrateDraft,
setInputFields,
} = useSnippetDraftStore(useShallow(state => ({
hydrateDraft: state.hydrateDraft,
setInputFields: state.setInputFields,
})))
const {
fields,
handleFieldsChange,
handleFieldsChange: handleSnippetFieldsChange,
} = useSnippetInputFieldActions({
canEdit: canCreateAndModifySnippet,
canEdit: canEditSnippet,
snippetId,
})
const {
@ -385,67 +229,65 @@ const SnippetMain = ({
const { getWorkflowRunAndTraceUrl } = useGetRunAndTraceUrl(snippetId)
useEffect(() => {
reset()
return () => reset()
}, [reset, snippetId])
useEffect(() => {
setFields(displayPayload.inputFields)
}, [displayPayload.inputFields, setFields, snippetId])
useLayoutEffect(() => {
hydrateDraft({
snippetId,
inputFields: effectiveDraftPayload.inputFields,
})
}, [effectiveDraftPayload.inputFields, hydrateDraft, snippetId])
useEffect(() => {
workflowStore.setState({ canvasReadOnly: !isEditing })
workflowStore.setState({ canvasReadOnly: !canEditSnippet })
return () => {
workflowStore.setState({ canvasReadOnly: false })
}
}, [isEditing, workflowStore])
}, [canEditSnippet, workflowStore])
useEffect(() => {
workflowStore.temporal.getState().pause()
workflowStore.getState().setWorkflowHistory({
nodes: displayNodes,
edges: displayEdges,
nodes: effectiveDraftNodes,
edges: effectiveDraftEdges,
workflowHistoryEvent: undefined,
workflowHistoryEventMeta: undefined,
})
workflowStore.temporal.getState().clear()
workflowStore.temporal.getState().resume()
}, [displayEdges, displayNodes, workflowStore])
}, [effectiveDraftEdges, effectiveDraftNodes, workflowStore])
const doSyncWorkflowDraft = useCallback((
...args: Parameters<typeof syncWorkflowDraft>
) => {
if (!canCreateAndModifySnippet || !isEditing)
if (!canEditSnippet)
return Promise.resolve()
const [
notRefreshWhenSyncError,
callback,
] = args
if (skipNextForcedDraftSyncRef.current && notRefreshWhenSyncError === true && !callback) {
skipNextForcedDraftSyncRef.current = false
return Promise.resolve()
}
if (isEditing)
setHasDraftChanges(true)
return syncWorkflowDraft(...args)
}, [canCreateAndModifySnippet, isEditing, setHasDraftChanges, syncWorkflowDraft])
}, [canEditSnippet, syncWorkflowDraft])
const syncWorkflowDraftWhenPageCloseInEditing = useCallback(() => {
if (!canCreateAndModifySnippet || !isEditing)
const handleSyncWorkflowDraftWhenPageClose = useCallback(() => {
if (!canEditSnippet)
return
syncWorkflowDraftWhenPageClose()
}, [canCreateAndModifySnippet, isEditing, syncWorkflowDraftWhenPageClose])
}, [canEditSnippet, syncWorkflowDraftWhenPageClose])
const handleFieldsChangeInEditing = useCallback((nextFields: SnippetInputField[]) => {
if (!canCreateAndModifySnippet || !isEditing)
return
const handleFieldsChange = useCallback((nextFields: SnippetInputField[]) => {
handleSnippetFieldsChange(nextFields)
}, [handleSnippetFieldsChange])
handleFieldsChange(nextFields)
setHasDraftChanges(true)
}, [canCreateAndModifySnippet, handleFieldsChange, isEditing, setHasDraftChanges])
useEffect(() => {
setNavigationState({
snippetId,
snippet,
readonly: !canEditSnippet,
onFieldsChange: handleFieldsChange,
})
}, [canEditSnippet, handleFieldsChange, setNavigationState, snippet, snippetId])
const updateLocalDraftFromSyncPayload = useCallback((
syncedDraftPayload?: Omit<SnippetDraftSyncPayload, 'hash'> | void,
@ -476,70 +318,13 @@ const SnippetMain = ({
edges: initialEdges(draftGraph.edges, draftGraph.nodes),
viewport: draftGraph.viewport,
})
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])
setInputFields(inputFields)
}, [draftPayload, fields, setInputFields])
const hooksStore = useMemo(() => {
return {
doSyncWorkflowDraft,
syncWorkflowDraftWhenPageClose: syncWorkflowDraftWhenPageCloseInEditing,
syncWorkflowDraftWhenPageClose: handleSyncWorkflowDraftWhenPageClose,
handleRefreshWorkflowDraft,
handleBackupDraft,
handleLoadBackupDraft,
@ -565,11 +350,19 @@ const SnippetMain = ({
invalidateSysVarValues,
resetConversationVar,
invalidateConversationVarValues,
accessControl: {
canEdit: canEditSnippet,
canComment: true,
canRun: true,
canImportExportDSL: canEditSnippet,
canReleaseAndVersion: canEditSnippet,
},
configsMap,
}
}, [
appendNodeInspectVars,
availableNodesMetaData,
canEditSnippet,
configsMap,
deleteAllInspectorVars,
deleteInspectVar,
@ -579,6 +372,7 @@ const SnippetMain = ({
fetchInspectVarValue,
fetchInspectVars,
handleBackupDraft,
handleSyncWorkflowDraftWhenPageClose,
handleRefreshWorkflowDraft,
handleLoadBackupDraft,
handleRestoreFromPublishedWorkflow,
@ -595,47 +389,25 @@ const SnippetMain = ({
renameInspectVarName,
resetConversationVar,
resetToLastRunVar,
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}-${isEditing ? 'draft' : 'published'}`}
nodes={displayNodes}
edges={displayEdges}
viewport={displayViewport ?? graph.viewport}
key={`${snippetId}-draft`}
nodes={effectiveDraftNodes}
edges={effectiveDraftEdges}
viewport={effectiveDraftViewport ?? 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)}
onCancel={handleCancelChanges}
onDiscardRoute={handleDiscardAndRoute}
onEdit={handleEdit}
onExitEditing={handleExitEditing}
onExitEditingWithoutSave={handleExitEditingWithoutSave}
onSaved={(syncedDraftPayload) => {
updateLocalDraftFromSyncPayload(syncedDraftPayload)
setHasDraftChanges(false)
}}
onSavedAndExitEditing={() => {
setHasDraftChanges(false)
setIsEditingState(false)
}}
canEdit={canEditSnippet}
onBeforePublish={() => doSyncWorkflowDraft(true)}
onSaved={updateLocalDraftFromSyncPayload}
/>
</WorkflowWithInnerContext>
</div>

View File

@ -0,0 +1,30 @@
'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

@ -5,14 +5,14 @@ import type { SnippetDetail, SnippetInputField } from '@/models/snippet'
import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import { isEqual } from 'es-toolkit/predicate'
import { memo, useCallback, useMemo, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import SnippetInfoDropdown from '@/app/components/app-sidebar/snippet-info/dropdown'
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,6 +21,10 @@ 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'],
@ -32,12 +36,13 @@ const toSnippetInputField = (field: InputVar): SnippetInputField => ({
type: field.type as unknown as SnippetInputField['type'],
})
const SnippetSidebar = ({
export const SnippetSidebarContent = ({
snippet,
fields,
readonly,
onFieldsChange,
}: SnippetSidebarProps) => {
className,
}: SnippetSidebarContentProps) => {
const { t } = useTranslation()
const [isShowAddVarModal, setIsShowAddVarModal] = useState(false)
const workflowInputVars = useMemo(() => fields.map(toWorkflowInputVar), [fields])
@ -88,32 +93,23 @@ const SnippetSidebar = ({
}, [fields, onFieldsChange])
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">
<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={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 />
<div className="min-w-0 grow">
<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 className="truncate system-xl-semibold text-text-primary" title={snippet.name}>{snippet.name}</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="mx-6 mt-7 h-px shrink-0 bg-divider-subtle" />
<div className="flex min-h-0 grow flex-col px-6 pt-7">
<div className="flex min-h-0 grow flex-col px-3 pt-6">
<Field
title={t('inputVariables', { ns: 'snippet' })}
operations={!readonly
@ -151,8 +147,6 @@ const SnippetSidebar = ({
varKeys={fields.map(v => v.variable)}
/>
)}
</aside>
</div>
)
}
export default memo(SnippetSidebar)

View File

@ -0,0 +1,36 @@
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

@ -0,0 +1,24 @@
'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

@ -0,0 +1,275 @@
import type { ReactElement } from 'react'
import type { Edge, Node } from '@/app/components/workflow/types'
import type { SnippetCanvasData, SnippetInputField } from '@/models/snippet'
import { act, renderHook } from '@testing-library/react'
import { BlockEnum } from '@/app/components/workflow/types'
import { PipelineInputVarType } from '@/models/pipeline'
import { useCreateSnippetFromSelection } from '../use-create-snippet-from-selection'
const SNIPPET_INPUT_FIELD_NODE_ID = 'start'
const mockHandleOpenCreateSnippetDialog = vi.fn()
const mockHandleCloseCreateSnippetDialog = vi.fn()
const mockHandleCreateSnippet = vi.fn()
vi.mock('../use-create-snippet', () => ({
useCreateSnippet: () => ({
createSnippetMutation: {
isPending: false,
},
handleCloseCreateSnippetDialog: mockHandleCloseCreateSnippetDialog,
handleCreateSnippet: mockHandleCreateSnippet,
handleOpenCreateSnippetDialog: mockHandleOpenCreateSnippetDialog,
isCreateSnippetDialogOpen: true,
isCreatingSnippet: false,
}),
}))
type DialogProps = {
selectedGraph?: SnippetCanvasData
inputFields?: SnippetInputField[]
}
const createNode = (
id: string,
data: Record<string, unknown>,
): Node => ({
id,
type: 'custom',
position: { x: 0, y: 0 },
width: 200,
height: 100,
data,
} as unknown as Node)
describe('useCreateSnippetFromSelection', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should convert environment, conversation, and system variables into snippet input fields', () => {
const selectedNodes = [
createNode('llm', {
type: BlockEnum.LLM,
prompt: [
'{{#env.API_KEY#}}',
'{{#conversation.user_name#}}',
'{{#sys.user_id#}}',
'{{#rag.query#}}',
'{{#source.result#}}',
].join(' '),
model_selector: ['env', 'MODEL_NAME'],
}),
]
const edges: Edge[] = []
const onClose = vi.fn()
const { result } = renderHook(() => useCreateSnippetFromSelection({
edges,
selectedNodes,
onClose,
}))
act(() => {
result.current.handleOpenCreateSnippet()
})
const dialogProps = (result.current.createSnippetDialog as ReactElement<DialogProps>).props
expect(dialogProps.inputFields).toEqual([
{
label: 'API_KEY',
variable: 'API_KEY',
type: PipelineInputVarType.textInput,
required: true,
},
{
label: 'user_name',
variable: 'user_name',
type: PipelineInputVarType.textInput,
required: true,
},
{
label: 'user_id',
variable: 'user_id',
type: PipelineInputVarType.textInput,
required: true,
},
{
label: 'result',
variable: 'result',
type: PipelineInputVarType.textInput,
required: true,
},
{
label: 'MODEL_NAME',
variable: 'MODEL_NAME',
type: PipelineInputVarType.textInput,
required: true,
},
])
const nodeData = dialogProps.selectedGraph?.nodes[0]?.data as Record<string, unknown> | undefined
expect(nodeData?.prompt).toBe([
`{{#${SNIPPET_INPUT_FIELD_NODE_ID}.API_KEY#}}`,
`{{#${SNIPPET_INPUT_FIELD_NODE_ID}.user_name#}}`,
`{{#${SNIPPET_INPUT_FIELD_NODE_ID}.user_id#}}`,
'{{#rag.query#}}',
`{{#${SNIPPET_INPUT_FIELD_NODE_ID}.result#}}`,
].join(' '))
expect(nodeData?.model_selector).toEqual([
SNIPPET_INPUT_FIELD_NODE_ID,
'MODEL_NAME',
])
expect(onClose).toHaveBeenCalled()
})
it('should convert system variables used by if-else and variable aggregator nodes', () => {
const selectedNodes = [
createNode('llm', {
type: BlockEnum.LLM,
title: 'LLM',
}),
createNode('if-else', {
type: BlockEnum.IfElse,
cases: [{
case_id: 'case-1',
conditions: [{
id: 'condition-1',
variable_selector: ['sys', 'query'],
comparison_operator: 'contains',
value: 'hello',
}],
}],
}),
createNode('variable-aggregator', {
type: BlockEnum.VariableAggregator,
variables: [
['sys', 'files'],
['llm', 'text'],
],
advanced_settings: {
group_enabled: true,
groups: [{
groupId: 'group-1',
group_name: 'Group1',
variables: [
['sys', 'workflow_id'],
],
}],
},
}),
]
const onClose = vi.fn()
const { result } = renderHook(() => useCreateSnippetFromSelection({
edges: [],
selectedNodes,
onClose,
}))
act(() => {
result.current.handleOpenCreateSnippet()
})
const dialogProps = (result.current.createSnippetDialog as ReactElement<DialogProps>).props
expect(dialogProps.inputFields).toEqual([
{
label: 'query',
variable: 'query',
type: PipelineInputVarType.textInput,
required: true,
},
{
label: 'files',
variable: 'files',
type: PipelineInputVarType.multiFiles,
required: true,
},
{
label: 'workflow_id',
variable: 'workflow_id',
type: PipelineInputVarType.textInput,
required: true,
},
])
const ifElseNode = dialogProps.selectedGraph?.nodes.find(node => node.id === 'if-else')
const aggregatorNode = dialogProps.selectedGraph?.nodes.find(node => node.id === 'variable-aggregator')
const ifElseData = ifElseNode?.data as Record<string, unknown>
const aggregatorData = aggregatorNode?.data as {
variables?: string[][]
advanced_settings?: { groups?: Array<{ variables?: string[][] }> }
}
expect(ifElseData.cases).toEqual([{
case_id: 'case-1',
conditions: [{
id: 'condition-1',
variable_selector: [SNIPPET_INPUT_FIELD_NODE_ID, 'query'],
comparison_operator: 'contains',
value: 'hello',
}],
}])
expect(aggregatorData.variables).toEqual([
[SNIPPET_INPUT_FIELD_NODE_ID, 'files'],
['llm', 'text'],
])
expect(aggregatorData.advanced_settings?.groups?.[0]?.variables).toEqual([
[SNIPPET_INPUT_FIELD_NODE_ID, 'workflow_id'],
])
})
it('should keep #context# prompt placeholders when creating a snippet from workflow selection', () => {
const selectedNodes = [
createNode('llm', {
type: BlockEnum.LLM,
context: {
enabled: true,
variable_selector: ['code', 'result'],
},
prompt: '{{#context#}} {{#code.summary#}}',
}),
]
const onClose = vi.fn()
const { result } = renderHook(() => useCreateSnippetFromSelection({
edges: [],
selectedNodes,
onClose,
}))
act(() => {
result.current.handleOpenCreateSnippet()
})
const dialogProps = (result.current.createSnippetDialog as ReactElement<DialogProps>).props
const nodeData = dialogProps.selectedGraph?.nodes[0]?.data as {
context?: {
enabled: boolean
variable_selector: string[]
}
prompt?: string
}
expect(dialogProps.inputFields).toEqual([
{
label: 'result',
variable: 'result',
type: PipelineInputVarType.textInput,
required: true,
},
{
label: 'summary',
variable: 'summary',
type: PipelineInputVarType.textInput,
required: true,
},
])
expect(nodeData.context).toEqual({
enabled: true,
variable_selector: [SNIPPET_INPUT_FIELD_NODE_ID, 'result'],
})
expect(nodeData.prompt).toBe(`{{#context#}} {{#${SNIPPET_INPUT_FIELD_NODE_ID}.summary#}}`)
})
})

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 { useSnippetDetailStore } from '../../store'
import { useSnippetDraftStore } from '../../draft-store'
import { useNodesSyncDraft } from '../use-nodes-sync-draft'
const mockGetNodes = vi.fn()
@ -112,9 +112,7 @@ describe('snippet/use-nodes-sync-draft', () => {
mockSetSyncWorkflowDraftHash.mockImplementation((hash: string) => {
workflowStoreState.syncWorkflowDraftHash = hash
})
useSnippetDetailStore.setState({
fields: [createInputField('topic')],
})
useSnippetDraftStore.getState().setInputFields([createInputField('topic')])
})
it('should include current input_fields when syncing the draft graph', async () => {
@ -139,6 +137,18 @@ 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('../../store', () => ({
useSnippetDetailStore: {
vi.mock('../../draft-store', () => ({
useSnippetDraftStore: {
setState: (...args: unknown[]) => mockSnippetSetState(...args),
},
}))
@ -75,7 +75,7 @@ describe('useSnippetRefreshDraft', () => {
})
expect(mockFetchSnippetDraftWorkflow).toHaveBeenCalledWith('snippet-1')
expect(mockSnippetSetState).toHaveBeenCalledWith({
fields: [],
inputFields: [],
})
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 { useSnippetDetailStore } from '../../store'
import { useSnippetDraftStore } from '../../draft-store'
import { useSnippetStartRun } from '../use-snippet-start-run'
const mockWorkflowStoreGetState = vi.fn()
@ -39,7 +39,7 @@ const inputFields: SnippetInputField[] = [
describe('useSnippetStartRun', () => {
beforeEach(() => {
vi.clearAllMocks()
useSnippetDetailStore.getState().reset()
useSnippetDraftStore.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', () => {
useSnippetDetailStore.setState({ fields: inputFields })
useSnippetDraftStore.getState().setInputFields(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', () => {
useSnippetDetailStore.setState({ fields: inputFields })
useSnippetDraftStore.getState().setInputFields(inputFields)
const { result } = renderHook(() => useSnippetStartRun({
handleRun: mockHandleRun,
@ -99,7 +99,7 @@ describe('useSnippetStartRun', () => {
})
it('should close the panel when debug panel is already open', () => {
useSnippetDetailStore.setState({ fields: inputFields })
useSnippetDraftStore.getState().setInputFields(inputFields)
mockWorkflowStoreGetState.mockReturnValue({
workflowRunningData: undefined,
@ -122,7 +122,7 @@ describe('useSnippetStartRun', () => {
})
it('should do nothing when workflow is already running', () => {
useSnippetDetailStore.setState({ fields: inputFields })
useSnippetDraftStore.getState().setInputFields(inputFields)
mockWorkflowStoreGetState.mockReturnValue({
workflowRunningData: {

View File

@ -0,0 +1,329 @@
import type { Edge, Node, ValueSelector } from '@/app/components/workflow/types'
import type { SnippetCanvasData, SnippetInputField } from '@/models/snippet'
import { useCallback, useState } from 'react'
import { getNodesBounds } from 'reactflow'
import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog'
import { PipelineInputVarType } from '@/models/pipeline'
import { useCreateSnippet } from './use-create-snippet'
const DEFAULT_SNIPPET_VIEWPORT = { x: 0, y: 0, zoom: 1 }
const SNIPPET_INPUT_FIELD_NODE_ID = 'start'
const SNIPPET_VIEWPORT_WIDTH = 1200
const SNIPPET_VIEWPORT_HEIGHT = 800
const SNIPPET_VIEWPORT_PADDING = 160
const VARIABLE_REFERENCE_REGEX = /\{\{#([^#{}]+)#\}\}/g
const RESERVED_VARIABLE_PREFIXES = new Set(['rag'])
const isRecord = (value: unknown): value is Record<string, unknown> => {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
const isValueSelector = (value: unknown): value is ValueSelector => {
return Array.isArray(value)
&& value.length > 0
&& value.every(item => typeof item === 'string')
}
const isSelectorKey = (key?: string) => {
return key === 'selector' || !!key?.endsWith('_selector')
}
const isValueSelectorListKey = (key?: string) => {
return key === 'variables'
}
const isValueSelectorList = (value: unknown[]) => {
return value.length > 0 && value.every(isValueSelector)
}
const isContextPlaceholderSelector = (selector: ValueSelector) => {
return (selector.length === 1 && selector[0] === 'context')
|| selector.at(-1) === '#context#'
}
const getCenteredViewport = (nodes: Node[]) => {
if (!nodes.length)
return DEFAULT_SNIPPET_VIEWPORT
const bounds = getNodesBounds(nodes)
if (!bounds.width || !bounds.height)
return DEFAULT_SNIPPET_VIEWPORT
const zoom = Math.min(
(SNIPPET_VIEWPORT_WIDTH - SNIPPET_VIEWPORT_PADDING * 2) / bounds.width,
(SNIPPET_VIEWPORT_HEIGHT - SNIPPET_VIEWPORT_PADDING * 2) / bounds.height,
1,
)
const centerX = bounds.x + bounds.width / 2
const centerY = bounds.y + bounds.height / 2
return {
x: SNIPPET_VIEWPORT_WIDTH / 2 - centerX * zoom,
y: SNIPPET_VIEWPORT_HEIGHT / 2 - centerY * zoom,
zoom,
}
}
const collectSelectorsFromText = (value: string, selectors: ValueSelector[]) => {
for (const match of value.matchAll(VARIABLE_REFERENCE_REGEX)) {
const variablePath = match[1]
if (!variablePath)
continue
const selector = variablePath.split('.').filter(Boolean)
if (selector.length > 0 && !isContextPlaceholderSelector(selector))
selectors.push(selector)
}
}
const collectVariableSelectors = (
value: unknown,
selectors: ValueSelector[],
key?: string,
) => {
if (typeof value === 'string') {
collectSelectorsFromText(value, selectors)
return
}
if (Array.isArray(value)) {
if (isSelectorKey(key) && isValueSelector(value))
selectors.push(value)
if (isValueSelectorListKey(key) && isValueSelectorList(value)) {
value.forEach(selector => selectors.push(selector))
return
}
value.forEach(item => collectVariableSelectors(item, selectors))
return
}
if (!isRecord(value))
return
Object.entries(value).forEach(([currentKey, currentValue]) => {
collectVariableSelectors(currentValue, selectors, currentKey)
})
}
const isExternalVariableSelector = (
selector: ValueSelector,
selectedNodeIds: Set<string>,
) => {
const nodeId = selector[0]
if (!nodeId)
return false
if (nodeId.startsWith('$'))
return false
if (isContextPlaceholderSelector(selector))
return false
if (selectedNodeIds.has(nodeId))
return false
return !RESERVED_VARIABLE_PREFIXES.has(nodeId)
}
const sanitizeInputFieldVariable = (variable: string) => {
const sanitized = variable.replace(/\W/g, '_')
if (!sanitized)
return 'input'
if (/^\d/.test(sanitized))
return `input_${sanitized}`
return sanitized
}
const getUniqueInputFieldVariable = (
selector: ValueSelector,
usedVariables: Set<string>,
) => {
const baseVariable = sanitizeInputFieldVariable(selector.at(-1) ?? 'input')
let variable = baseVariable
let index = 2
while (usedVariables.has(variable)) {
variable = `${baseVariable}_${index}`
index += 1
}
usedVariables.add(variable)
return variable
}
const getInputFieldType = (selector: ValueSelector) => {
const variable = selector.at(-1)
if (variable === 'files')
return PipelineInputVarType.multiFiles
return PipelineInputVarType.textInput
}
const getExternalVariableInputFields = (
nodes: Node[],
selectedNodeIds: Set<string>,
) => {
const selectors: ValueSelector[] = []
nodes.forEach(node => collectVariableSelectors(node.data, selectors))
const usedVariables = new Set<string>()
const fieldBySelector = new Map<string, SnippetInputField>()
selectors.forEach((selector) => {
if (!isExternalVariableSelector(selector, selectedNodeIds))
return
const selectorKey = selector.join('.')
if (fieldBySelector.has(selectorKey))
return
const variable = getUniqueInputFieldVariable(selector, usedVariables)
fieldBySelector.set(selectorKey, {
label: variable,
variable,
type: getInputFieldType(selector),
required: true,
})
})
return {
inputFields: [...fieldBySelector.values()],
selectorMap: new Map(
[...fieldBySelector.entries()].map(([selectorKey, field]) => [
selectorKey,
[SNIPPET_INPUT_FIELD_NODE_ID, field.variable] satisfies ValueSelector,
]),
),
}
}
const rewriteVariableReferences = (
value: unknown,
selectorMap: Map<string, ValueSelector>,
key?: string,
): unknown => {
if (typeof value === 'string') {
return value.replace(VARIABLE_REFERENCE_REGEX, (match, variablePath: string) => {
const nextSelector = selectorMap.get(variablePath)
if (!nextSelector)
return match
return `{{#${nextSelector.join('.')}#}}`
})
}
if (Array.isArray(value)) {
if (isSelectorKey(key) && isValueSelector(value)) {
const nextSelector = selectorMap.get(value.join('.'))
if (nextSelector)
return nextSelector
}
if (isValueSelectorListKey(key) && isValueSelectorList(value)) {
return value.map((selector) => {
const nextSelector = selectorMap.get(selector.join('.'))
return nextSelector || selector
})
}
return value.map(item => rewriteVariableReferences(item, selectorMap))
}
if (!isRecord(value))
return value
return Object.fromEntries(
Object.entries(value).map(([currentKey, currentValue]) => [
currentKey,
rewriteVariableReferences(currentValue, selectorMap, currentKey),
]),
)
}
const getSelectedSnippetGraph = (selectedNodes: Node[], edges: Edge[]) => {
const selectedNodeIds = new Set(selectedNodes.map(node => node.id))
const {
inputFields,
selectorMap,
} = getExternalVariableInputFields(selectedNodes, selectedNodeIds)
const nodes = selectedNodes.map(node => ({
...node,
data: rewriteVariableReferences(node.data, selectorMap) as Node['data'],
selected: false,
}))
return {
graph: {
nodes,
edges: edges
.filter(edge => selectedNodeIds.has(edge.source) && selectedNodeIds.has(edge.target))
.map(edge => ({
...edge,
selected: false,
})),
viewport: getCenteredViewport(nodes),
} satisfies SnippetCanvasData,
inputFields,
}
}
type UseCreateSnippetFromSelectionParams = {
edges: Edge[]
selectedNodes: Node[]
onClose: () => void
}
export const useCreateSnippetFromSelection = ({
edges,
selectedNodes,
onClose,
}: UseCreateSnippetFromSelectionParams) => {
const [selectedSnippetGraph, setSelectedSnippetGraph] = useState<SnippetCanvasData>()
const [selectedSnippetInputFields, setSelectedSnippetInputFields] = useState<SnippetInputField[]>([])
const {
createSnippetMutation,
handleCloseCreateSnippetDialog,
handleCreateSnippet,
handleOpenCreateSnippetDialog,
isCreateSnippetDialogOpen,
isCreatingSnippet,
} = useCreateSnippet()
const handleOpenCreateSnippet = useCallback(() => {
const {
graph,
inputFields,
} = getSelectedSnippetGraph(selectedNodes, edges)
setSelectedSnippetGraph(graph)
setSelectedSnippetInputFields(inputFields)
handleOpenCreateSnippetDialog()
onClose()
}, [edges, handleOpenCreateSnippetDialog, onClose, selectedNodes])
const handleCloseCreateSnippet = useCallback(() => {
setSelectedSnippetGraph(undefined)
setSelectedSnippetInputFields([])
handleCloseCreateSnippetDialog()
}, [handleCloseCreateSnippetDialog])
const createSnippetDialog = (
<CreateSnippetDialog
isOpen={isCreateSnippetDialogOpen}
selectedGraph={selectedSnippetGraph}
inputFields={selectedSnippetInputFields}
isSubmitting={isCreatingSnippet || createSnippetMutation.isPending}
onClose={handleCloseCreateSnippet}
onConfirm={handleCreateSnippet}
/>
)
return {
createSnippetDialog,
handleOpenCreateSnippet,
isCreateSnippetDialogOpen,
}
}

View File

@ -8,8 +8,9 @@ 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 { useSnippetDetailStore } from '../store'
import { useSnippetDraftStore } from '../draft-store'
import { useSnippetRefreshDraft } from './use-snippet-refresh-draft'
const isSyncConflictError = (error: unknown): error is { bodyUsed: boolean, json: () => Promise<{ code?: string }> } => {
@ -51,7 +52,7 @@ export const useNodesSyncDraft = (snippetId: string) => {
const getInputFieldsSyncPayload = useCallback((inputFields?: SnippetInputField[]) => {
return {
input_fields: inputFields ?? useSnippetDetailStore.getState().fields,
input_fields: inputFields ?? useSnippetDraftStore.getState().inputFields,
}
}, [])

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 { useSnippetDetailStore } from '../store'
import { useSnippetDraftStore } from '../draft-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)
useSnippetDetailStore.setState({
fields: inputFields,
useSnippetDraftStore.setState({
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 { useSnippetDetailStore } from '../store'
import { useSnippetDraftStore } from '../draft-store'
type UseSnippetStartRunOptions = {
handleRun: (params: SnippetDraftRunPayload) => void
@ -38,7 +38,7 @@ export const useSnippetStartRun = ({
setShowDebugAndPreviewPanel(true)
const currentInputFields = useSnippetDetailStore.getState().fields
const currentInputFields = useSnippetDraftStore.getState().inputFields
if (currentInputFields.length > 0) {
setShowInputsPanel(true)

View File

@ -1,31 +1,44 @@
import type { SnippetInputField } from '@/models/snippet'
import { PipelineInputVarType } from '@/models/pipeline'
import type { SnippetDetail } from '@/models/snippet'
import { useSnippetDetailStore } from '..'
const createField = (variable: string): SnippetInputField => ({
label: variable,
variable,
type: PipelineInputVarType.textInput,
required: true,
})
const snippet: SnippetDetail = {
id: 'snippet-1',
name: 'Snippet',
description: 'Description',
updatedAt: '2026-03-29 10:00',
usage: '0',
tags: [],
}
describe('useSnippetDetailStore', () => {
beforeEach(() => {
useSnippetDetailStore.getState().reset()
})
it('should store and reset snippet input fields', () => {
const fields = [
createField('topic'),
createField('audience'),
]
it('should store and reset snippet navigation state', () => {
const onFieldsChange = vi.fn()
useSnippetDetailStore.getState().setFields(fields)
useSnippetDetailStore.getState().setNavigationState({
snippetId: 'snippet-1',
snippet,
readonly: false,
onFieldsChange,
})
expect(useSnippetDetailStore.getState().fields).toEqual(fields)
expect(useSnippetDetailStore.getState()).toMatchObject({
snippetId: 'snippet-1',
snippet,
readonly: false,
onFieldsChange,
})
useSnippetDetailStore.getState().reset()
expect(useSnippetDetailStore.getState().fields).toEqual([])
expect(useSnippetDetailStore.getState()).toMatchObject({
readonly: true,
snippet: undefined,
snippetId: undefined,
onFieldsChange: undefined,
})
})
})

View File

@ -1,20 +1,29 @@
'use client'
import type { SnippetInputField } from '@/models/snippet'
import type { SnippetDetail, SnippetInputField } from '@/models/snippet'
import { create } from 'zustand'
type SnippetDetailUIState = {
fields: SnippetInputField[]
setFields: (fields: SnippetInputField[]) => void
reset: () => void
type SnippetNavigationState = {
snippet?: SnippetDetail
snippetId?: string
readonly: boolean
onFieldsChange?: (fields: SnippetInputField[]) => void
}
type SnippetDetailUIState = {
setNavigationState: (state: SnippetNavigationState) => void
reset: () => void
} & SnippetNavigationState
const initialState = {
fields: [] as SnippetInputField[],
readonly: true,
snippet: undefined,
snippetId: undefined,
onFieldsChange: undefined,
}
export const useSnippetDetailStore = create<SnippetDetailUIState>(set => ({
...initialState,
setFields: fields => set({ fields }),
setNavigationState: state => set(state),
reset: () => set(initialState),
}))

View File

@ -1,5 +1,6 @@
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'
@ -147,6 +148,24 @@ 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,8 +3,10 @@ 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'
@ -15,6 +17,48 @@ 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')
@ -98,6 +142,9 @@ 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', () => {
@ -156,7 +203,41 @@ describe('SelectionContextmenu', () => {
expect(store.getState().contextMenuTarget).toBeUndefined()
})
it('should hide create snippet action for selected nodes', async () => {
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 = []
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 }),
@ -171,7 +252,76 @@ describe('SelectionContextmenu', () => {
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 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()
})
it('should stay hidden when only one node is selected', async () => {

View File

@ -106,7 +106,6 @@ 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()

View File

@ -134,7 +134,7 @@ function NodeSelector({
const defaultAllowUserInputSelection = !hasUserInputNode && !hasTriggerNode
const canSelectUserInput = allowUserInputSelection ?? defaultAllowUserInputSelection
const disableStartTab = flowType === FlowType.snippet
const disableSnippetsTab = true
const disableSnippetsTab = flowType === FlowType.snippet
const {
activeTab,
resetActiveTab,

View File

@ -1,6 +1,6 @@
import type { Node, NodeOutPutVar, Var } from '../../types'
import { renderHook } from '@testing-library/react'
import { useSnippetDetailStore } from '@/app/components/snippets/store'
import { useSnippetDraftStore } from '@/app/components/snippets/draft-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({}, '', '/')
useSnippetDetailStore.getState().reset()
useSnippetDraftStore.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')
useSnippetDetailStore.getState().setFields([{
useSnippetDraftStore.getState().setInputFields([{
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 { useSnippetDetailStore } from '@/app/components/snippets/store'
import { useSnippetDraftStore } from '@/app/components/snippets/draft-store'
import {
useIsChatMode,
useWorkflow,
@ -51,7 +51,7 @@ const useNodesAvailableVarList = (nodes: Node[], {
filterVar: () => true,
}) => {
const { t } = useTranslation()
const snippetInputFields = useSnippetDetailStore(s => s.fields)
const snippetInputFields = useSnippetDraftStore(s => s.inputFields)
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 = useSnippetDetailStore(s => s.fields)
const snippetInputFields = useSnippetDraftStore(s => s.inputFields)
const { getTreeLeafNodes, getBeforeNodesInSameBranchIncludeParent } = useWorkflow()
const { getNodeAvailableVars } = useWorkflowVariables()
const isChatMode = useIsChatMode()

View File

@ -0,0 +1,34 @@
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/store', () => ({
useSnippetDetailStore: (selector: (state: { fields: unknown[] }) => unknown) => selector({ fields: [] }),
vi.mock('@/app/components/snippets/draft-store', () => ({
useSnippetDraftStore: (selector: (state: { inputFields: unknown[] }) => unknown) => selector({ inputFields: [] }),
}))
vi.mock('@/app/components/workflow/hooks', () => ({

View File

@ -98,17 +98,18 @@ export const appendSnippetInputFieldVars = ({
title,
}: {
availableNodes: Node[]
fields: SnippetInputField[]
fields?: SnippetInputField[]
title: string
}) => {
const inputFields = fields ?? []
const shouldAppendSnippetInputFields = isSnippetCanvas()
&& fields.length > 0
&& inputFields.length > 0
&& !availableNodes.some(node => node.data.type === BlockEnum.Start)
const snippetInputFieldNode = shouldAppendSnippetInputFields
? buildSnippetInputFieldNode(fields, title)
? buildSnippetInputFieldNode(inputFields, title)
: undefined
const snippetInputFieldVars = shouldAppendSnippetInputFields
? buildSnippetInputFieldVars(fields, title)
? buildSnippetInputFieldVars(inputFields, 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 { useSnippetDetailStore } from '@/app/components/snippets/store'
import { useSnippetDraftStore } from '@/app/components/snippets/draft-store'
import {
useIsChatMode,
useWorkflow,
@ -34,7 +34,7 @@ const useAvailableVarList = (nodeId: string, {
filterVar: () => true,
}) => {
const { t } = useTranslation()
const snippetInputFields = useSnippetDetailStore(s => s.fields)
const snippetInputFields = useSnippetDraftStore(s => s.inputFields)
const { getTreeLeafNodes, getNodeById, getBeforeNodesInSameBranchIncludeParent } = useWorkflow()
const { getNodeAvailableVars } = useWorkflowVariables()
const isChatMode = useIsChatMode()

View File

@ -9,6 +9,7 @@ import {
useCallback,
} from 'react'
import { useTranslation } from 'react-i18next'
import { FlowType } from '@/types/common'
import { TEST_RUN_MENU_HOTKEY } from './header/shortcuts'
import {
useDSL,
@ -18,6 +19,7 @@ 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'
@ -48,6 +50,7 @@ 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
@ -57,6 +60,7 @@ export function PanelContextmenu({
)
const canEditWorkflow = accessControl.canEdit && !workflowOperationReadOnly
const canCommentWorkflow = accessControl.canComment && !workflowOperationReadOnly
const shouldHideImportApp = flowType === FlowType.snippet || isSnippetCanvas()
const renderAddBlockTrigger = useCallback(() => {
return (
@ -178,12 +182,14 @@ export function PanelContextmenu({
>
{t('export', { ns: 'app' })}
</ContextMenuItem>
<ContextMenuItem
className="justify-between gap-4 px-3 text-text-secondary"
onClick={() => setShowImportDSLModal(true)}
>
{t('importApp', { ns: 'app' })}
</ContextMenuItem>
{!shouldHideImportApp && (
<ContextMenuItem
className="justify-between gap-4 px-3 text-text-secondary"
onClick={() => setShowImportDSLModal(true)}
>
{t('importApp', { ns: 'app' })}
</ContextMenuItem>
)}
</ContextMenuGroup>
</>
)}

View File

@ -12,11 +12,15 @@ 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',
@ -71,6 +75,14 @@ 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>()
@ -223,6 +235,7 @@ 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')
@ -234,8 +247,20 @@ 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()
@ -345,11 +370,24 @@ export function SelectionContextmenu({
}, [collaborativeWorkflow, workflowStore, selectedNodes, getNodesReadOnly, handleSyncWorkflowDraft, saveStateToHistory, onClose])
if (!isSelectionContextMenu || selectedNodes.length <= 1)
return null
return isCreateSnippetDialogOpen ? createSnippetDialog : 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"
@ -398,6 +436,7 @@ export function SelectionContextmenu({
</ContextMenuGroup>
))}
</ContextMenuContent>
{createSnippetDialog}
</>
)
}

View File

@ -29,7 +29,7 @@ const { mockUseQueryData, createTag } = vi.hoisted(() => ({
}))
const mockWorkspacePermissionKeys = vi.hoisted(() => ({
value: ['app.tag.manage', 'dataset.tag.manage', 'snippets.management'] as string[],
value: ['app.tag.manage', 'dataset.tag.manage', 'snippets.create_and_modify'] as string[],
}))
vi.mock('@tanstack/react-query', () => ({
@ -99,7 +99,7 @@ describe('TagManagementModal', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseQueryData.current = mockTags
mockWorkspacePermissionKeys.value = ['app.tag.manage', 'dataset.tag.manage', 'snippets.management']
mockWorkspacePermissionKeys.value = ['app.tag.manage', 'dataset.tag.manage', 'snippets.create_and_modify']
vi.mocked(createTag).mockResolvedValue({ id: 'new-tag', name: 'NewTag', type: 'app', binding_count: 0 })
})

View File

@ -244,8 +244,8 @@ describe('TagSearchContent', () => {
expect(screen.getByRole('option', { name: /KnowledgeDB/i })).toBeInTheDocument()
})
it('renders snippet management action with snippets management permission', () => {
mockWorkspacePermissionKeys.value = ['snippets.management']
it('renders snippet management action with snippets create-and-modify permission', () => {
mockWorkspacePermissionKeys.value = ['snippets.create_and_modify']
render(
<PanelHarness

View File

@ -30,7 +30,7 @@ const { mockUseQueryData, createTag, bindTag, unBindTag } = vi.hoisted(() => {
})
const mockWorkspacePermissionKeys = vi.hoisted(() => ({
value: ['app.tag.manage', 'dataset.tag.manage', 'snippets.management'] as string[],
value: ['app.tag.manage', 'dataset.tag.manage', 'snippets.create_and_modify'] as string[],
}))
vi.mock('@/context/app-context', () => ({
@ -111,7 +111,7 @@ const defaultProps = {
describe('TagSelector', () => {
beforeEach(() => {
vi.clearAllMocks()
mockWorkspacePermissionKeys.value = ['app.tag.manage', 'dataset.tag.manage', 'snippets.management']
mockWorkspacePermissionKeys.value = ['app.tag.manage', 'dataset.tag.manage', 'snippets.create_and_modify']
mockUseQueryData.current = appTags
vi.mocked(createTag).mockResolvedValue({ id: 'new-tag', name: 'NewTag', type: 'app', binding_count: 0 })
vi.mocked(bindTag).mockResolvedValue(undefined)
@ -312,9 +312,9 @@ describe('TagSelector', () => {
expect(createTag).not.toHaveBeenCalled()
})
it('opens snippet tag selector with snippets management permission', async () => {
it('opens snippet tag selector with snippets create-and-modify permission', async () => {
const user = userEvent.setup()
mockWorkspacePermissionKeys.value = ['snippets.management']
mockWorkspacePermissionKeys.value = ['snippets.create_and_modify']
mockUseQueryData.current = [{ id: 'snippet-tag-1', name: 'Reusable', type: 'snippet', binding_count: 1 }]
render(

View File

@ -7,7 +7,7 @@ export const getTagManagePermissionKey = (type: TagType): PermissionKey => {
return 'app.tag.manage'
if (type === 'snippet')
return SnippetPermission.Management
return SnippetPermission.CreateAndModify
return 'dataset.tag.manage'
}

View File

@ -222,6 +222,7 @@
"studio.starFailed": "فشل تحديث النجمة",
"studio.starred": "المميزة بنجمة",
"studio.unstarApp": "إزالة النجمة من التطبيق",
"studio.viewSnippets": "عرض المقتطفات",
"switch": "التبديل إلى Workflow Orchestrate",
"switchLabel": "نسخة التطبيق التي سيتم إنشاؤها",
"switchStart": "بدء التبديل",

View File

@ -1,5 +1,4 @@
{
"continueEditing": "متابعة التحرير",
"create": "إنشاء مقتطف",
"createFailed": "فشل إنشاء المقتطف",
"createFrom": "إنشاء من",
@ -9,11 +8,7 @@
"deleteConfirmTitle": "هل تريد حذف المقتطف؟",
"deleteFailed": "فشل حذف المقتطف",
"deleted": "تم حذف المقتطف",
"discardChanges": "تجاهل التغييرات",
"discardChangesDescription": "سيتم تجاهل مسودّة تغييراتك وسيعود المقتطف إلى آخر نسخة محفوظة.",
"discardChangesTitle": "هل تريد تجاهل مسودة التغييرات؟",
"discardDraft": "تجاهل المسودة",
"doNotSave": "اترك كمسودة",
"draft": "مسودة",
"dslVersionMismatchDescription": "تم اكتشاف اختلاف كبير في إصدارات DSL. قد يؤدي فرض الاستيراد إلى حدوث خلل في المقتطف.",
"dslVersionMismatchQuestion": "هل تريد الاستمرار؟",
"dslVersionMismatchTitle": "عدم توافق الإصدار",
@ -21,9 +16,7 @@
"editDialogTitle": "تحرير معلومات المقتطف",
"editDone": "تم تحديث معلومات المقتطف",
"editFailed": "فشل تحديث معلومات المقتطف",
"editingDraft": "أنت تقوم بتحرير مسودة.",
"emptyGraphSaveError": "أضف عقدة واحدة على الأقل قبل الحفظ.",
"exitEditing": "الخروج من التحرير",
"emptyGraphSaveError": "أضف عقدة واحدة على الأقل قبل النشر.",
"exportFailed": "فشل تصدير المقتطف.",
"importDSLFile": "استيراد ملف دي اس ال",
"importDialogTitle": "استيراد مقتطف",
@ -38,14 +31,15 @@
"menu.editInfo": "تحرير المعلومات",
"menu.exportSnippet": "تصدير مقتطف",
"panelTitle": "حقل الإدخال",
"publishButton": "نشر",
"publishFailed": "فشل نشر المقتطف",
"save": "حفظ",
"saveAndExit": "حفظ والخروج",
"saveBeforeLeavingDescription": "احفظ لجعل هذا الإصدار متاحًا للاستخدام في مهام سير العمل. أو احتفظ بتعديلاتك كمسودة في الوقت الحالي.",
"saveBeforeLeavingTitle": "هل تريد حفظ التغييرات قبل المغادرة؟",
"saveSuccess": "تم حفظ المقتطف",
"publishMenuCurrentDraft": "المسودة الحالية غير منشورة",
"publishSuccess": "تم نشر المقتطف",
"sectionOrchestrate": "نسق",
"testRunButton": "تشغيل تجريبي",
"typeLabel": "مقتطف",
"unknownUser": "المستخدم",
"viewOnly": "عرض فقط"
"updatedBy": "{{name}} تم التحديث {{time}}",
"usageCount": "تم الاستخدام {{count}} مرات",
"variableInspect": "فحص متغير"
}

View File

@ -222,6 +222,7 @@
"studio.starFailed": "Stern konnte nicht aktualisiert werden",
"studio.starred": "Markiert",
"studio.unstarApp": "Markierung der App entfernen",
"studio.viewSnippets": "Snippets anzeigen",
"switch": "Zu Workflow-Orchestrierung wechseln",
"switchLabel": "Die zu erstellende App-Kopie",
"switchStart": "Wechsel starten",

View File

@ -1,5 +1,4 @@
{
"continueEditing": "Bearbeiten Sie weiter",
"create": "SNIPPET ERSTELLEN",
"createFailed": "Snippet konnte nicht erstellt werden",
"createFrom": "ERSTELLEN AUS",
@ -9,11 +8,7 @@
"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?",
"dslVersionMismatchTitle": "Versionsinkompatibilität",
@ -21,9 +16,7 @@
"editDialogTitle": "Bearbeiten Sie die Snippet-Informationen",
"editDone": "Snippet-Informationen aktualisiert",
"editFailed": "Snippet-Informationen konnten nicht aktualisiert werden",
"editingDraft": "Sie bearbeiten einen Entwurf.",
"emptyGraphSaveError": "Fügen Sie vor dem Speichern mindestens einen Knoten hinzu.",
"exitEditing": "Bearbeiten beenden",
"emptyGraphSaveError": "Fügen Sie vor dem Veröffentlichen mindestens einen Knoten hinzu.",
"exportFailed": "Der Export des Snippets ist fehlgeschlagen.",
"importDSLFile": "DSL-Datei importieren",
"importDialogTitle": "Snippet importieren",
@ -38,14 +31,15 @@
"menu.editInfo": "Informationen bearbeiten",
"menu.exportSnippet": "Snippet exportieren",
"panelTitle": "Eingabefeld",
"publishButton": "Veröffentlichen",
"publishFailed": "Snippet konnte nicht veröffentlicht werden",
"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",
"publishMenuCurrentDraft": "Aktueller Entwurf unveröffentlicht",
"publishSuccess": "Snippet veröffentlicht",
"sectionOrchestrate": "Orchestrieren",
"testRunButton": "Testlauf",
"typeLabel": "Ausschnitt",
"unknownUser": "Benutzer",
"viewOnly": "Nur ansehen"
"updatedBy": "{{name}} aktualisiert {{time}}",
"usageCount": "{{count}} Mal verwendet",
"variableInspect": "Variablenprüfung"
}

View File

@ -222,6 +222,7 @@
"studio.starFailed": "Failed to update star",
"studio.starred": "Starred",
"studio.unstarApp": "Unstar app",
"studio.viewSnippets": "View Snippets",
"switch": "Switch to Workflow Orchestrate",
"switchLabel": "The app copy to be created",
"switchStart": "Start switch",

View File

@ -1,5 +1,4 @@
{
"continueEditing": "Continue Editing",
"create": "CREATE SNIPPET",
"createFailed": "Failed to create snippet",
"createFrom": "CREATE FROM",
@ -9,11 +8,7 @@
"deleteConfirmTitle": "Delete Snippet?",
"deleteFailed": "Failed to delete snippet",
"deleted": "Snippet deleted",
"discardChanges": "Discard Changes",
"discardChangesDescription": "Your draft changes will be discarded and the snippet will return to the last saved version.",
"discardChangesTitle": "Discard draft changes?",
"discardDraft": "Discard Draft",
"doNotSave": "Leave as Draft",
"draft": "Draft",
"dslVersionMismatchDescription": "A significant difference in DSL versions has been detected. Forcing the import may cause the snippet to malfunction.",
"dslVersionMismatchQuestion": "Do you want to continue?",
"dslVersionMismatchTitle": "Version Incompatibility",
@ -21,9 +16,7 @@
"editDialogTitle": "Edit Snippet Info",
"editDone": "Snippet info updated",
"editFailed": "Failed to update snippet info",
"editingDraft": "You are editing a draft.",
"emptyGraphSaveError": "Add at least one node before saving.",
"exitEditing": "Exit Editing",
"emptyGraphSaveError": "Add at least one node before publishing.",
"exportFailed": "Export snippet failed.",
"importDSLFile": "Import DSL file",
"importDialogTitle": "Import Snippet",
@ -38,14 +31,15 @@
"menu.editInfo": "Edit Info",
"menu.exportSnippet": "Export Snippet",
"panelTitle": "Input Field",
"publishButton": "Publish",
"publishFailed": "Failed to publish snippet",
"save": "Save",
"saveAndExit": "Save and Exit",
"saveBeforeLeavingDescription": "Save to make this version available to use in workflows. Or keep your edits as a draft for now.",
"saveBeforeLeavingTitle": "Save changes before leaving?",
"saveSuccess": "Snippet saved",
"publishMenuCurrentDraft": "Current draft unpublished",
"publishSuccess": "Snippet published",
"sectionOrchestrate": "Orchestrate",
"testRunButton": "Test run",
"typeLabel": "Snippet",
"unknownUser": "User",
"viewOnly": "View only"
"updatedBy": "{{name}} updated {{time}}",
"usageCount": "Used {{count}} times",
"variableInspect": "Variable Inspect"
}

View File

@ -222,6 +222,7 @@
"studio.starFailed": "No se pudo actualizar la estrella",
"studio.starred": "Destacadas",
"studio.unstarApp": "Quitar estrella de la aplicación",
"studio.viewSnippets": "Ver fragmentos",
"switch": "Cambiar a Orquestación de Flujo de Trabajo",
"switchLabel": "La copia de la app a crear",
"switchStart": "Iniciar cambio",

View File

@ -1,5 +1,4 @@
{
"continueEditing": "Continuar editando",
"create": "CREAR FRAGMENTO",
"createFailed": "No se pudo crear el fragmento",
"createFrom": "CREAR DESDE",
@ -9,11 +8,7 @@
"deleteConfirmTitle": "¿Eliminar fragmento?",
"deleteFailed": "No se pudo eliminar el fragmento",
"deleted": "Fragmento eliminado",
"discardChanges": "Descartar cambios",
"discardChangesDescription": "Los cambios en el borrador se descartarán y el fragmento volverá a la última versión guardada.",
"discardChangesTitle": "¿Descartar cambios en el borrador?",
"discardDraft": "Descartar borrador",
"doNotSave": "Dejar como borrador",
"draft": "Borrador",
"dslVersionMismatchDescription": "Se ha detectado una diferencia significativa en las versiones DSL. Forzar la importación puede provocar que el fragmento no funcione correctamente.",
"dslVersionMismatchQuestion": "¿Quieres continuar?",
"dslVersionMismatchTitle": "Incompatibilidad de versión",
@ -21,9 +16,7 @@
"editDialogTitle": "Editar información del fragmento",
"editDone": "Información del fragmento actualizada",
"editFailed": "No se pudo actualizar la información del fragmento",
"editingDraft": "Estás editando un borrador.",
"emptyGraphSaveError": "Agregue al menos un nodo antes de guardar.",
"exitEditing": "Salir de edición",
"emptyGraphSaveError": "Agregue al menos un nodo antes de publicar.",
"exportFailed": "Error al exportar el fragmento.",
"importDSLFile": "Importar archivo DSL",
"importDialogTitle": "Importar fragmento",
@ -38,14 +31,15 @@
"menu.editInfo": "Editar información",
"menu.exportSnippet": "Exportar fragmento",
"panelTitle": "Campo de entrada",
"publishButton": "Publicar",
"publishFailed": "No se pudo publicar el fragmento",
"save": "Guardar",
"saveAndExit": "Guardar y salir",
"saveBeforeLeavingDescription": "Guárdelo para que esta versión esté disponible para su uso en flujos de trabajo. O mantén tus ediciones como borrador por ahora.",
"saveBeforeLeavingTitle": "¿Guardar cambios antes de salir?",
"saveSuccess": "Fragmento guardado",
"publishMenuCurrentDraft": "Borrador actual inédito",
"publishSuccess": "Fragmento publicado",
"sectionOrchestrate": "orquestar",
"testRunButton": "Ejecución de prueba",
"typeLabel": "Fragmento",
"unknownUser": "Usuario",
"viewOnly": "Ver sólo"
"updatedBy": "{{name}} actualizado {{time}}",
"usageCount": "Usado {{count}} veces",
"variableInspect": "Inspección de variables"
}

View File

@ -222,6 +222,7 @@
"studio.starFailed": "به‌روزرسانی ستاره ناموفق بود",
"studio.starred": "ستاره‌دار",
"studio.unstarApp": "برداشتن ستاره برنامه",
"studio.viewSnippets": "مشاهده قطعه‌ها",
"switch": "تغییر به سازماندهی گردش کار",
"switchLabel": "نسخه برنامه که ایجاد می‌شود",
"switchStart": "شروع تغییر",

View File

@ -1,5 +1,4 @@
{
"continueEditing": "ادامه ویرایش",
"create": "ایجاد قطعه",
"createFailed": "قطعه ایجاد نشد",
"createFrom": "ایجاد از",
@ -9,11 +8,7 @@
"deleteConfirmTitle": "قطعه حذف شود؟",
"deleteFailed": "قطعه حذف نشد",
"deleted": "قطعه حذف شد",
"discardChanges": "حذف تغییرات",
"discardChangesDescription": "تغییرات پیش نویس شما نادیده گرفته می شود و قطعه به آخرین نسخه ذخیره شده باز می گردد.",
"discardChangesTitle": "از تغییرات پیش‌نویس صرف‌نظر شود؟",
"discardDraft": "دور انداختن پیش نویس",
"doNotSave": "به عنوان پیش نویس بگذارید",
"draft": "پیش نویس",
"dslVersionMismatchDescription": "تفاوت قابل توجهی در نسخه های DSL شناسایی شده است. وارد کردن اجباری ممکن است باعث اختلال در عملکرد قطعه شود.",
"dslVersionMismatchQuestion": "آیا می خواهید ادامه دهید؟",
"dslVersionMismatchTitle": "ناسازگاری نسخه",
@ -21,9 +16,7 @@
"editDialogTitle": "ویرایش اطلاعات قطعه",
"editDone": "اطلاعات قطعه به‌روزرسانی شد",
"editFailed": "اطلاعات قطعه به‌روزرسانی نشد",
"editingDraft": "شما در حال ویرایش پیش نویس هستید.",
"emptyGraphSaveError": "قبل از ذخیره حداقل یک گره اضافه کنید.",
"exitEditing": "خروج از ویرایش",
"emptyGraphSaveError": "قبل از انتشار حداقل یک گره اضافه کنید.",
"exportFailed": "قطعه صادر نشد.",
"importDSLFile": "فایل DSL را وارد کنید",
"importDialogTitle": "وارد کردن قطعه",
@ -38,14 +31,15 @@
"menu.editInfo": "ویرایش اطلاعات",
"menu.exportSnippet": "صادرات قطعه",
"panelTitle": "فیلد ورودی",
"publishButton": "انتشار",
"publishFailed": "انتشار قطعه ناموفق بود",
"save": "ذخیره کنید",
"saveAndExit": "ذخیره و خروج",
"saveBeforeLeavingDescription": "ذخیره کنید تا این نسخه برای استفاده در گردش کار در دسترس باشد. یا ویرایش های خود را در حال حاضر به عنوان پیش نویس نگه دارید.",
"saveBeforeLeavingTitle": "تغییرات قبل از خروج ذخیره شود؟",
"saveSuccess": "قطعه ذخیره شد",
"publishMenuCurrentDraft": "پیش نویس فعلی منتشر نشده است",
"publishSuccess": "قطعه منتشر شد",
"sectionOrchestrate": "ارکستر کردن",
"testRunButton": "اجرای آزمایشی",
"typeLabel": "قطعه",
"unknownUser": "کاربر",
"viewOnly": "فقط مشاهده کنید"
"updatedBy": "{{name}} به روز شد {{time}}",
"usageCount": "{{count}} بار استفاده شده است",
"variableInspect": "متغیر بازرسی"
}

View File

@ -222,6 +222,7 @@
"studio.starFailed": "Échec de la mise à jour du favori",
"studio.starred": "Favoris",
"studio.unstarApp": "Retirer des favoris",
"studio.viewSnippets": "Voir les extraits",
"switch": "Passer à l'orchestration de flux de travail",
"switchLabel": "La copie de l'application à créer",
"switchStart": "Commencer la commutation",

View File

@ -1,5 +1,4 @@
{
"continueEditing": "Continuer la modification",
"create": "CRÉER UN EXTRAIT",
"createFailed": "Échec de la création de l'extrait",
"createFrom": "CRÉER À PARTIR DE",
@ -9,11 +8,7 @@
"deleteConfirmTitle": "Supprimer l'extrait ?",
"deleteFailed": "Échec de la suppression de l'extrait",
"deleted": "Extrait supprimé",
"discardChanges": "Ignorer les modifications",
"discardChangesDescription": "Vos brouillons de modifications seront ignorés et l'extrait reviendra la dernière version enregistrée.",
"discardChangesTitle": "Supprimer les brouillons de modifications ?",
"discardDraft": "Supprimer le brouillon",
"doNotSave": "Laisser comme brouillon",
"draft": "Brouillon",
"dslVersionMismatchDescription": "Une différence significative dans les versions DSL a été détectée. Forcer limportation peut entraîner un dysfonctionnement de lextrait.",
"dslVersionMismatchQuestion": "Voulez-vous continuer ?",
"dslVersionMismatchTitle": "Incompatibilité des versions",
@ -21,9 +16,7 @@
"editDialogTitle": "Modifier les informations sur l'extrait",
"editDone": "Informations sur l'extrait mises jour",
"editFailed": "Échec de la mise jour des informations sur l'extrait",
"editingDraft": "Vous modifiez un brouillon.",
"emptyGraphSaveError": "Ajoutez au moins un nœud avant d'enregistrer.",
"exitEditing": "Quitter l'édition",
"emptyGraphSaveError": "Ajoutez au moins un nœud avant de publier.",
"exportFailed": "Échec de l'exportation de l'extrait.",
"importDSLFile": "Importer un fichier DSL",
"importDialogTitle": "Importer un extrait",
@ -38,14 +31,15 @@
"menu.editInfo": "Modifier les informations",
"menu.exportSnippet": "Exporter l'extrait",
"panelTitle": "Champ de saisie",
"publishButton": "Publier",
"publishFailed": "Échec de la publication de l'extrait",
"save": "Enregistrer",
"saveAndExit": "Enregistrer et quitter",
"saveBeforeLeavingDescription": "Enregistrez pour rendre cette version disponible pour une utilisation dans les flux de travail. Ou conservez vos modifications sous forme de brouillon pour le moment.",
"saveBeforeLeavingTitle": "Enregistrer les modifications avant de quitter ?",
"saveSuccess": "Extrait enregistré",
"publishMenuCurrentDraft": "Projet actuel non publié",
"publishSuccess": "Extrait publié",
"sectionOrchestrate": "Orchestrer",
"testRunButton": "Exécution d'essai",
"typeLabel": "Extrait",
"unknownUser": "Utilisateur",
"viewOnly": "Visualisation uniquement"
"updatedBy": "{{name}} mis jour {{time}}",
"usageCount": "Utilisé {{count}} fois",
"variableInspect": "Inspecter les variables"
}

View File

@ -222,6 +222,7 @@
"studio.starFailed": "स्टार अपडेट करने में विफल",
"studio.starred": "स्टार किए गए",
"studio.unstarApp": "ऐप से स्टार हटाएं",
"studio.viewSnippets": "स्निपेट देखें",
"switch": "वर्कफ़्लो ऑर्केस्ट्रेट पर स्विच करें",
"switchLabel": "बनाई जाने वाली ऐप कॉपी",
"switchStart": "स्विच शुरू करें",

View File

@ -1,5 +1,4 @@
{
"continueEditing": "संपादन जारी रखें",
"create": "स्निपेट बनाएं",
"createFailed": "स्निपेट बनाने में विफल",
"createFrom": "से बनाएं",
@ -9,11 +8,7 @@
"deleteConfirmTitle": "स्निपेट हटाएं?",
"deleteFailed": "स्निपेट हटाने में विफल",
"deleted": "स्निपेट हटा दिया गया",
"discardChanges": "परिवर्तन त्यागें",
"discardChangesDescription": "आपके ड्राफ्ट परिवर्तन खारिज कर दिए जाएंगे और स्निपेट अंतिम सहेजे गए संस्करण में वापस आ जाएगा।",
"discardChangesTitle": "ड्राफ्ट परितन खारज करं?",
"discardDraft": "ड्राफ्ट त्यागें",
"doNotSave": "ड्राफ्ट के रूप में छोड़ें",
"draft": "ड्राफ्ट",
"dslVersionMismatchDescription": "डीएसएल संस्करणों में एक महत्वपूर्ण अंतर पाया गया है। आयात को बाध्य करने से स्निपेट ख़राब हो सकता है।",
"dslVersionMismatchQuestion": "क्या आप जारी रखना चाहते हैं?",
"dslVersionMismatchTitle": "संस्करण असंगति",
@ -21,9 +16,7 @@
"editDialogTitle": "स्निपेट जानकारी संपादित करें",
"editDone": "स्निपेट जानकारी अपडेट की गई",
"editFailed": "स्निपेट जानकारी अपडेट करने में विफल",
"editingDraft": "आप एक ड्राफ्ट संपादित कर रहे हैं.",
"emptyGraphSaveError": "सहेजने से पहले कम से कम एक नोड जोड़ें.",
"exitEditing": "संपादन से बाहर निकलें",
"emptyGraphSaveError": "प्रकाशित करने से पहले कम से कम एक नोड जोड़ें.",
"exportFailed": "निर्यात स्निपेट विफल रहा.",
"importDSLFile": "डीएसएल फ़ाइल आयात करें",
"importDialogTitle": "स्निपेट आयात करें",
@ -38,14 +31,15 @@
"menu.editInfo": "जानकारी संपादित करें",
"menu.exportSnippet": "स्निपेट निर्यात करें",
"panelTitle": "इनपुट फ़ील्ड",
"publishButton": "प्रकाशित करें",
"publishFailed": "स्निपेट प्रकाशित करने में विफल",
"save": "सहेजें",
"saveAndExit": "सहेजें और बाहर निकलें",
"saveBeforeLeavingDescription": "इस संस्करण को वर्कफ़्लो में उपयोग के लिए उपलब्ध कराने के लिए सहेजें। या अपने संपादनों को अभी ड्राफ्ट के रूप में रखें।",
"saveBeforeLeavingTitle": "जाने से पहले परिवर्तन सहेजें?",
"saveSuccess": "स्निपेट सहेजा गया",
"publishMenuCurrentDraft": "वर्तमान मसौदा अप्रकाशित",
"publishSuccess": "स्निपेट प्रकाशित",
"sectionOrchestrate": "आर्केस्ट्रा",
"testRunButton": "टेस्ट रन",
"typeLabel": "स्निपेट",
"unknownUser": "उपयोगकर्ता",
"viewOnly": "केवल देखें"
"updatedBy": "{{name}} अद्यतन {{time}}",
"usageCount": "{{count}} बार उपयोग किया गया",
"variableInspect": "परिवर्तनीय निरीक्षण"
}

View File

@ -222,6 +222,7 @@
"studio.starFailed": "Gagal memperbarui bintang",
"studio.starred": "Berbintang",
"studio.unstarApp": "Hapus bintang aplikasi",
"studio.viewSnippets": "Lihat snippet",
"switch": "Beralih ke Workflow Orchestrate",
"switchLabel": "Salinan aplikasi yang akan dibuat",
"switchStart": "Sakelar mulai",

View File

@ -1,5 +1,4 @@
{
"continueEditing": "Lanjutkan Mengedit",
"create": "BUAT SNIPPET",
"createFailed": "Gagal membuat cuplikan",
"createFrom": "BUAT DARI",
@ -9,11 +8,7 @@
"deleteConfirmTitle": "Hapus Cuplikan?",
"deleteFailed": "Gagal menghapus cuplikan",
"deleted": "Cuplikan dihapus",
"discardChanges": "Buang Perubahan",
"discardChangesDescription": "Draf perubahan Anda akan dibuang dan cuplikan akan kembali ke versi terakhir yang disimpan.",
"discardChangesTitle": "Hapus draf perubahan?",
"discardDraft": "Buang Draf",
"doNotSave": "Biarkan sebagai Draf",
"draft": "Draf",
"dslVersionMismatchDescription": "Perbedaan signifikan dalam versi DSL telah terdeteksi. Memaksa impor dapat menyebabkan cuplikan tidak berfungsi.",
"dslVersionMismatchQuestion": "Apakah Anda ingin melanjutkan?",
"dslVersionMismatchTitle": "Ketidakcocokan Versi",
@ -21,9 +16,7 @@
"editDialogTitle": "Edit Info Cuplikan",
"editDone": "Info cuplikan diperbarui",
"editFailed": "Gagal memperbarui info cuplikan",
"editingDraft": "Anda sedang mengedit draf.",
"emptyGraphSaveError": "Tambahkan setidaknya satu node sebelum menyimpan.",
"exitEditing": "Keluar dari Pengeditan",
"emptyGraphSaveError": "Tambahkan setidaknya satu node sebelum memublikasikan.",
"exportFailed": "Ekspor cuplikan gagal.",
"importDSLFile": "Impor berkas DSL",
"importDialogTitle": "Impor Cuplikan",
@ -38,14 +31,15 @@
"menu.editInfo": "Sunting Informasi",
"menu.exportSnippet": "Ekspor Cuplikan",
"panelTitle": "Bidang Masukan",
"publishButton": "Publikasikan",
"publishFailed": "Gagal memublikasikan cuplikan",
"save": "Simpan",
"saveAndExit": "Simpan dan Keluar",
"saveBeforeLeavingDescription": "Simpan agar versi ini tersedia untuk digunakan dalam alur kerja. Atau simpan hasil edit Anda sebagai draf untuk saat ini.",
"saveBeforeLeavingTitle": "Simpan perubahan sebelum berangkat?",
"saveSuccess": "Cuplikan disimpan",
"publishMenuCurrentDraft": "Draf saat ini tidak dipublikasikan",
"publishSuccess": "Cuplikan diterbitkan",
"sectionOrchestrate": "Mengatur",
"testRunButton": "Uji coba",
"typeLabel": "Cuplikan",
"unknownUser": "Pengguna",
"viewOnly": "Lihat saja"
"updatedBy": "{{name}} diperbarui {{time}}",
"usageCount": "Digunakan {{count}} kali",
"variableInspect": "Pemeriksaan Variabel"
}

View File

@ -222,6 +222,7 @@
"studio.starFailed": "Impossibile aggiornare la stella",
"studio.starred": "Preferite",
"studio.unstarApp": "Rimuovi app dai preferiti",
"studio.viewSnippets": "Visualizza snippet",
"switch": "Passa a Orchestrazione del flusso di lavoro",
"switchLabel": "La copia dell'app da creare",
"switchStart": "Inizia il passaggio",

View File

@ -1,5 +1,4 @@
{
"continueEditing": "Continua a modificare",
"create": "CREA SNIPPET",
"createFailed": "Impossibile creare lo snippet",
"createFrom": "CREA DA",
@ -9,11 +8,7 @@
"deleteConfirmTitle": "Eliminare lo snippet?",
"deleteFailed": "Impossibile eliminare lo snippet",
"deleted": "Frammento eliminato",
"discardChanges": "Annulla modifiche",
"discardChangesDescription": "Le modifiche alla bozza verranno annullate e lo snippet torner all'ultima versione salvata.",
"discardChangesTitle": "Eliminare le modifiche alla bozza?",
"discardDraft": "Scarta bozza",
"doNotSave": "Lascia come bozza",
"draft": "Bozza",
"dslVersionMismatchDescription": "È stata rilevata una differenza significativa nelle versioni DSL. Forzare l'importazione potrebbe causare il malfunzionamento dello snippet.",
"dslVersionMismatchQuestion": "Vuoi continuare?",
"dslVersionMismatchTitle": "Incompatibilit di versione",
@ -21,9 +16,7 @@
"editDialogTitle": "Modifica informazioni sullo snippet",
"editDone": "Informazioni sullo snippet aggiornate",
"editFailed": "Impossibile aggiornare le informazioni sullo snippet",
"editingDraft": "Stai modificando una bozza.",
"emptyGraphSaveError": "Aggiungi almeno un nodo prima di salvare.",
"exitEditing": "Esci dalla modifica",
"emptyGraphSaveError": "Aggiungi almeno un nodo prima di pubblicare.",
"exportFailed": "Esportazione dello snippet non riuscita.",
"importDSLFile": "Importa file DSL",
"importDialogTitle": "Importa snippet",
@ -38,14 +31,15 @@
"menu.editInfo": "Modifica informazioni",
"menu.exportSnippet": "Esporta frammento",
"panelTitle": "Campo di immissione",
"publishButton": "Pubblica",
"publishFailed": "Impossibile pubblicare lo snippet",
"save": "Salva",
"saveAndExit": "Salva ed esci",
"saveBeforeLeavingDescription": "Salva per rendere questa versione disponibile per l'utilizzo nei flussi di lavoro. Oppure mantieni le modifiche come bozza per ora.",
"saveBeforeLeavingTitle": "Vuoi salvare le modifiche prima di uscire?",
"saveSuccess": "Frammento salvato",
"publishMenuCurrentDraft": "Bozza attuale non pubblicata",
"publishSuccess": "Frammento pubblicato",
"sectionOrchestrate": "Orchestrare",
"testRunButton": "Prova di funzionamento",
"typeLabel": "Frammento",
"unknownUser": "Utente",
"viewOnly": "Visualizza solo"
"updatedBy": "{{name}} aggiornato {{time}}",
"usageCount": "Usato {{count}} volte",
"variableInspect": "Ispezione variabile"
}

View File

@ -222,6 +222,7 @@
"studio.starFailed": "スターの更新に失敗しました",
"studio.starred": "スター付き",
"studio.unstarApp": "アプリのスターを外す",
"studio.viewSnippets": "スニペットを表示",
"switch": "ワークフロー オーケストレートに切り替える",
"switchLabel": "作成されるアプリのコピー",
"switchStart": "切り替えを開始する",

View File

@ -1,5 +1,4 @@
{
"continueEditing": "編集を続ける",
"create": "スニペットの作成",
"createFailed": "スニペットの作成に失敗しました",
"createFrom": "から作成",
@ -9,11 +8,7 @@
"deleteConfirmTitle": "スニペットを削除しますか?",
"deleteFailed": "スニペットの削除に失敗しました",
"deleted": "スニペットが削除されました",
"discardChanges": "変更の 棄",
"discardChangesDescription": "下書きの変更は 棄され、スニペットは最後に保存されたバージョンに戻ります。",
"discardChangesTitle": "ドラフトの変更を 棄しますか?",
"discardDraft": "下書きを 棄",
"doNotSave": "ドラフトとして残す",
"draft": "草案",
"dslVersionMismatchDescription": "DSL バージョンの大きな違いが検出されました。インポートを強制すると、スニペットが誤動作する可能性があります。",
"dslVersionMismatchQuestion": "続けますか?",
"dslVersionMismatchTitle": "バージョンの非互換性",
@ -21,9 +16,7 @@
"editDialogTitle": "スニペット情 の編集",
"editDone": "スニペット情 が更新されました",
"editFailed": "スニペット情 の更新に失敗しました",
"editingDraft": "下書きを編集中です。",
"emptyGraphSaveError": "保存する前に少なくとも 1 つのノードを追 してく さい。",
"exitEditing": "編集を終了する",
"emptyGraphSaveError": "公開する前に少なくとも 1 つのノードを追加してください。",
"exportFailed": "スニペットのエクスポートに失敗しました。",
"importDSLFile": "DSL ファイルをインポートする",
"importDialogTitle": "スニペットのインポート",
@ -38,14 +31,15 @@
"menu.editInfo": "情 の編集",
"menu.exportSnippet": "スニペットのエクスポート",
"panelTitle": "入力フィールド",
"publishButton": "公開",
"publishFailed": "スニペットの公開に失敗しました",
"save": "保存",
"saveAndExit": "保存して終了",
"saveBeforeLeavingDescription": "保存して、このバージョンをワークフローで使用できるようにします。または、編集内容を今のところ下書きとして保存しておいてく さい。",
"saveBeforeLeavingTitle": "終了する前に変更を保存しますか?",
"saveSuccess": "スニペットが保存されました",
"publishMenuCurrentDraft": "現在のドラフトは未公開です",
"publishSuccess": "スニペットが公開されました",
"sectionOrchestrate": "オーケストレーション",
"testRunButton": "試運転",
"typeLabel": "スニペット",
"unknownUser": "ユーザー",
"viewOnly": "閲覧のみ"
"updatedBy": "{{name}} {{time}} を更新しました",
"usageCount": "{{count}} 回使用しました",
"variableInspect": "変数の検査"
}

View File

@ -222,6 +222,7 @@
"studio.starFailed": "별표 업데이트 실패",
"studio.starred": "별표 표시됨",
"studio.unstarApp": "앱 별표 해제",
"studio.viewSnippets": "스니펫 보기",
"switch": "워크플로우 오케스트레이션으로 전환하기",
"switchLabel": "생성될 앱의 복사본",
"switchStart": "전환 시작하기",

View File

@ -1,5 +1,4 @@
{
"continueEditing": "계속 편집",
"create": "스니펫 만들기",
"createFailed": "스니펫을 생성하지 못했습니다.",
"createFrom": "다음에서 생성",
@ -9,11 +8,7 @@
"deleteConfirmTitle": "스니펫을 삭 하시 습니까?",
"deleteFailed": "스니펫을 삭 하지 못했습니다.",
"deleted": "스니펫이 삭 되었습니다.",
"discardChanges": "변경사항 취소",
"discardChangesDescription": "초안 변경사항이 삭 되 스니펫이 마지막으로 장된 버 으로 돌아갑니다.",
"discardChangesTitle": "초안 변경사항을 삭 하시 습니까?",
"discardDraft": "초안 삭 ",
"doNotSave": "초안으로 남겨두기",
"draft": "초안",
"dslVersionMismatchDescription": "DSL 버 에서 상당한 차이가 발견되었습니다. 강 로 가 오면 스니펫이 오작동 수 있습니다.",
"dslVersionMismatchQuestion": "계속하시 습니까?",
"dslVersionMismatchTitle": "버 비호환성",
@ -21,9 +16,7 @@
"editDialogTitle": "스니펫 보 편집",
"editDone": "스니펫 보가 업데이트되었습니다.",
"editFailed": "스니펫 보를 업데이트하지 못했습니다.",
"editingDraft": "초안을 편집하 있습니다.",
"emptyGraphSaveError": "저장하기 전에 노드를 하나 이상 추가하십시오.",
"exitEditing": "편집 종료",
"emptyGraphSaveError": "게시하기 전에 노드를 하나 이상 추가하세요.",
"exportFailed": "스니펫 내보내기에 실패했습니다.",
"importDSLFile": "DSL 파일 가 오기",
"importDialogTitle": "스니펫 가 오기",
@ -38,14 +31,15 @@
"menu.editInfo": " 보 편집",
"menu.exportSnippet": "스니펫 내보내기",
"panelTitle": "입 필드",
"publishButton": "게시",
"publishFailed": "스니펫을 게시하지 못했습니다.",
"save": " 장",
"saveAndExit": " 장하 종료",
"saveBeforeLeavingDescription": "이 버 을 워크플로에서 사용 수 있도록 하 면 장하세요. 아니면 지금은 수 사항을 초안으로 지하세요.",
"saveBeforeLeavingTitle": " 나기 에 변경사항을 장하시 습니까?",
"saveSuccess": "스니펫이 장되었습니다.",
"publishMenuCurrentDraft": "현재 초안이 게시되지 않았습니다.",
"publishSuccess": "스니펫이 게시되었습니다.",
"sectionOrchestrate": "오케스트 이션",
"testRunButton": "테스트 실행",
"typeLabel": "스니펫",
"unknownUser": "사용자",
"viewOnly": "보기 용"
"updatedBy": "{{name}} 업데이트됨 {{time}}",
"usageCount": "{{count}}번 사용됨",
"variableInspect": "변수 검사"
}

View File

@ -222,6 +222,7 @@
"studio.starFailed": "Ster kon niet worden bijgewerkt",
"studio.starred": "Gemarkeerd",
"studio.unstarApp": "Markering van app verwijderen",
"studio.viewSnippets": "Snippets bekijken",
"switch": "Switch to Workflow Orchestrate",
"switchLabel": "The app copy to be created",
"switchStart": "Start switch",

View File

@ -1,5 +1,4 @@
{
"continueEditing": "Ga door met bewerken",
"create": "SNIPPET MAKEN",
"createFailed": "Kan fragment niet maken",
"createFrom": "CREËER VAN",
@ -9,11 +8,7 @@
"deleteConfirmTitle": "Fragment verwijderen?",
"deleteFailed": "Kan fragment niet verwijderen",
"deleted": "Fragment verwijderd",
"discardChanges": "Wijzigingen negeren",
"discardChangesDescription": "Uw conceptwijzigingen worden verwijderd en het fragment keert terug naar de laatst opgeslagen versie.",
"discardChangesTitle": "Conceptwijzigingen negeren?",
"discardDraft": "Gooi concept weg",
"doNotSave": "Laat het als concept staan",
"draft": "Diepgang",
"dslVersionMismatchDescription": "Er is een significant verschil in DSL-versies gedetecteerd. Als u de import forceert, kan het fragment defect raken.",
"dslVersionMismatchQuestion": "Wil je doorgaan?",
"dslVersionMismatchTitle": "Versie-incompatibiliteit",
@ -21,9 +16,7 @@
"editDialogTitle": "Fragmentinformatie bewerken",
"editDone": "Fragmentinformatie bijgewerkt",
"editFailed": "Kan fragmentinformatie niet updaten",
"editingDraft": "U bewerkt een concept.",
"emptyGraphSaveError": "Voeg ten minste één knooppunt toe voordat u opslaat.",
"exitEditing": "Sluit het bewerken af",
"emptyGraphSaveError": "Voeg ten minste één knooppunt toe voordat u publiceert.",
"exportFailed": "Het exporteren van het fragment is mislukt.",
"importDSLFile": "DSL-bestand importeren",
"importDialogTitle": "Fragment importeren",
@ -38,14 +31,15 @@
"menu.editInfo": "Bewerk informatie",
"menu.exportSnippet": "Fragment exporteren",
"panelTitle": "Invoerveld",
"publishButton": "Publiceren",
"publishFailed": "Kan fragment niet publiceren",
"save": "Opslaan",
"saveAndExit": "Opslaan en afsluiten",
"saveBeforeLeavingDescription": "Sla op om deze versie beschikbaar te maken voor gebruik in workflows. Of bewaar uw bewerkingen voorlopig als concept.",
"saveBeforeLeavingTitle": "Wijzigingen opslaan voordat u vertrekt?",
"saveSuccess": "Fragment opgeslagen",
"publishMenuCurrentDraft": "Huidig concept niet gepubliceerd",
"publishSuccess": "Fragment gepubliceerd",
"sectionOrchestrate": "Orkesteren",
"testRunButton": "Proefdraaien",
"typeLabel": "Fragment",
"unknownUser": "Gebruiker",
"viewOnly": "Alleen bekijken"
"updatedBy": "{{name}} bijgewerkt {{time}}",
"usageCount": "{{count}} keer gebruikt",
"variableInspect": "Variabele inspectie"
}

View File

@ -222,6 +222,7 @@
"studio.starFailed": "Nie udało się zaktualizować gwiazdki",
"studio.starred": "Oznaczone gwiazdką",
"studio.unstarApp": "Usuń gwiazdkę aplikacji",
"studio.viewSnippets": "Wyświetl fragmenty",
"switch": "Przełącz na Orkiestrację Przepływu Pracy",
"switchLabel": "Kopia aplikacji do utworzenia",
"switchStart": "Rozpocznij przełączanie",

View File

@ -1,5 +1,4 @@
{
"continueEditing": "Kontynuuj edycję",
"create": "UTWÓRZ FRAGMENT",
"createFailed": "Nie udało się utworzyć fragmentu",
"createFrom": "UTWÓRZ Z",
@ -9,11 +8,7 @@
"deleteConfirmTitle": "Usunąłeśfragment?",
"deleteFailed": "Nie udało się usunąć fragmentu",
"deleted": "Fragment usunięty",
"discardChanges": "Odrzuć zmiany",
"discardChangesDescription": "Zmiany w wersji roboczej zostaną odrzucone, a fragment kodu powróci do ostatnio zapisanej wersji.",
"discardChangesTitle": "Odrzucić wersje robocze zmian?",
"discardDraft": "Odrzuć wersję roboczą",
"doNotSave": "Pozostaw jako wersję roboczą",
"draft": "Wersja robocza",
"dslVersionMismatchDescription": "Wykryto znaczącą różnicę w wersjach DSL. Wymuszenie importu może spowodować nieprawidłowe działanie fragmentu.",
"dslVersionMismatchQuestion": "Czy chcesz kontynuować?",
"dslVersionMismatchTitle": "Niekompatybilność wersji",
@ -21,9 +16,7 @@
"editDialogTitle": "Edytuj informacje o fragmentach",
"editDone": "Zaktualizowano informacje o fragmencie",
"editFailed": "Nie udało się zaktualizować informacji o fragmencie",
"editingDraft": "Edytujesz wersję roboczą.",
"emptyGraphSaveError": "Przed zapisaniem dodaj co najmniej jeden węzeł.",
"exitEditing": "Wyjdź z edycji",
"emptyGraphSaveError": "Przed opublikowaniem dodaj co najmniej jeden węzeł.",
"exportFailed": "Nie udało się wyeksportować fragmentu.",
"importDSLFile": "Importuj plik DSL",
"importDialogTitle": "Importuj fragment",
@ -38,14 +31,15 @@
"menu.editInfo": "Edytuj informacje",
"menu.exportSnippet": "Eksportuj fragment",
"panelTitle": "Pole wejściowe",
"publishButton": "Opublikuj",
"publishFailed": "Nie udało się opublikować fragmentu",
"save": "Zapisz",
"saveAndExit": "Zapisz i wyjdź",
"saveBeforeLeavingDescription": "Zapisz, aby udostępnić tę wersję do użycia w przepływach pracy. Możesz też na razie zachować swoje zmiany jako wersję roboczą.",
"saveBeforeLeavingTitle": "Zapisać zmiany przed opuszczeniem?",
"saveSuccess": "Fragment został zapisany",
"publishMenuCurrentDraft": "Aktualny projekt niepublikowany",
"publishSuccess": "Fragment opublikowany",
"sectionOrchestrate": "Orkiestrować",
"testRunButton": "Uruchomienie próbne",
"typeLabel": "Fragment",
"unknownUser": "Użytkownik",
"viewOnly": "Tylko przeglądaj"
"updatedBy": "{{name}} zaktualizowano {{time}}",
"usageCount": "Użyto {{count}} razy",
"variableInspect": "Kontrola zmiennej"
}

View File

@ -222,6 +222,7 @@
"studio.starFailed": "Falha ao atualizar favorito",
"studio.starred": "Favoritos",
"studio.unstarApp": "Remover app dos favoritos",
"studio.viewSnippets": "Ver snippets",
"switch": "Mudar para Orquestração de Fluxo de Trabalho",
"switchLabel": "A cópia do aplicativo a ser criada",
"switchStart": "Iniciar mudança",

View File

@ -1,5 +1,4 @@
{
"continueEditing": "Continuar editando",
"create": "CRIAR SNIPPET",
"createFailed": "Falha ao criar o snippet",
"createFrom": "CRIAR DE",
@ -9,11 +8,7 @@
"deleteConfirmTitle": "Excluir trecho?",
"deleteFailed": "Falha ao excluir o snippet",
"deleted": "Fragmento excluído",
"discardChanges": "Descartar alterações",
"discardChangesDescription": "Suas alterações no rascunho serão descartadas e o snippet retornará última versão salva.",
"discardChangesTitle": "Descartar alterações no rascunho?",
"discardDraft": "Descartar rascunho",
"doNotSave": "Sair como rascunho",
"draft": "Rascunho",
"dslVersionMismatchDescription": "Foi detectada uma diferença significativa nas versões DSL. Forçar a importação pode causar mau funcionamento do snippet.",
"dslVersionMismatchQuestion": "Você quer continuar?",
"dslVersionMismatchTitle": "Incompatibilidade de versão",
@ -21,9 +16,7 @@
"editDialogTitle": "Editar informações do snippet",
"editDone": "Informações do snippet atualizadas",
"editFailed": "Falha ao atualizar as informações do snippet",
"editingDraft": "Você está editando um rascunho.",
"emptyGraphSaveError": "Adicione pelo menos um nó antes de salvar.",
"exitEditing": "Sair da edição",
"emptyGraphSaveError": "Adicione pelo menos um nó antes de publicar.",
"exportFailed": "Falha na exportação do snippet.",
"importDSLFile": "Importar arquivo DSL",
"importDialogTitle": "Fragmento de importação",
@ -38,14 +31,15 @@
"menu.editInfo": "Editar informações",
"menu.exportSnippet": "Exportar trecho",
"panelTitle": "Campo de entrada",
"publishButton": "Publicar",
"publishFailed": "Falha ao publicar o snippet",
"save": "Salvar",
"saveAndExit": "Salvar e sair",
"saveBeforeLeavingDescription": "Salve para disponibilizar esta versão para uso em fluxos de trabalho. Ou mantenha suas edições como rascunho por enquanto.",
"saveBeforeLeavingTitle": "Salvar as alterações antes de sair?",
"saveSuccess": "Fragmento salvo",
"publishMenuCurrentDraft": "Rascunho atual não publicado",
"publishSuccess": "Trecho publicado",
"sectionOrchestrate": "Orquestrar",
"testRunButton": "Execução de teste",
"typeLabel": "Trecho",
"unknownUser": "Usuário",
"viewOnly": "Somente visualizar"
"updatedBy": "{{name}} atualizado {{time}}",
"usageCount": "Usado {{count}} vezes",
"variableInspect": "Inspeção de Variável"
}

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