mirror of
https://github.com/langgenius/dify.git
synced 2026-07-21 18:58:35 +08:00
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:
parent
d2216fe181
commit
ca2755e0c1
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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**
|
||||
|
||||
|
||||
@ -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]:
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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} />)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'
|
||||
|
||||
|
||||
107
web/app/components/main-nav/components/snippet-detail-top.tsx
Normal file
107
web/app/components/main-nav/components/snippet-detail-top.tsx
Normal 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
|
||||
@ -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>
|
||||
|
||||
@ -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([])
|
||||
|
||||
|
||||
@ -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()} />)
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
@ -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" />
|
||||
|
||||
@ -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' }))
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}))
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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),
|
||||
})
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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 }))
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
@ -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} />
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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([])
|
||||
})
|
||||
})
|
||||
24
web/app/components/snippets/draft-store/index.ts
Normal file
24
web/app/components/snippets/draft-store/index.ts
Normal 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),
|
||||
}))
|
||||
@ -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#}}`)
|
||||
})
|
||||
})
|
||||
@ -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'))
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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),
|
||||
}))
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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', () => ({
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 })
|
||||
})
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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'
|
||||
}
|
||||
|
||||
@ -222,6 +222,7 @@
|
||||
"studio.starFailed": "فشل تحديث النجمة",
|
||||
"studio.starred": "المميزة بنجمة",
|
||||
"studio.unstarApp": "إزالة النجمة من التطبيق",
|
||||
"studio.viewSnippets": "عرض المقتطفات",
|
||||
"switch": "التبديل إلى Workflow Orchestrate",
|
||||
"switchLabel": "نسخة التطبيق التي سيتم إنشاؤها",
|
||||
"switchStart": "بدء التبديل",
|
||||
|
||||
@ -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": "فحص متغير"
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -222,6 +222,7 @@
|
||||
"studio.starFailed": "بهروزرسانی ستاره ناموفق بود",
|
||||
"studio.starred": "ستارهدار",
|
||||
"studio.unstarApp": "برداشتن ستاره برنامه",
|
||||
"studio.viewSnippets": "مشاهده قطعهها",
|
||||
"switch": "تغییر به سازماندهی گردش کار",
|
||||
"switchLabel": "نسخه برنامه که ایجاد میشود",
|
||||
"switchStart": "شروع تغییر",
|
||||
|
||||
@ -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": "متغیر بازرسی"
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 l’importation peut entraîner un dysfonctionnement de l’extrait.",
|
||||
"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"
|
||||
}
|
||||
|
||||
@ -222,6 +222,7 @@
|
||||
"studio.starFailed": "स्टार अपडेट करने में विफल",
|
||||
"studio.starred": "स्टार किए गए",
|
||||
"studio.unstarApp": "ऐप से स्टार हटाएं",
|
||||
"studio.viewSnippets": "स्निपेट देखें",
|
||||
"switch": "वर्कफ़्लो ऑर्केस्ट्रेट पर स्विच करें",
|
||||
"switchLabel": "बनाई जाने वाली ऐप कॉपी",
|
||||
"switchStart": "स्विच शुरू करें",
|
||||
|
||||
@ -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": "परिवर्तनीय निरीक्षण"
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -222,6 +222,7 @@
|
||||
"studio.starFailed": "スターの更新に失敗しました",
|
||||
"studio.starred": "スター付き",
|
||||
"studio.unstarApp": "アプリのスターを外す",
|
||||
"studio.viewSnippets": "スニペットを表示",
|
||||
"switch": "ワークフロー オーケストレートに切り替える",
|
||||
"switchLabel": "作成されるアプリのコピー",
|
||||
"switchStart": "切り替えを開始する",
|
||||
|
||||
@ -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": "変数の検査"
|
||||
}
|
||||
|
||||
@ -222,6 +222,7 @@
|
||||
"studio.starFailed": "별표 업데이트 실패",
|
||||
"studio.starred": "별표 표시됨",
|
||||
"studio.unstarApp": "앱 별표 해제",
|
||||
"studio.viewSnippets": "스니펫 보기",
|
||||
"switch": "워크플로우 오케스트레이션으로 전환하기",
|
||||
"switchLabel": "생성될 앱의 복사본",
|
||||
"switchStart": "전환 시작하기",
|
||||
|
||||
@ -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": "변수 검사"
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user