diff --git a/api/controllers/console/snippets/snippet_workflow.py b/api/controllers/console/snippets/snippet_workflow.py index 0b8dc264a68..f9404e55cb0 100644 --- a/api/controllers/console/snippets/snippet_workflow.py +++ b/api/controllers/console/snippets/snippet_workflow.py @@ -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//workflows/") +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//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. diff --git a/api/controllers/console/snippets/snippet_workflow_draft_variable.py b/api/controllers/console/snippets/snippet_workflow_draft_variable.py index 4befd259666..a28ba07b5dd 100644 --- a/api/controllers/console/snippets/snippet_workflow_draft_variable.py +++ b/api/controllers/console/snippets/snippet_workflow_draft_variable.py @@ -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) diff --git a/api/controllers/console/tag/tags.py b/api/controllers/console/tag/tags.py index 38e7395ccf8..1af5b113f1d 100644 --- a/api/controllers/console/tag/tags.py +++ b/api/controllers/console/tag/tags.py @@ -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, diff --git a/api/controllers/console/workspace/snippets.py b/api/controllers/console/workspace/snippets.py index e8f0b228c8b..e0987a3cae1 100644 --- a/api/controllers/console/workspace/snippets.py +++ b/api/controllers/console/workspace/snippets.py @@ -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.""" diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 2c86c4f70b1..86be3a5dc73 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -8700,6 +8700,32 @@ Reset a draft workflow variable to its default value (snippet scope) | 200 | Workflow published successfully | **application/json**: [WorkflowPublishResponse](#workflowpublishresponse)
| | 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)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow updated successfully | **application/json**: [SnippetWorkflowResponse](#snippetworkflowresponse)
| +| 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** diff --git a/api/services/snippet_service.py b/api/services/snippet_service.py index 75282db9d4c..a54c9f6a069 100644 --- a/api/services/snippet_service.py +++ b/api/services/snippet_service.py @@ -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]: diff --git a/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow.py b/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow.py index b20dd3e30a7..8785ce85109 100644 --- a/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow.py +++ b/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow.py @@ -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( diff --git a/api/tests/unit_tests/controllers/console/tag/test_tags.py b/api/tests/unit_tests/controllers/console/tag/test_tags.py index 84a70835437..8f5bb176b8e 100644 --- a/api/tests/unit_tests/controllers/console/tag/test_tags.py +++ b/api/tests/unit_tests/controllers/console/tag/test_tags.py @@ -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) diff --git a/api/tests/unit_tests/services/test_snippet_service.py b/api/tests/unit_tests/services/test_snippet_service.py index 16e1e58baae..464f2313a6a 100644 --- a/api/tests/unit_tests/services/test_snippet_service.py +++ b/api/tests/unit_tests/services/test_snippet_service.py @@ -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)) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index d0d06b284d2..eb99f899a63 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -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 diff --git a/packages/contracts/generated/api/console/snippets/orpc.gen.ts b/packages/contracts/generated/api/console/snippets/orpc.gen.ts index 29c8c3c6ee5..7e79e024498 100644 --- a/packages/contracts/generated/api/console/snippets/orpc.gen.ts +++ b/packages/contracts/generated/api/console/snippets/orpc.gen.ts @@ -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, } diff --git a/packages/contracts/generated/api/console/snippets/types.gen.ts b/packages/contracts/generated/api/console/snippets/types.gen.ts index 631da7bace8..8e4279acb23 100644 --- a/packages/contracts/generated/api/console/snippets/types.gen.ts +++ b/packages/contracts/generated/api/console/snippets/types.gen.ts @@ -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: { diff --git a/packages/contracts/generated/api/console/snippets/zod.gen.ts b/packages/contracts/generated/api/console/snippets/zod.gen.ts index 85e5b961547..b8386c8a03b 100644 --- a/packages/contracts/generated/api/console/snippets/zod.gen.ts +++ b/packages/contracts/generated/api/console/snippets/zod.gen.ts @@ -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(), diff --git a/web/app/components/app-sidebar/snippet-info/__tests__/dropdown.spec.tsx b/web/app/components/app-sidebar/snippet-info/__tests__/dropdown.spec.tsx index f72100e9a8f..4b3849cc8bb 100644 --- a/web/app/components/app-sidebar/snippet-info/__tests__/dropdown.spec.tsx +++ b/web/app/components/app-sidebar/snippet-info/__tests__/dropdown.spec.tsx @@ -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() @@ -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() diff --git a/web/app/components/app-sidebar/snippet-info/dropdown.tsx b/web/app/components/app-sidebar/snippet-info/dropdown.tsx index 00400019f92..a830ab09ac5 100644 --- a/web/app/components/app-sidebar/snippet-info/dropdown.tsx +++ b/web/app/components/app-sidebar/snippet-info/dropdown.tsx @@ -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 && ( - - - {t('menu.editInfo')} - - )} - {canManageSnippet && ( <> + + + {t('menu.editInfo')} + {t('menu.exportSnippet')} - + + )} + {canManageSnippet && ( + <> + {canCreateAndModifySnippet && } { 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', () => { diff --git a/web/app/components/apps/app-list-header-filters.tsx b/web/app/components/apps/app-list-header-filters.tsx index ceccb7575f6..df32f57d7ba 100644 --- a/web/app/components/apps/app-list-header-filters.tsx +++ b/web/app/components/apps/app-list-header-filters.tsx @@ -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({ />
+ + {t('studio.viewSnippets', { ns: 'app' })} + {showCreateButton && ( ({ +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 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 + }) => ( +
+ {snippet.name} + {fields.map(field => field.variable).join(',')} + +
+ ), +})) + +vi.mock('../components/snippet-detail-top', () => ({ + default: ({ expand, onToggle }: { expand: boolean, onToggle: () => void }) => ( +
+ +
+ ), +})) + vi.mock('@/context/i18n', () => ({ useLocale: () => 'en-US', useDocLink: () => (path: string) => `https://docs.dify.ai${path}`, @@ -244,6 +297,24 @@ const createInstalledApp = (overrides: Partial = {}): 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' diff --git a/web/app/components/main-nav/components/snippet-detail-top.tsx b/web/app/components/main-nav/components/snippet-detail-top.tsx new file mode 100644 index 00000000000..2e44894d801 --- /dev/null +++ b/web/app/components/main-nav/components/snippet-detail-top.tsx @@ -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 ( +
+ {onToggle && ( + } + 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" + /> + )} +
+ ) + } + + return ( +
+
+
+ + + + +
+ + / + + + {t('tabs.snippets', { ns: 'workflow' })} + +
+ + setGotoAnythingOpen(true)} + > + + + )} + /> + + {t('gotoAnything.quickAction', { ns: 'app' })} + + {SEARCH_SHORTCUT.map(key => ( + {formatForDisplay(key)} + ))} + + + + {onToggle && ( + } + 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" + /> + )} +
+ ) +} + +export default SnippetDetailTop diff --git a/web/app/components/main-nav/index.tsx b/web/app/components/main-nav/index.tsx index 4dcb09b835d..9caa2900bb2 100644 --- a/web/app/components/main-nav/index.tsx +++ b/web/app/components/main-nav/index.tsx @@ -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 | 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} /> ) - : ( - - ) - : showSnippetDetailBottomNavigation - ? null - : ( - <> -
- {renderLogo()} - -
-
- -
- - )} + : showDeploymentDetailNavigation + ? ( + + ) + : ( + + ) + : ( + <> +
+ {renderLogo()} + +
+
+ +
+ + )} {showDetailNavigation ? showAppDetailNavigation ? @@ -293,29 +305,40 @@ export function MainNav({ ? : showAgentDetailNavigation ? - : - : showSnippetDetailBottomNavigation - ? null - : ( - <> - - {!isCurrentWorkspaceDatasetOperator && } - - )} - {showEnvTag && !showSnippetDetailBottomNavigation && detailNavigationVisibleExpanded && ( + ) + : null + : + : ( + <> + + {!isCurrentWorkspaceDatasetOperator && } + + )} + {showEnvTag && detailNavigationVisibleExpanded && (
diff --git a/web/app/components/snippet-list/__tests__/index.spec.tsx b/web/app/components/snippet-list/__tests__/index.spec.tsx index dca361764eb..867c0de017e 100644 --- a/web/app/components/snippet-list/__tests__/index.spec.tsx +++ b/web/app/components/snippet-list/__tests__/index.spec.tsx @@ -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([]) diff --git a/web/app/components/snippet-list/components/__tests__/snippet-card.spec.tsx b/web/app/components/snippet-list/components/__tests__/snippet-card.spec.tsx index 24a4ba5751d..cd9b0d26380 100644 --- a/web/app/components/snippet-list/components/__tests__/snippet-card.spec.tsx +++ b/web/app/components/snippet-list/components/__tests__/snippet-card.spec.tsx @@ -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() @@ -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() diff --git a/web/app/components/snippet-list/components/__tests__/snippet-publish-status-filter.spec.tsx b/web/app/components/snippet-list/components/__tests__/snippet-publish-status-filter.spec.tsx new file mode 100644 index 00000000000..c9ffbf9df0f --- /dev/null +++ b/web/app/components/snippet-list/components/__tests__/snippet-publish-status-filter.spec.tsx @@ -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() + + 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() + + 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() + + expect(screen.getByRole('button', { name: /snippet\.draft/i })).toBeInTheDocument() + }) +}) diff --git a/web/app/components/snippet-list/components/snippet-card.tsx b/web/app/components/snippet-list/components/snippet-card.tsx index 2707016fe7d..c40271be75d 100644 --- a/web/app/components/snippet-list/components/snippet-card.tsx +++ b/web/app/components/snippet-list/components/snippet-card.tsx @@ -82,7 +82,7 @@ const SnippetCard = ({ } const handleExportSnippet = async () => { - if (!canManageSnippet) + if (!canCreateAndModifySnippet) return setIsOperationsMenuOpen(false) @@ -155,13 +155,7 @@ const SnippetCard = ({
-
{ - e.stopPropagation() - e.preventDefault() - }} - > +
{canCreateAndModifySnippet && ( - - {t('menu.editInfo')} - - )} - {canManageSnippet && ( <> + + {t('menu.editInfo')} + {t('menu.exportSnippet')} - + + )} + {canManageSnippet && ( + <> + {canCreateAndModifySnippet && } 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 ( + + + )} + > + {triggerLabel} + + + + isSnippetPublishStatus(nextValue) && onChange(nextValue)}> + {options.map(option => ( + + {option.text} + + + ))} + + + + ) +} + +export default SnippetPublishStatusFilter diff --git a/web/app/components/snippet-list/index.tsx b/web/app/components/snippet-list/index.tsx index e62216a7c4a..8437a02a923 100644 --- a/web/app/components/snippet-list/index.tsx +++ b/web/app/components/snippet-list/index.tsx @@ -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(null) const anchorRef = useRef(null) const [showTagManagementModal, setShowTagManagementModal] = useState(false) + const [publishStatus, setPublishStatus] = useState('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} /> + setShowTagManagementModal(true)} />
diff --git a/web/app/components/snippets/components/__tests__/save-before-leaving-dialog.spec.tsx b/web/app/components/snippets/components/__tests__/save-before-leaving-dialog.spec.tsx deleted file mode 100644 index fcff9180549..00000000000 --- a/web/app/components/snippets/components/__tests__/save-before-leaving-dialog.spec.tsx +++ /dev/null @@ -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( - leave snippet} - 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( - , - ) - - expect(screen.getByRole('button', { name: 'snippet.continueEditing' })).toBeDisabled() - expect(screen.getByRole('button', { name: 'snippet.doNotSave' })).toBeDisabled() - expectLoadingButton(screen.getByRole('button', { name: 'snippet.saveAndExit' })) - }) -}) diff --git a/web/app/components/snippets/components/__tests__/snippet-children.spec.tsx b/web/app/components/snippets/components/__tests__/snippet-children.spec.tsx index f4ed414ace1..acdf92eed2c 100644 --- a/web/app/components/snippets/components/__tests__/snippet-children.spec.tsx +++ b/web/app/components/snippets/components/__tests__/snippet-children.spec.tsx @@ -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( , @@ -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, })) diff --git a/web/app/components/snippets/components/__tests__/snippet-main.spec.tsx b/web/app/components/snippets/components/__tests__/snippet-main.spec.tsx index 5be724ce257..5d79147507a 100644 --- a/web/app/components/snippets/components/__tests__/snippet-main.spec.tsx +++ b/web/app/components/snippets/components/__tests__/snippet-main.spec.tsx @@ -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: (selector: (state: { workspacePermissionKeys: string[] }) => T): T => selector({ - workspacePermissionKeys: mockWorkspacePermissionKeys.value, + workspacePermissionKeys: mockAppContext.workspacePermissionKeys, }), })) + let capturedHooksStore: Record | 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 }) => (
- {!isEditing && canEdit && } snippets list - - - -
- ), -})) - -vi.mock('@/app/components/snippets/components/snippet-sidebar', () => ({ - default: ({ - fields, - onFieldsChange, - }: { - fields: SnippetInputField[] - onFieldsChange: (fields: SnippetInputField[]) => void - }) => ( -
- - + {canEdit && }
), })) @@ -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) 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) + 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) 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) - 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) 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) - 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() diff --git a/web/app/components/snippets/components/__tests__/snippet-sidebar.spec.tsx b/web/app/components/snippets/components/__tests__/snippet-sidebar.spec.tsx index d7163d225e8..422dfedb89b 100644 --- a/web/app/components/snippets/components/__tests__/snippet-sidebar.spec.tsx +++ b/web/app/components/snippets/components/__tests__/snippet-sidebar.spec.tsx @@ -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 - }) => ( - {children} - ), -})) - 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( - { it('should forward changed VarList updates', () => { const onFieldsChange = vi.fn() render( - { it('should hide input field operations when readonly', () => { render( - { />, ) - 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( - { it('should reject duplicate variable and label updates', () => { const onFieldsChange = vi.fn() render( - ({ 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 => ({ type: PipelineInputVarType.textInput, label: 'Blog URL', @@ -32,19 +23,13 @@ const createField = (overrides: Partial = {}): 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), }) diff --git a/web/app/components/snippets/components/hooks/__tests__/use-snippet-publish.spec.ts b/web/app/components/snippets/components/hooks/__tests__/use-snippet-publish.spec.ts index 37a06160434..4dcd8a60c71 100644 --- a/web/app/components/snippets/components/hooks/__tests__/use-snippet-publish.spec.ts +++ b/web/app/components/snippets/components/hooks/__tests__/use-snippet-publish.spec.ts @@ -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 () => { diff --git a/web/app/components/snippets/components/hooks/use-snippet-input-field-actions.ts b/web/app/components/snippets/components/hooks/use-snippet-input-field-actions.ts index 862cb3cfc29..853b96663c1 100644 --- a/web/app/components/snippets/components/hooks/use-snippet-input-field-actions.ts +++ b/web/app/components/snippets/components/hooks/use-snippet-input-field-actions.ts @@ -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, } } diff --git a/web/app/components/snippets/components/hooks/use-snippet-publish.ts b/web/app/components/snippets/components/hooks/use-snippet-publish.ts index 4096ca74fbe..8c7f6a40468 100644 --- a/web/app/components/snippets/components/hooks/use-snippet-publish.ts +++ b/web/app/components/snippets/components/hooks/use-snippet-publish.ts @@ -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) { diff --git a/web/app/components/snippets/components/save-before-leaving-dialog.tsx b/web/app/components/snippets/components/save-before-leaving-dialog.tsx deleted file mode 100644 index 6a0da78ef8f..00000000000 --- a/web/app/components/snippets/components/save-before-leaving-dialog.tsx +++ /dev/null @@ -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 - onSave: () => void | Promise -} - -const SaveBeforeLeavingDialog = ({ - open, - onOpenChange, - trigger, - disabled, - saveDisabled, - loading, - onDiscard, - onSave, -}: SaveBeforeLeavingDialogProps) => { - const { t } = useTranslation('snippet') - - return ( - - {trigger && ( - - )} - -
- - {t('saveBeforeLeavingTitle')} - - - {t('saveBeforeLeavingDescription')} - -
- - - {t('continueEditing')} - - - {t('doNotSave')} - - - {t('saveAndExit')} - - -
-
- ) -} - -export default SaveBeforeLeavingDialog diff --git a/web/app/components/snippets/components/snippet-children.tsx b/web/app/components/snippets/components/snippet-children.tsx index 8ce66be3053..4c2e2db3545 100644 --- a/web/app/components/snippets/components/snippet-children.tsx +++ b/web/app/components/snippets/components/snippet-children.tsx @@ -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 - onExitEditingWithoutSave: () => void | Promise onPublish: () => void - onSaveAndExitEditing: () => void | Promise } 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 = ({ + + + ) +} diff --git a/web/app/components/snippets/components/snippet-header/__tests__/cancel-changes.spec.tsx b/web/app/components/snippets/components/snippet-header/__tests__/cancel-changes.spec.tsx deleted file mode 100644 index 520354e6e2c..00000000000 --- a/web/app/components/snippets/components/snippet-header/__tests__/cancel-changes.spec.tsx +++ /dev/null @@ -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() - - 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() - - 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) - }) - }) -}) diff --git a/web/app/components/snippets/components/snippet-header/__tests__/index.spec.tsx b/web/app/components/snippets/components/snippet-header/__tests__/index.spec.tsx index b099d095267..7a1572665de 100644 --- a/web/app/components/snippets/components/snippet-header/__tests__/index.spec.tsx +++ b/web/app/components/snippets/components/snippet-header/__tests__/index.spec.tsx @@ -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 }) =>
{children}
, - AlertDialogActions: ({ children }: { children: ReactNode }) =>
{children}
, - AlertDialogCancelButton: ({ children }: { children: ReactNode }) => , - AlertDialogConfirmButton: ({ - children, - disabled, - onClick, - }: { - children: ReactNode - disabled?: boolean - onClick?: () => void - }) => , - AlertDialogContent: ({ children }: { children: ReactNode }) =>
{children}
, - AlertDialogDescription: ({ children }: { children: ReactNode }) =>
{children}
, - AlertDialogTitle: ({ children }: { children: ReactNode }) =>
{children}
, - AlertDialogTrigger: ({ children, render }: { children?: ReactNode, render?: ReactNode }) => render ?? , -})) - 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( - , - ) + it('should configure workflow header slots and hide workflow-only controls', () => { + render( + , + ) - 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( - , - ) + it('should publish from the primary header action', () => { + render( + , + ) - 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( - , - ) + it('should disable publish when the current graph has no nodes', () => { + render( + , + ) - 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( - , - ) + it('should show publish loading state while publishing', () => { + render( + , + ) - expect(screen.queryByText('snippet.discardDraft')).not.toBeInTheDocument() - expect(screen.getByText('snippet.editingDraft')).toBeInTheDocument() - }) - - it('should enter editing mode from the readonly header action', () => { - render( - , - ) - - fireEvent.click(screen.getByRole('button', { name: 'snippet.edit' })) - - expect(mockEdit).toHaveBeenCalledTimes(1) - }) - - it('should exit editing immediately when there are no draft changes', () => { - render( - , - ) - - 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( - , - ) - - 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( - , - ) - - 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( - , - ) - - 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 })) }) }) diff --git a/web/app/components/snippets/components/snippet-header/cancel-changes.tsx b/web/app/components/snippets/components/snippet-header/cancel-changes.tsx deleted file mode 100644 index 9e749cf967c..00000000000 --- a/web/app/components/snippets/components/snippet-header/cancel-changes.tsx +++ /dev/null @@ -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 -} - -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 ( -
- {canDiscardChanges && ( - <> - - - {t('discardDraft')} - - -
- - {t('discardChangesTitle')} - - - {t('discardChangesDescription')} - -
- - - {t('continueEditing')} - - - {t('discardChanges')} - - -
-
- · - - )} - {t('editingDraft')} -
- ) -} - -export default memo(CancelChanges) diff --git a/web/app/components/snippets/components/snippet-header/index.tsx b/web/app/components/snippets/components/snippet-header/index.tsx index b2d433d7197..95517eb58bc 100644 --- a/web/app/components/snippets/components/snippet-header/index.tsx +++ b/web/app/components/snippets/components/snippet-header/index.tsx @@ -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 - onExitEditingWithoutSave: () => void | Promise onPublish: () => void - onSaveAndExitEditing: () => void | Promise } -const ViewOnlyBadge = () => { - const { t } = useTranslation('snippet') - - return ( -
- {t('viewOnly')} -
- ) -} - -const EditActions = ({ - canEdit = true, +const PublishAction = ({ canSave, - hasDraftChanges, - isEditing, isPublishing, - onEdit, - onExitEditing, - onExitEditingWithoutSave, onPublish, - onSaveAndExitEditing, -}: Pick) => { +}: Pick) => { const { t } = useTranslation('snippet') - const [exitConfirmOpen, setExitConfirmOpen] = useState(false) - - if (!isEditing) { - if (!canEdit) - return null - - return ( - - ) - } return ( - <> - { - if (!canEdit) - return - - if (!hasDraftChanges) { - event.preventDefault() - void onExitEditing() - return - } - - setExitConfirmOpen(true) - }} - > - {t('exitEditing')} - - )} - disabled={isPublishing || !canEdit} - saveDisabled={!canEdit || !canSave} - loading={isPublishing} - onDiscard={async () => { - await onExitEditingWithoutSave() - setExitConfirmOpen(false) - }} - onSave={async () => { - await onSaveAndExitEditing() - setExitConfirmOpen(false) - }} - /> - - + ) } 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 ? : <>) - : , left: ( - + canEdit + ? ( + + ) + : 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
} diff --git a/web/app/components/snippets/components/snippet-main.tsx b/web/app/components/snippets/components/snippet-main.tsx index 9f9ecab698a..c712d3b35cd 100644 --- a/web/app/components/snippets/components/snippet-main.tsx +++ b/web/app/components/snippets/components/snippet-main.tsx @@ -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 | void> - onCancel: () => void | Promise - onDiscardRoute: () => void | Promise - onEdit: () => void - onExitEditing: () => void | Promise - onExitEditingWithoutSave: () => void | Promise onSaved: (syncedDraftPayload?: Omit | void) => void - onSavedAndExitEditing: () => void } const unsupportedSnippetBlockTypes = new Set([ @@ -94,23 +82,12 @@ const hasSnippetDraftNodes = (payload?: Omit | 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() 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 ( - <> - - !open && setPendingHref(undefined)} - disabled={isPublishing} - saveDisabled={!canSave} - loading={isPublishing} - onDiscard={handleDiscardAndRoute} - onSave={handleSaveAndRoute} - /> - + ) } const SnippetMain = ({ - payload, draftPayload, - hasInitialDraftChanges, - hasPublishedWorkflow, snippetId, - nodes, - edges, - viewport, draftNodes, draftEdges, draftViewport, }: SnippetMainProps) => { - const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys) - const canCreateAndModifySnippet = canCreateAndModifySnippets(workspacePermissionKeys) - const [isEditingState, setIsEditingState] = useState(!hasPublishedWorkflow) - const isEditing = canCreateAndModifySnippet && isEditingState const [localDraftState, setLocalDraftState] = useState() - 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 ) => { - 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 | 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 (
-
} > 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} />
diff --git a/web/app/components/snippets/components/snippet-placeholder-icon.tsx b/web/app/components/snippets/components/snippet-placeholder-icon.tsx new file mode 100644 index 00000000000..982ba688e80 --- /dev/null +++ b/web/app/components/snippets/components/snippet-placeholder-icon.tsx @@ -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 ( + + ) +} diff --git a/web/app/components/snippets/components/snippet-sidebar.tsx b/web/app/components/snippets/components/snippet-sidebar.tsx index 1b70f765598..a2090979d9b 100644 --- a/web/app/components/snippets/components/snippet-sidebar.tsx +++ b/web/app/components/snippets/components/snippet-sidebar.tsx @@ -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 ( - +
) } - -export default memo(SnippetSidebar) diff --git a/web/app/components/snippets/draft-store/__tests__/index.spec.ts b/web/app/components/snippets/draft-store/__tests__/index.spec.ts new file mode 100644 index 00000000000..d50067f2033 --- /dev/null +++ b/web/app/components/snippets/draft-store/__tests__/index.spec.ts @@ -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([]) + }) +}) diff --git a/web/app/components/snippets/draft-store/index.ts b/web/app/components/snippets/draft-store/index.ts new file mode 100644 index 00000000000..2cc9081b016 --- /dev/null +++ b/web/app/components/snippets/draft-store/index.ts @@ -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(set => ({ + ...initialState, + hydrateDraft: ({ snippetId, inputFields }) => set({ snippetId, inputFields }), + setInputFields: inputFields => set({ inputFields }), + reset: () => set(initialState), +})) diff --git a/web/app/components/snippets/hooks/__tests__/use-create-snippet-from-selection.spec.tsx b/web/app/components/snippets/hooks/__tests__/use-create-snippet-from-selection.spec.tsx new file mode 100644 index 00000000000..ddf48659807 --- /dev/null +++ b/web/app/components/snippets/hooks/__tests__/use-create-snippet-from-selection.spec.tsx @@ -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, +): 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).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 | 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).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 + 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).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#}}`) + }) +}) diff --git a/web/app/components/snippets/hooks/__tests__/use-nodes-sync-draft.spec.ts b/web/app/components/snippets/hooks/__tests__/use-nodes-sync-draft.spec.ts index ee5a2663134..dc955e7f6cb 100644 --- a/web/app/components/snippets/hooks/__tests__/use-nodes-sync-draft.spec.ts +++ b/web/app/components/snippets/hooks/__tests__/use-nodes-sync-draft.spec.ts @@ -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')) diff --git a/web/app/components/snippets/hooks/__tests__/use-snippet-refresh-draft.spec.ts b/web/app/components/snippets/hooks/__tests__/use-snippet-refresh-draft.spec.ts index c85bfe3bc5d..eb500839efb 100644 --- a/web/app/components/snippets/hooks/__tests__/use-snippet-refresh-draft.spec.ts +++ b/web/app/components/snippets/hooks/__tests__/use-snippet-refresh-draft.spec.ts @@ -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) diff --git a/web/app/components/snippets/hooks/__tests__/use-snippet-start-run.spec.ts b/web/app/components/snippets/hooks/__tests__/use-snippet-start-run.spec.ts index 58edaccbbfb..cc8cbb9f536 100644 --- a/web/app/components/snippets/hooks/__tests__/use-snippet-start-run.spec.ts +++ b/web/app/components/snippets/hooks/__tests__/use-snippet-start-run.spec.ts @@ -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: { diff --git a/web/app/components/snippets/hooks/use-create-snippet-from-selection.tsx b/web/app/components/snippets/hooks/use-create-snippet-from-selection.tsx new file mode 100644 index 00000000000..31ed8a5f379 --- /dev/null +++ b/web/app/components/snippets/hooks/use-create-snippet-from-selection.tsx @@ -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 => { + 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, +) => { + 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, +) => { + 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, +) => { + const selectors: ValueSelector[] = [] + nodes.forEach(node => collectVariableSelectors(node.data, selectors)) + + const usedVariables = new Set() + const fieldBySelector = new Map() + + 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, + 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() + const [selectedSnippetInputFields, setSelectedSnippetInputFields] = useState([]) + 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 = ( + + ) + + return { + createSnippetDialog, + handleOpenCreateSnippet, + isCreateSnippetDialogOpen, + } +} diff --git a/web/app/components/snippets/hooks/use-nodes-sync-draft.ts b/web/app/components/snippets/hooks/use-nodes-sync-draft.ts index e38a0539aa6..3cdab0254b2 100644 --- a/web/app/components/snippets/hooks/use-nodes-sync-draft.ts +++ b/web/app/components/snippets/hooks/use-nodes-sync-draft.ts @@ -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, } }, []) diff --git a/web/app/components/snippets/hooks/use-snippet-refresh-draft.ts b/web/app/components/snippets/hooks/use-snippet-refresh-draft.ts index 12174e731ea..e310c919fba 100644 --- a/web/app/components/snippets/hooks/use-snippet-refresh-draft.ts +++ b/web/app/components/snippets/hooks/use-snippet-refresh-draft.ts @@ -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) diff --git a/web/app/components/snippets/hooks/use-snippet-start-run.ts b/web/app/components/snippets/hooks/use-snippet-start-run.ts index 9657391bf2a..3e7ec5f4ac2 100644 --- a/web/app/components/snippets/hooks/use-snippet-start-run.ts +++ b/web/app/components/snippets/hooks/use-snippet-start-run.ts @@ -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) diff --git a/web/app/components/snippets/store/__tests__/index.spec.ts b/web/app/components/snippets/store/__tests__/index.spec.ts index 8117db552b7..aa7611bae2a 100644 --- a/web/app/components/snippets/store/__tests__/index.spec.ts +++ b/web/app/components/snippets/store/__tests__/index.spec.ts @@ -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, + }) }) }) diff --git a/web/app/components/snippets/store/index.ts b/web/app/components/snippets/store/index.ts index db8b41efdba..09ec732aae1 100644 --- a/web/app/components/snippets/store/index.ts +++ b/web/app/components/snippets/store/index.ts @@ -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(set => ({ ...initialState, - setFields: fields => set({ fields }), + setNavigationState: state => set(state), reset: () => set(initialState), })) diff --git a/web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx index 37ee2cf0b30..f22601f82aa 100644 --- a/web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx +++ b/web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx @@ -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) diff --git a/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx index b58fa5efc51..3ae8851a23f 100644 --- a/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx +++ b/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx @@ -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: (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('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 ?
: null + }, +})) vi.mock('../hooks', async () => { const actual = await vi.importActual('../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 () => { diff --git a/web/app/components/workflow/block-selector/__tests__/main.spec.tsx b/web/app/components/workflow/block-selector/__tests__/main.spec.tsx index decb090ab25..b300c1b1cf0 100644 --- a/web/app/components/workflow/block-selector/__tests__/main.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/main.spec.tsx @@ -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() diff --git a/web/app/components/workflow/block-selector/main.tsx b/web/app/components/workflow/block-selector/main.tsx index 51f49d46b20..865c4c52a53 100644 --- a/web/app/components/workflow/block-selector/main.tsx +++ b/web/app/components/workflow/block-selector/main.tsx @@ -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, diff --git a/web/app/components/workflow/hooks/__tests__/use-nodes-available-var-list.spec.ts b/web/app/components/workflow/hooks/__tests__/use-nodes-available-var-list.spec.ts index 27edcbd1633..53c218d9b1e 100644 --- a/web/app/components/workflow/hooks/__tests__/use-nodes-available-var-list.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-nodes-available-var-list.spec.ts @@ -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', diff --git a/web/app/components/workflow/hooks/use-nodes-available-var-list.ts b/web/app/components/workflow/hooks/use-nodes-available-var-list.ts index 927096698d7..6b16c920bac 100644 --- a/web/app/components/workflow/hooks/use-nodes-available-var-list.ts +++ b/web/app/components/workflow/hooks/use-nodes-available-var-list.ts @@ -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() diff --git a/web/app/components/workflow/nodes/_base/hooks/__tests__/snippet-input-field-vars.spec.ts b/web/app/components/workflow/nodes/_base/hooks/__tests__/snippet-input-field-vars.spec.ts new file mode 100644 index 00000000000..6ba689b1843 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/hooks/__tests__/snippet-input-field-vars.spec.ts @@ -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: [], + }) + }) +}) diff --git a/web/app/components/workflow/nodes/_base/hooks/__tests__/use-available-var-list.spec.ts b/web/app/components/workflow/nodes/_base/hooks/__tests__/use-available-var-list.spec.ts index cd88d29128b..58abbbcafe2 100644 --- a/web/app/components/workflow/nodes/_base/hooks/__tests__/use-available-var-list.spec.ts +++ b/web/app/components/workflow/nodes/_base/hooks/__tests__/use-available-var-list.spec.ts @@ -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', () => ({ diff --git a/web/app/components/workflow/nodes/_base/hooks/snippet-input-field-vars.ts b/web/app/components/workflow/nodes/_base/hooks/snippet-input-field-vars.ts index 67a0703c0be..86cd5b49f94 100644 --- a/web/app/components/workflow/nodes/_base/hooks/snippet-input-field-vars.ts +++ b/web/app/components/workflow/nodes/_base/hooks/snippet-input-field-vars.ts @@ -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 { diff --git a/web/app/components/workflow/nodes/_base/hooks/use-available-var-list.ts b/web/app/components/workflow/nodes/_base/hooks/use-available-var-list.ts index 40b2b664ef8..02aaee0d6ae 100644 --- a/web/app/components/workflow/nodes/_base/hooks/use-available-var-list.ts +++ b/web/app/components/workflow/nodes/_base/hooks/use-available-var-list.ts @@ -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() diff --git a/web/app/components/workflow/panel-contextmenu.tsx b/web/app/components/workflow/panel-contextmenu.tsx index f4f37c17fbf..f2ac9d07623 100644 --- a/web/app/components/workflow/panel-contextmenu.tsx +++ b/web/app/components/workflow/panel-contextmenu.tsx @@ -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' })} - setShowImportDSLModal(true)} - > - {t('importApp', { ns: 'app' })} - + {!shouldHideImportApp && ( + setShowImportDSLModal(true)} + > + {t('importApp', { ns: 'app' })} + + )} )} diff --git a/web/app/components/workflow/selection-contextmenu.tsx b/web/app/components/workflow/selection-contextmenu.tsx index 0478b0e12f5..dffbd31a5e1 100644 --- a/web/app/components/workflow/selection-contextmenu.tsx +++ b/web/app/components/workflow/selection-contextmenu.tsx @@ -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() @@ -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 ( <> + {canCreateSnippet && ( + <> + + + {t('snippet.createDialogTitle', { defaultValue: 'Create Snippet', ns: 'workflow' })} + + + + + )} ))} + {createSnippetDialog} ) } diff --git a/web/features/tag-management/__tests__/tag-management-modal.spec.tsx b/web/features/tag-management/__tests__/tag-management-modal.spec.tsx index 01c58082401..ee67a561365 100644 --- a/web/features/tag-management/__tests__/tag-management-modal.spec.tsx +++ b/web/features/tag-management/__tests__/tag-management-modal.spec.tsx @@ -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 }) }) diff --git a/web/features/tag-management/__tests__/tag-search-content.spec.tsx b/web/features/tag-management/__tests__/tag-search-content.spec.tsx index 61f5d17be8d..43679367605 100644 --- a/web/features/tag-management/__tests__/tag-search-content.spec.tsx +++ b/web/features/tag-management/__tests__/tag-search-content.spec.tsx @@ -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( { }) 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( diff --git a/web/features/tag-management/utils.ts b/web/features/tag-management/utils.ts index bc88569ade9..08c7e22a8f9 100644 --- a/web/features/tag-management/utils.ts +++ b/web/features/tag-management/utils.ts @@ -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' } diff --git a/web/i18n/ar-TN/app.json b/web/i18n/ar-TN/app.json index 2f776754461..0ca6f5d85f2 100644 --- a/web/i18n/ar-TN/app.json +++ b/web/i18n/ar-TN/app.json @@ -222,6 +222,7 @@ "studio.starFailed": "فشل تحديث النجمة", "studio.starred": "المميزة بنجمة", "studio.unstarApp": "إزالة النجمة من التطبيق", + "studio.viewSnippets": "عرض المقتطفات", "switch": "التبديل إلى Workflow Orchestrate", "switchLabel": "نسخة التطبيق التي سيتم إنشاؤها", "switchStart": "بدء التبديل", diff --git a/web/i18n/ar-TN/snippet.json b/web/i18n/ar-TN/snippet.json index b659bd66fd6..2c86346c464 100644 --- a/web/i18n/ar-TN/snippet.json +++ b/web/i18n/ar-TN/snippet.json @@ -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": "فحص متغير" } diff --git a/web/i18n/de-DE/app.json b/web/i18n/de-DE/app.json index d95be1f0699..bf345218f97 100644 --- a/web/i18n/de-DE/app.json +++ b/web/i18n/de-DE/app.json @@ -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", diff --git a/web/i18n/de-DE/snippet.json b/web/i18n/de-DE/snippet.json index 23fb5548b22..c662f04915a 100644 --- a/web/i18n/de-DE/snippet.json +++ b/web/i18n/de-DE/snippet.json @@ -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" } diff --git a/web/i18n/en-US/app.json b/web/i18n/en-US/app.json index 3ffae71bb6e..dd3cc7648c7 100644 --- a/web/i18n/en-US/app.json +++ b/web/i18n/en-US/app.json @@ -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", diff --git a/web/i18n/en-US/snippet.json b/web/i18n/en-US/snippet.json index 42cf657eb8d..d960dfa3538 100644 --- a/web/i18n/en-US/snippet.json +++ b/web/i18n/en-US/snippet.json @@ -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" } diff --git a/web/i18n/es-ES/app.json b/web/i18n/es-ES/app.json index 8ba47249b3a..3d2cd58d41f 100644 --- a/web/i18n/es-ES/app.json +++ b/web/i18n/es-ES/app.json @@ -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", diff --git a/web/i18n/es-ES/snippet.json b/web/i18n/es-ES/snippet.json index cd3317c9921..29e81758b87 100644 --- a/web/i18n/es-ES/snippet.json +++ b/web/i18n/es-ES/snippet.json @@ -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" } diff --git a/web/i18n/fa-IR/app.json b/web/i18n/fa-IR/app.json index 1746041b174..7f21e2f3352 100644 --- a/web/i18n/fa-IR/app.json +++ b/web/i18n/fa-IR/app.json @@ -222,6 +222,7 @@ "studio.starFailed": "به‌روزرسانی ستاره ناموفق بود", "studio.starred": "ستاره‌دار", "studio.unstarApp": "برداشتن ستاره برنامه", + "studio.viewSnippets": "مشاهده قطعه‌ها", "switch": "تغییر به سازماندهی گردش کار", "switchLabel": "نسخه برنامه که ایجاد می‌شود", "switchStart": "شروع تغییر", diff --git a/web/i18n/fa-IR/snippet.json b/web/i18n/fa-IR/snippet.json index 1339716b924..4cedf1229e9 100644 --- a/web/i18n/fa-IR/snippet.json +++ b/web/i18n/fa-IR/snippet.json @@ -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": "متغیر بازرسی" } diff --git a/web/i18n/fr-FR/app.json b/web/i18n/fr-FR/app.json index c1a69f09857..8f93dee1634 100644 --- a/web/i18n/fr-FR/app.json +++ b/web/i18n/fr-FR/app.json @@ -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", diff --git a/web/i18n/fr-FR/snippet.json b/web/i18n/fr-FR/snippet.json index 885709173b6..491cee0f723 100644 --- a/web/i18n/fr-FR/snippet.json +++ b/web/i18n/fr-FR/snippet.json @@ -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" } diff --git a/web/i18n/hi-IN/app.json b/web/i18n/hi-IN/app.json index 1a684411579..50a7cdfa8e0 100644 --- a/web/i18n/hi-IN/app.json +++ b/web/i18n/hi-IN/app.json @@ -222,6 +222,7 @@ "studio.starFailed": "स्टार अपडेट करने में विफल", "studio.starred": "स्टार किए गए", "studio.unstarApp": "ऐप से स्टार हटाएं", + "studio.viewSnippets": "स्निपेट देखें", "switch": "वर्कफ़्लो ऑर्केस्ट्रेट पर स्विच करें", "switchLabel": "बनाई जाने वाली ऐप कॉपी", "switchStart": "स्विच शुरू करें", diff --git a/web/i18n/hi-IN/snippet.json b/web/i18n/hi-IN/snippet.json index 65353c062b5..9c2df491ba5 100644 --- a/web/i18n/hi-IN/snippet.json +++ b/web/i18n/hi-IN/snippet.json @@ -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": "परिवर्तनीय निरीक्षण" } diff --git a/web/i18n/id-ID/app.json b/web/i18n/id-ID/app.json index ff7cbc3888d..fe9271fabbe 100644 --- a/web/i18n/id-ID/app.json +++ b/web/i18n/id-ID/app.json @@ -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", diff --git a/web/i18n/id-ID/snippet.json b/web/i18n/id-ID/snippet.json index d0aaa8f751a..894a216a34c 100644 --- a/web/i18n/id-ID/snippet.json +++ b/web/i18n/id-ID/snippet.json @@ -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" } diff --git a/web/i18n/it-IT/app.json b/web/i18n/it-IT/app.json index 44ac58fda24..2a68ddb07e8 100644 --- a/web/i18n/it-IT/app.json +++ b/web/i18n/it-IT/app.json @@ -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", diff --git a/web/i18n/it-IT/snippet.json b/web/i18n/it-IT/snippet.json index 948a6eab085..d5545c915d2 100644 --- a/web/i18n/it-IT/snippet.json +++ b/web/i18n/it-IT/snippet.json @@ -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" } diff --git a/web/i18n/ja-JP/app.json b/web/i18n/ja-JP/app.json index 984644a1a19..f07f42e2442 100644 --- a/web/i18n/ja-JP/app.json +++ b/web/i18n/ja-JP/app.json @@ -222,6 +222,7 @@ "studio.starFailed": "スターの更新に失敗しました", "studio.starred": "スター付き", "studio.unstarApp": "アプリのスターを外す", + "studio.viewSnippets": "スニペットを表示", "switch": "ワークフロー オーケストレートに切り替える", "switchLabel": "作成されるアプリのコピー", "switchStart": "切り替えを開始する", diff --git a/web/i18n/ja-JP/snippet.json b/web/i18n/ja-JP/snippet.json index 98a9ef7368c..95d460df28b 100644 --- a/web/i18n/ja-JP/snippet.json +++ b/web/i18n/ja-JP/snippet.json @@ -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": "変数の検査" } diff --git a/web/i18n/ko-KR/app.json b/web/i18n/ko-KR/app.json index f3e4065ee69..3c9663da75d 100644 --- a/web/i18n/ko-KR/app.json +++ b/web/i18n/ko-KR/app.json @@ -222,6 +222,7 @@ "studio.starFailed": "별표 업데이트 실패", "studio.starred": "별표 표시됨", "studio.unstarApp": "앱 별표 해제", + "studio.viewSnippets": "스니펫 보기", "switch": "워크플로우 오케스트레이션으로 전환하기", "switchLabel": "생성될 앱의 복사본", "switchStart": "전환 시작하기", diff --git a/web/i18n/ko-KR/snippet.json b/web/i18n/ko-KR/snippet.json index 56f020eb1a1..dcd722ecdf5 100644 --- a/web/i18n/ko-KR/snippet.json +++ b/web/i18n/ko-KR/snippet.json @@ -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": "변수 검사" } diff --git a/web/i18n/nl-NL/app.json b/web/i18n/nl-NL/app.json index a61af2657dc..ca0916944f0 100644 --- a/web/i18n/nl-NL/app.json +++ b/web/i18n/nl-NL/app.json @@ -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", diff --git a/web/i18n/nl-NL/snippet.json b/web/i18n/nl-NL/snippet.json index 970eb83458e..ff7d962f45c 100644 --- a/web/i18n/nl-NL/snippet.json +++ b/web/i18n/nl-NL/snippet.json @@ -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" } diff --git a/web/i18n/pl-PL/app.json b/web/i18n/pl-PL/app.json index d8a9ca9e05b..6daea811ad1 100644 --- a/web/i18n/pl-PL/app.json +++ b/web/i18n/pl-PL/app.json @@ -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", diff --git a/web/i18n/pl-PL/snippet.json b/web/i18n/pl-PL/snippet.json index 651b266ef97..0e75202a6c9 100644 --- a/web/i18n/pl-PL/snippet.json +++ b/web/i18n/pl-PL/snippet.json @@ -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" } diff --git a/web/i18n/pt-BR/app.json b/web/i18n/pt-BR/app.json index 445a65d76d2..8fe9f75c996 100644 --- a/web/i18n/pt-BR/app.json +++ b/web/i18n/pt-BR/app.json @@ -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", diff --git a/web/i18n/pt-BR/snippet.json b/web/i18n/pt-BR/snippet.json index 342d7eb92fb..f7802071915 100644 --- a/web/i18n/pt-BR/snippet.json +++ b/web/i18n/pt-BR/snippet.json @@ -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" } diff --git a/web/i18n/ro-RO/app.json b/web/i18n/ro-RO/app.json index 61275664030..29ae37643f3 100644 --- a/web/i18n/ro-RO/app.json +++ b/web/i18n/ro-RO/app.json @@ -222,6 +222,7 @@ "studio.starFailed": "Actualizarea stelei a eșuat", "studio.starred": "Marcate cu stea", "studio.unstarApp": "Elimină steaua aplicației", + "studio.viewSnippets": "Vezi fragmentele", "switch": "Comută la Orchestrare Flux de Lucru", "switchLabel": "Copia aplicației care urmează să fie creată", "switchStart": "Începe comutarea", diff --git a/web/i18n/ro-RO/snippet.json b/web/i18n/ro-RO/snippet.json index 117d369d30b..f91245129ab 100644 --- a/web/i18n/ro-RO/snippet.json +++ b/web/i18n/ro-RO/snippet.json @@ -1,5 +1,4 @@ { - "continueEditing": "Continuați editarea", "create": "Creați fragment", "createFailed": "Nu s-a putut crea fragmentul", "createFrom": "CREAȚI DIN", @@ -9,11 +8,7 @@ "deleteConfirmTitle": "Ștergeți fragmentul?", "deleteFailed": "Fragmentul nu a putut fi șters", "deleted": "Fragmentul a fost șters", - "discardChanges": "Renunțați la modificări", - "discardChangesDescription": "Schița modificărilor dvs. vor fi renunțate și fragmentul va reveni la ultima versiune salvată.", - "discardChangesTitle": "Renunțați la schița de modificări?", - "discardDraft": "Renunțați la Ciornă", - "doNotSave": "Lăsați ca Ciornă", + "draft": "Proiect", "dslVersionMismatchDescription": "A fost detectată o diferență semnificativă în versiunile DSL. Forțarea importului poate duce la funcționarea defectuoasă a fragmentului.", "dslVersionMismatchQuestion": "Vrei să continui?", "dslVersionMismatchTitle": "Incompatibilitate versiuni", @@ -21,9 +16,7 @@ "editDialogTitle": "Editați informațiile fragmentului", "editDone": "Informațiile despre fragment au fost actualizate", "editFailed": "Nu s-au actualizat informațiile despre fragment", - "editingDraft": "Editați o schiță.", - "emptyGraphSaveError": "Adăugați cel puțin un nod înainte de a salva.", - "exitEditing": "Ieșiți din editare", + "emptyGraphSaveError": "Adăugați cel puțin un nod înainte de a publica.", "exportFailed": "Exportul fragmentului nu a reușit.", "importDSLFile": "Importați fișierul DSL", "importDialogTitle": "Importați fragment", @@ -38,14 +31,15 @@ "menu.editInfo": "Editați informații", "menu.exportSnippet": "Exportați fragmentul", "panelTitle": "Câmp de intrare", + "publishButton": "Publică", "publishFailed": "Nu s-a putut publica fragmentul", - "save": "Salva", - "saveAndExit": "Salvați și ieșiți", - "saveBeforeLeavingDescription": "Salvați pentru a face această versiune disponibilă pentru utilizare în fluxurile de lucru. Sau păstrați-vă editările ca schiță pentru moment.", - "saveBeforeLeavingTitle": "Salvați modificările înainte de a pleca?", - "saveSuccess": "Fragmentul a fost salvat", + "publishMenuCurrentDraft": "Schița actuală nepublicată", + "publishSuccess": "Snippet publicat", + "sectionOrchestrate": "Orchestrare", "testRunButton": "Rulează testul", "typeLabel": "Fragment", "unknownUser": "Utilizator", - "viewOnly": "Numai vizualizare" + "updatedBy": "{{name}} actualizat {{time}}", + "usageCount": "Folosit de {{count}} ori", + "variableInspect": "Inspectare variabilă" } diff --git a/web/i18n/ru-RU/app.json b/web/i18n/ru-RU/app.json index e2cf880c357..bbd6838b861 100644 --- a/web/i18n/ru-RU/app.json +++ b/web/i18n/ru-RU/app.json @@ -222,6 +222,7 @@ "studio.starFailed": "Не удалось обновить отметку", "studio.starred": "Избранные", "studio.unstarApp": "Удалить приложение из избранного", + "studio.viewSnippets": "Посмотреть фрагменты", "switch": "Переключиться на Workflow", "switchLabel": "Копия приложения, которая будет создана", "switchStart": "Переключиться", diff --git a/web/i18n/ru-RU/snippet.json b/web/i18n/ru-RU/snippet.json index 01439e78f5d..ee63e10f107 100644 --- a/web/i18n/ru-RU/snippet.json +++ b/web/i18n/ru-RU/snippet.json @@ -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": "Переменная проверка" } diff --git a/web/i18n/sl-SI/app.json b/web/i18n/sl-SI/app.json index e1b57f744cf..d566d93d616 100644 --- a/web/i18n/sl-SI/app.json +++ b/web/i18n/sl-SI/app.json @@ -222,6 +222,7 @@ "studio.starFailed": "Posodobitev zvezdice ni uspela", "studio.starred": "Označeno z zvezdico", "studio.unstarApp": "Odstrani zvezdico aplikacije", + "studio.viewSnippets": "Prikaži izrezke", "switch": "Preklopi na Workflow Orchestrate", "switchLabel": "Kopija aplikacije, ki bo ustvarjena", "switchStart": "Začni preklop", diff --git a/web/i18n/sl-SI/snippet.json b/web/i18n/sl-SI/snippet.json index d978e537025..795bfd10494 100644 --- a/web/i18n/sl-SI/snippet.json +++ b/web/i18n/sl-SI/snippet.json @@ -1,5 +1,4 @@ { - "continueEditing": "Nadaljujte z urejanjem", "create": "USTVARI DELČEK", "createFailed": "Delčka ni bilo mogoče ustvariti", "createFrom": "USTVARJANJE IZ", @@ -9,11 +8,7 @@ "deleteConfirmTitle": "Izbrisati delček?", "deleteFailed": "Delčka ni bilo mogoče izbrisati", "deleted": "Delček izbrisan", - "discardChanges": "Zavrzi spremembe", - "discardChangesDescription": "Vaši osnutki sprememb bodo zavrženi in delček se bo vrnil na zadnjo shranjeno različico.", - "discardChangesTitle": "Želite zavreči spremembe osnutka?", - "discardDraft": "Zavrzi osnutek", - "doNotSave": "Pusti kot osnutek", + "draft": "Osnutek", "dslVersionMismatchDescription": "Zaznana je bila pomembna razlika v različicah DSL. Vsiljen uvoz lahko povzroči okvaro delčka.", "dslVersionMismatchQuestion": "Želite nadaljevati?", "dslVersionMismatchTitle": "Nezdružljivost različice", @@ -21,9 +16,7 @@ "editDialogTitle": "Uredi informacije o izrezku", "editDone": "Podatki o delčku so posodobljeni", "editFailed": "Podatkov o delčku ni bilo mogoče posodobiti", - "editingDraft": "Urejate osnutek.", - "emptyGraphSaveError": "Pred shranjevanjem dodajte vsaj eno vozlišče.", - "exitEditing": "Zapustite urejanje", + "emptyGraphSaveError": "Pred objavo dodajte vsaj eno vozlišče.", "exportFailed": "Izvoz delčka ni uspel.", "importDSLFile": "Uvozi datoteko DSL", "importDialogTitle": "Uvozi delček", @@ -38,14 +31,15 @@ "menu.editInfo": "Uredi informacije", "menu.exportSnippet": "Izvozi delček", "panelTitle": "Vnosno polje", + "publishButton": "Objavi", "publishFailed": "Objava delčka ni uspela", - "save": "Shrani", - "saveAndExit": "Shrani in zapusti", - "saveBeforeLeavingDescription": "Shranite, da bo ta različica na voljo za uporabo v delovnih tokovih. Ali pa za zdaj obdržite svoja urejanja kot osnutek.", - "saveBeforeLeavingTitle": "Shrani spremembe pred odhodom?", - "saveSuccess": "Delček shranjen", + "publishMenuCurrentDraft": "Trenutni osnutek neobjavljen", + "publishSuccess": "Delček objavljen", + "sectionOrchestrate": "Orkestrirajte", "testRunButton": "Testna vožnja", "typeLabel": "Izrezek", "unknownUser": "Uporabnik", - "viewOnly": "Samo ogled" + "updatedBy": "{{name}} posodobljeno {{time}}", + "usageCount": "Uporabljeno {{count}} krat", + "variableInspect": "Pregled spremenljivk" } diff --git a/web/i18n/th-TH/app.json b/web/i18n/th-TH/app.json index 5c0b846f22c..18d1787e1a1 100644 --- a/web/i18n/th-TH/app.json +++ b/web/i18n/th-TH/app.json @@ -222,6 +222,7 @@ "studio.starFailed": "อัปเดตดาวไม่สำเร็จ", "studio.starred": "ติดดาวแล้ว", "studio.unstarApp": "เลิกติดดาวแอป", + "studio.viewSnippets": "ดูสนิปเปต", "switch": "เปลี่ยนไปใช้ Workflow Orchestrate", "switchLabel": "สําเนาโปรเจกต์ที่จะสร้าง", "switchStart": "สวิตช์สตาร์ท", diff --git a/web/i18n/th-TH/snippet.json b/web/i18n/th-TH/snippet.json index db4a9bcac30..e9551e567cd 100644 --- a/web/i18n/th-TH/snippet.json +++ b/web/i18n/th-TH/snippet.json @@ -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": "การตรวจสอบตัวแปร" } diff --git a/web/i18n/tr-TR/app.json b/web/i18n/tr-TR/app.json index d7d7b2fad0a..f54a27f28ac 100644 --- a/web/i18n/tr-TR/app.json +++ b/web/i18n/tr-TR/app.json @@ -222,6 +222,7 @@ "studio.starFailed": "Yıldız güncellenemedi", "studio.starred": "Yıldızlı", "studio.unstarApp": "Uygulamanın yıldızını kaldır", + "studio.viewSnippets": "Snippet'leri görüntüle", "switch": "İş Akışı Orkestrasyonuna Geç", "switchLabel": "Oluşturulacak uygulama kopyası", "switchStart": "Geçişi Başlat", diff --git a/web/i18n/tr-TR/snippet.json b/web/i18n/tr-TR/snippet.json index 6fb1f567219..71b3f254982 100644 --- a/web/i18n/tr-TR/snippet.json +++ b/web/i18n/tr-TR/snippet.json @@ -1,5 +1,4 @@ { - "continueEditing": "Düzenlemeye Devam Et", "create": "SNIPPET OLUŞTURUN", "createFailed": "Snippet oluşturulamadı", "createFrom": "OLUŞTURUN", @@ -9,11 +8,7 @@ "deleteConfirmTitle": "Snippet silinsin mi?", "deleteFailed": "Snippet silinemedi", "deleted": "Snippet silindi", - "discardChanges": "Değişiklikleri İptal Et", - "discardChangesDescription": "Taslak değişiklikleriniz silinecek ve snippet, kaydedilen son sürüme geri dönecek.", - "discardChangesTitle": "Taslak değişiklikler silinsin mi?", - "discardDraft": "Taslağı Sil", - "doNotSave": "Taslak olarak bırak", + "draft": "Taslak", "dslVersionMismatchDescription": "DSL sürümlerinde önemli bir fark tespit edildi. İçe aktarmayı zorlamak pasajın arızalanmasına neden olabilir.", "dslVersionMismatchQuestion": "Devam etmek istiyor musun?", "dslVersionMismatchTitle": "Sürüm Uyumsuzluğu", @@ -21,9 +16,7 @@ "editDialogTitle": "Parçacık Bilgilerini Düzenle", "editDone": "Snippet bilgisi güncellendi", "editFailed": "Snippet bilgileri güncellenemedi", - "editingDraft": "Bir taslağı düzenliyorsunuz.", - "emptyGraphSaveError": "Kaydetmeden önce en az bir düğüm ekleyin.", - "exitEditing": "Düzenlemeden Çık", + "emptyGraphSaveError": "Yayınlamadan önce en az bir düğüm ekleyin.", "exportFailed": "Snippet'i dışa aktarma başarısız oldu.", "importDSLFile": "DSL dosyasını içe aktar", "importDialogTitle": "Parçacığı İçe Aktar", @@ -38,14 +31,15 @@ "menu.editInfo": "Bilgileri Düzenle", "menu.exportSnippet": "Snippet'i Dışa Aktar", "panelTitle": "Giriş Alanı", + "publishButton": "Yayınla", "publishFailed": "Snippet yayınlanamadı", - "save": "Kaydetmek", - "saveAndExit": "Kaydet ve Çık", - "saveBeforeLeavingDescription": "Bu sürümün iş akışlarında kullanılabilir olmasını sağlamak için kaydedin. Veya düzenlemelerinizi şimdilik taslak olarak saklayın.", - "saveBeforeLeavingTitle": "Ayrılmadan önce değişiklikler kaydedilsin mi?", - "saveSuccess": "Snippet kaydedildi", + "publishMenuCurrentDraft": "Mevcut taslak yayınlanmadı", + "publishSuccess": "Snippet yayınlandı", + "sectionOrchestrate": "Yönetmek", "testRunButton": "Test çalıştırması", "typeLabel": "Parçacık", "unknownUser": "Kullanıcı", - "viewOnly": "Yalnızca görüntüle" + "updatedBy": "{{name}} güncellendi {{time}}", + "usageCount": "{{count}} kez kullanıldı", + "variableInspect": "Değişken İnceleme" } diff --git a/web/i18n/uk-UA/app.json b/web/i18n/uk-UA/app.json index 764eddb2d1e..23d0976774e 100644 --- a/web/i18n/uk-UA/app.json +++ b/web/i18n/uk-UA/app.json @@ -222,6 +222,7 @@ "studio.starFailed": "Не вдалося оновити зірку", "studio.starred": "Позначені зіркою", "studio.unstarApp": "Зняти зірку із застосунку", + "studio.viewSnippets": "Переглянути фрагменти", "switch": "Перейти до оркестрації робочого процесу", "switchLabel": "Копія додатка, яка буде створена", "switchStart": "Почати перемикання", diff --git a/web/i18n/uk-UA/snippet.json b/web/i18n/uk-UA/snippet.json index a0de7cbfc53..49d21572093 100644 --- a/web/i18n/uk-UA/snippet.json +++ b/web/i18n/uk-UA/snippet.json @@ -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": "Перевірка змінних" } diff --git a/web/i18n/vi-VN/app.json b/web/i18n/vi-VN/app.json index 33cc2a36086..2fe5bd3e781 100644 --- a/web/i18n/vi-VN/app.json +++ b/web/i18n/vi-VN/app.json @@ -222,6 +222,7 @@ "studio.starFailed": "Không thể cập nhật sao", "studio.starred": "Đã gắn sao", "studio.unstarApp": "Bỏ gắn sao ứng dụng", + "studio.viewSnippets": "Xem snippets", "switch": "Chuyển sang quản lý quy trình", "switchLabel": "Bản sao ứng dụng sẽ được tạo", "switchStart": "Bắt đầu chuyển", diff --git a/web/i18n/vi-VN/snippet.json b/web/i18n/vi-VN/snippet.json index da851724a5d..b4af3505a04 100644 --- a/web/i18n/vi-VN/snippet.json +++ b/web/i18n/vi-VN/snippet.json @@ -1,5 +1,4 @@ { - "continueEditing": "Tiếp tục chỉnh sửa", "create": "TẠO ĐOẠN TRÍCH", "createFailed": "Không tạo được đoạn trích", "createFrom": "TẠO TỪ", @@ -9,11 +8,7 @@ "deleteConfirmTitle": "Xóa đoạn trích?", "deleteFailed": "Không thể xóa đoạn trích", "deleted": "Đã xóa đoạn trích", - "discardChanges": "Hủy thay đổi", - "discardChangesDescription": "Các thay đổi nháp của bạn sẽ bị hủy và đoạn trích sẽ trở về phiên bản đã lưu cuối cùng.", - "discardChangesTitle": "Hủy các thay đổi dự thảo?", - "discardDraft": "Hủy bản nháp", - "doNotSave": "Để lại dưới dạng bản nháp", + "draft": "Bản nháp", "dslVersionMismatchDescription": "Đã phát hiện sự khác biệt đáng kể giữa các phiên bản DSL. Việc ép buộc nhập có thể khiến đoạn trích bị trục trặc.", "dslVersionMismatchQuestion": "Bạn có muốn tiếp tục không?", "dslVersionMismatchTitle": "Phiên bản không tương thích", @@ -21,9 +16,7 @@ "editDialogTitle": "Chỉnh sửa thông tin đoạn trích", "editDone": "Thông tin đoạn trích đã được cập nhật", "editFailed": "Không cập nhật được thông tin đoạn trích", - "editingDraft": "Bạn đang chỉnh sửa một bản nháp.", - "emptyGraphSaveError": "Thêm ít nhất một nút trước khi lưu.", - "exitEditing": "Thoát chỉnh sửa", + "emptyGraphSaveError": "Thêm ít nhất một nút trước khi xuất bản.", "exportFailed": "Xuất đoạn trích không thành công.", "importDSLFile": "Nhập tệp DSL", "importDialogTitle": "Nhập đoạn trích", @@ -38,14 +31,15 @@ "menu.editInfo": "Chỉnh sửa thông tin", "menu.exportSnippet": "Xuất đoạn trích", "panelTitle": "Trường nhập liệu", + "publishButton": "Xuất bản", "publishFailed": "Không thể xuất bản đoạn trích", - "save": "Lưu", - "saveAndExit": "Lưu và thoát", - "saveBeforeLeavingDescription": "Lưu để phiên bản này có thể dùng trong quy trình làm việc. Hoặc giữ các chỉnh sửa của bạn dưới dạng bản nháp ngay bây giờ.", - "saveBeforeLeavingTitle": "Lưu các thay đổi trước khi rời đi?", - "saveSuccess": "Đã lưu đoạn trích", + "publishMenuCurrentDraft": "Bản thảo hiện tại chưa được xuất bản", + "publishSuccess": "Đoạn trích được xuất bản", + "sectionOrchestrate": "Điều phối", "testRunButton": "Chạy thử", "typeLabel": "Đoạn trích", "unknownUser": "Người dùng", - "viewOnly": "Chỉ xem" + "updatedBy": "{{name}} đã cập nhật {{time}}", + "usageCount": "Đã sử dụng {{count}} lần", + "variableInspect": "Kiểm tra biến" } diff --git a/web/i18n/zh-Hans/app.json b/web/i18n/zh-Hans/app.json index c151d230eab..7c1a657358b 100644 --- a/web/i18n/zh-Hans/app.json +++ b/web/i18n/zh-Hans/app.json @@ -222,6 +222,7 @@ "studio.starFailed": "更新星标失败", "studio.starred": "星标", "studio.unstarApp": "取消星标应用", + "studio.viewSnippets": "查看 Snippets", "switch": "迁移为工作流编排", "switchLabel": "新应用创建为", "switchStart": "开始迁移", diff --git a/web/i18n/zh-Hans/snippet.json b/web/i18n/zh-Hans/snippet.json index ee8ee567443..eabdb057fb9 100644 --- a/web/i18n/zh-Hans/snippet.json +++ b/web/i18n/zh-Hans/snippet.json @@ -1,5 +1,4 @@ { - "continueEditing": "继续编辑", "create": "创建 Snippet", "createFailed": "创建 Snippet 失败", "createFrom": "创建方式", @@ -9,11 +8,7 @@ "deleteConfirmTitle": "删除 Snippet?", "deleteFailed": "删除 Snippet 失败", "deleted": "Snippet 已删除", - "discardChanges": "放弃更改", - "discardChangesDescription": "你的草稿更改将被放弃,Snippet 会恢复到上次保存的版本。", - "discardChangesTitle": "放弃草稿更改?", - "discardDraft": "放弃草稿", - "doNotSave": "保留为草稿", + "draft": "草稿", "dslVersionMismatchDescription": "检测到 DSL 版本差异较大,强制导入 Snippet 可能无法正常运行。", "dslVersionMismatchQuestion": "是否继续?", "dslVersionMismatchTitle": "版本不兼容", @@ -21,9 +16,7 @@ "editDialogTitle": "编辑 Snippet 信息", "editDone": "Snippet 信息已更新", "editFailed": "更新 Snippet 信息失败", - "editingDraft": "你正在编辑草稿。", - "emptyGraphSaveError": "请至少添加一个节点后再保存。", - "exitEditing": "退出编辑", + "emptyGraphSaveError": "请至少添加一个节点后再发布。", "exportFailed": "导出 Snippet 失败。", "importDSLFile": "导入 DSL 文件", "importDialogTitle": "导入 Snippet", @@ -38,14 +31,15 @@ "menu.editInfo": "编辑信息", "menu.exportSnippet": "导出 Snippet", "panelTitle": "输入字段", + "publishButton": "发布", "publishFailed": "发布 Snippet 失败", - "save": "保存", - "saveAndExit": "保存并退出", - "saveBeforeLeavingDescription": "保存后,此版本可在工作流中添加使用。也可以暂时将编辑内容保留为草稿。", - "saveBeforeLeavingTitle": "离开前保存更改?", - "saveSuccess": "Snippet 保存成功", + "publishMenuCurrentDraft": "当前草稿未发布", + "publishSuccess": "Snippet 已发布", + "sectionOrchestrate": "编排", "testRunButton": "测试运行", "typeLabel": "Snippet", "unknownUser": "用户", - "viewOnly": "仅查看" + "updatedBy": "{{name}} 更新于 {{time}}", + "usageCount": "已使用 {{count}} 次", + "variableInspect": "变量查看" } diff --git a/web/i18n/zh-Hant/app.json b/web/i18n/zh-Hant/app.json index a3a565015bf..40d0c70a915 100644 --- a/web/i18n/zh-Hant/app.json +++ b/web/i18n/zh-Hant/app.json @@ -222,6 +222,7 @@ "studio.starFailed": "更新星標失敗", "studio.starred": "星標", "studio.unstarApp": "取消星標應用", + "studio.viewSnippets": "查看片段", "switch": "遷移為工作流編排", "switchLabel": "新應用建立為", "switchStart": "開始遷移", diff --git a/web/i18n/zh-Hant/snippet.json b/web/i18n/zh-Hant/snippet.json index 2a704b844b9..8be90155d92 100644 --- a/web/i18n/zh-Hant/snippet.json +++ b/web/i18n/zh-Hant/snippet.json @@ -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": "變數檢查" }