Merge branch 'main' into jzh

This commit is contained in:
JzoNg 2026-03-20 15:33:49 +08:00
commit bfcac64a9d
83 changed files with 2027 additions and 652 deletions

View File

@ -7,7 +7,7 @@ from flask import abort, request
from flask_restx import Resource, fields, marshal_with
from pydantic import BaseModel, Field, field_validator
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound
from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound
import services
from controllers.console import console_ns
@ -46,13 +46,14 @@ from models import App
from models.model import AppMode
from models.workflow import Workflow
from services.app_generate_service import AppGenerateService
from services.errors.app import WorkflowHashNotEqualError
from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError
from services.errors.llm import InvokeRateLimitError
from services.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError, WorkflowService
logger = logging.getLogger(__name__)
LISTENING_RETRY_IN = 2000
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
RESTORE_SOURCE_WORKFLOW_MUST_BE_PUBLISHED_MESSAGE = "source workflow must be published"
# Register models for flask_restx to avoid dict type issues in Swagger
# Register in dependency order: base models first, then dependent models
@ -284,7 +285,9 @@ class DraftWorkflowApi(Resource):
workflow_service = WorkflowService()
try:
environment_variables_list = args.get("environment_variables") or []
environment_variables_list = Workflow.normalize_environment_variable_mappings(
args.get("environment_variables") or [],
)
environment_variables = [
variable_factory.build_environment_variable_from_mapping(obj) for obj in environment_variables_list
]
@ -994,6 +997,43 @@ class PublishedAllWorkflowApi(Resource):
}
@console_ns.route("/apps/<uuid:app_id>/workflows/<string:workflow_id>/restore")
class DraftWorkflowRestoreApi(Resource):
@console_ns.doc("restore_workflow_to_draft")
@console_ns.doc(description="Restore a published workflow version into the draft workflow")
@console_ns.doc(params={"app_id": "Application ID", "workflow_id": "Published workflow ID"})
@console_ns.response(200, "Workflow restored successfully")
@console_ns.response(400, "Source workflow must be published")
@console_ns.response(404, "Workflow not found")
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@edit_permission_required
def post(self, app_model: App, workflow_id: str):
current_user, _ = current_account_with_tenant()
workflow_service = WorkflowService()
try:
workflow = workflow_service.restore_published_workflow_to_draft(
app_model=app_model,
workflow_id=workflow_id,
account=current_user,
)
except IsDraftWorkflowError as exc:
raise BadRequest(RESTORE_SOURCE_WORKFLOW_MUST_BE_PUBLISHED_MESSAGE) from exc
except WorkflowNotFoundError as exc:
raise NotFound(str(exc)) from exc
except ValueError as exc:
raise BadRequest(str(exc)) from exc
return {
"result": "success",
"hash": workflow.unique_hash,
"updated_at": TimestampField().format(workflow.updated_at or workflow.created_at),
}
@console_ns.route("/apps/<uuid:app_id>/workflows/<string:workflow_id>")
class WorkflowByIdApi(Resource):
@console_ns.doc("update_workflow_by_id")

View File

@ -6,7 +6,7 @@ from flask import abort, request
from flask_restx import Resource, marshal_with # type: ignore
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound
from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound
import services
from controllers.common.schema import register_schema_models
@ -16,7 +16,11 @@ from controllers.console.app.error import (
DraftWorkflowNotExist,
DraftWorkflowNotSync,
)
from controllers.console.app.workflow import workflow_model, workflow_pagination_model
from controllers.console.app.workflow import (
RESTORE_SOURCE_WORKFLOW_MUST_BE_PUBLISHED_MESSAGE,
workflow_model,
workflow_pagination_model,
)
from controllers.console.app.workflow_run import (
workflow_run_detail_model,
workflow_run_node_execution_list_model,
@ -42,7 +46,8 @@ from libs.login import current_account_with_tenant, current_user, login_required
from models import Account
from models.dataset import Pipeline
from models.model import EndUser
from services.errors.app import WorkflowHashNotEqualError
from models.workflow import Workflow
from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError
from services.errors.llm import InvokeRateLimitError
from services.rag_pipeline.pipeline_generate_service import PipelineGenerateService
from services.rag_pipeline.rag_pipeline import RagPipelineService
@ -203,9 +208,12 @@ class DraftRagPipelineApi(Resource):
abort(415)
payload = DraftWorkflowSyncPayload.model_validate(payload_dict)
rag_pipeline_service = RagPipelineService()
try:
environment_variables_list = payload.environment_variables or []
environment_variables_list = Workflow.normalize_environment_variable_mappings(
payload.environment_variables or [],
)
environment_variables = [
variable_factory.build_environment_variable_from_mapping(obj) for obj in environment_variables_list
]
@ -213,7 +221,6 @@ class DraftRagPipelineApi(Resource):
conversation_variables = [
variable_factory.build_conversation_variable_from_mapping(obj) for obj in conversation_variables_list
]
rag_pipeline_service = RagPipelineService()
workflow = rag_pipeline_service.sync_draft_workflow(
pipeline=pipeline,
graph=payload.graph,
@ -705,6 +712,36 @@ class PublishedAllRagPipelineApi(Resource):
}
@console_ns.route("/rag/pipelines/<uuid:pipeline_id>/workflows/<string:workflow_id>/restore")
class RagPipelineDraftWorkflowRestoreApi(Resource):
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@get_rag_pipeline
def post(self, pipeline: Pipeline, workflow_id: str):
current_user, _ = current_account_with_tenant()
rag_pipeline_service = RagPipelineService()
try:
workflow = rag_pipeline_service.restore_published_workflow_to_draft(
pipeline=pipeline,
workflow_id=workflow_id,
account=current_user,
)
except IsDraftWorkflowError as exc:
# Use a stable, predefined message to keep the 400 response consistent
raise BadRequest(RESTORE_SOURCE_WORKFLOW_MUST_BE_PUBLISHED_MESSAGE) from exc
except WorkflowNotFoundError as exc:
raise NotFound(str(exc)) from exc
return {
"result": "success",
"hash": workflow.unique_hash,
"updated_at": TimestampField().format(workflow.updated_at or workflow.created_at),
}
@console_ns.route("/rag/pipelines/<uuid:pipeline_id>/workflows/<string:workflow_id>")
class RagPipelineByIdApi(Resource):
@setup_required

View File

@ -101,6 +101,9 @@ class HttpRequestNode(Node[HttpRequestNodeData]):
timeout=self._get_request_timeout(self.node_data),
variable_pool=self.graph_runtime_state.variable_pool,
http_request_config=self._http_request_config,
# Must be 0 to disable executor-level retries, as the graph engine handles them.
# This is critical to prevent nested retries.
max_retries=0,
ssl_verify=self.node_data.ssl_verify,
http_client=self._http_client,
file_manager=self._file_manager,

View File

@ -1,3 +1,4 @@
import copy
import json
import logging
from collections.abc import Generator, Mapping, Sequence
@ -302,26 +303,40 @@ class Workflow(Base): # bug
def features(self) -> str:
"""
Convert old features structure to new features structure.
This property avoids rewriting the underlying JSON when normalization
produces no effective change, to prevent marking the row dirty on read.
"""
if not self._features:
return self._features
features = json.loads(self._features)
if features.get("file_upload", {}).get("image", {}).get("enabled", False):
image_enabled = True
image_number_limits = int(features["file_upload"]["image"].get("number_limits", DEFAULT_FILE_NUMBER_LIMITS))
image_transfer_methods = features["file_upload"]["image"].get(
"transfer_methods", ["remote_url", "local_file"]
)
features["file_upload"]["enabled"] = image_enabled
features["file_upload"]["number_limits"] = image_number_limits
features["file_upload"]["allowed_file_upload_methods"] = image_transfer_methods
features["file_upload"]["allowed_file_types"] = features["file_upload"].get("allowed_file_types", ["image"])
features["file_upload"]["allowed_file_extensions"] = features["file_upload"].get(
"allowed_file_extensions", []
)
del features["file_upload"]["image"]
self._features = json.dumps(features)
# Parse once and deep-copy before normalization to detect in-place changes.
original_dict = self._decode_features_payload(self._features)
if original_dict is None:
return self._features
# Fast-path: if the legacy file_upload.image.enabled shape is absent, skip
# deep-copy and normalization entirely and return the stored JSON.
file_upload_payload = original_dict.get("file_upload")
if not isinstance(file_upload_payload, dict):
return self._features
file_upload = cast(dict[str, Any], file_upload_payload)
image_payload = file_upload.get("image")
if not isinstance(image_payload, dict):
return self._features
image = cast(dict[str, Any], image_payload)
if "enabled" not in image:
return self._features
normalized_dict = self._normalize_features_payload(copy.deepcopy(original_dict))
if normalized_dict == original_dict:
# No effective change; return stored JSON unchanged.
return self._features
# Normalization changed the payload: persist the normalized JSON.
self._features = json.dumps(normalized_dict)
return self._features
@features.setter
@ -332,6 +347,44 @@ class Workflow(Base): # bug
def features_dict(self) -> dict[str, Any]:
return json.loads(self.features) if self.features else {}
@property
def serialized_features(self) -> str:
"""Return the stored features JSON without triggering compatibility rewrites."""
return self._features
@property
def normalized_features_dict(self) -> dict[str, Any]:
"""Decode features with legacy normalization without mutating the model state."""
if not self._features:
return {}
features = self._decode_features_payload(self._features)
return self._normalize_features_payload(features) if features is not None else {}
@staticmethod
def _decode_features_payload(features: str) -> dict[str, Any] | None:
"""Decode workflow features JSON when it contains an object payload."""
payload = json.loads(features)
return cast(dict[str, Any], payload) if isinstance(payload, dict) else None
@staticmethod
def _normalize_features_payload(features: dict[str, Any]) -> dict[str, Any]:
if features.get("file_upload", {}).get("image", {}).get("enabled", False):
image_number_limits = int(features["file_upload"]["image"].get("number_limits", DEFAULT_FILE_NUMBER_LIMITS))
image_transfer_methods = features["file_upload"]["image"].get(
"transfer_methods", ["remote_url", "local_file"]
)
features["file_upload"]["enabled"] = True
features["file_upload"]["number_limits"] = image_number_limits
features["file_upload"]["allowed_file_upload_methods"] = image_transfer_methods
features["file_upload"]["allowed_file_types"] = features["file_upload"].get("allowed_file_types", ["image"])
features["file_upload"]["allowed_file_extensions"] = features["file_upload"].get(
"allowed_file_extensions", []
)
del features["file_upload"]["image"]
return features
def walk_nodes(
self, specific_node_type: NodeType | None = None
) -> Generator[tuple[str, Mapping[str, Any]], None, None]:
@ -517,6 +570,31 @@ class Workflow(Base): # bug
)
self._environment_variables = environment_variables_json
@staticmethod
def normalize_environment_variable_mappings(
mappings: Sequence[Mapping[str, Any]],
) -> list[dict[str, Any]]:
"""Convert masked secret placeholders into the draft hidden sentinel.
Regular draft sync requests should preserve existing secrets without shipping
plaintext values back from the client. The dedicated restore endpoint now
copies published secrets server-side, so draft sync only needs to normalize
the UI mask into `HIDDEN_VALUE`.
"""
masked_secret_value = encrypter.full_mask_token()
normalized_mappings: list[dict[str, Any]] = []
for mapping in mappings:
normalized_mapping = dict(mapping)
if (
normalized_mapping.get("value_type") == SegmentType.SECRET.value
and normalized_mapping.get("value") == masked_secret_value
):
normalized_mapping["value"] = HIDDEN_VALUE
normalized_mappings.append(normalized_mapping)
return normalized_mappings
def to_dict(self, *, include_secret: bool = False) -> WorkflowContentDict:
environment_variables = list(self.environment_variables)
environment_variables = [
@ -564,6 +642,12 @@ class Workflow(Base): # bug
ensure_ascii=False,
)
def copy_serialized_variable_storage_from(self, source_workflow: "Workflow") -> None:
"""Copy stored variable JSON directly for same-tenant restore flows."""
self._environment_variables = source_workflow._environment_variables
self._conversation_variables = source_workflow._conversation_variables
self._rag_pipeline_variables = source_workflow._rag_pipeline_variables
@staticmethod
def version_from_datetime(d: datetime) -> str:
return str(d)

View File

@ -79,10 +79,11 @@ from services.entities.knowledge_entities.rag_pipeline_entities import (
KnowledgeConfiguration,
PipelineTemplateInfoEntity,
)
from services.errors.app import WorkflowHashNotEqualError
from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError
from services.rag_pipeline.pipeline_template.pipeline_template_factory import PipelineTemplateRetrievalFactory
from services.tools.builtin_tools_manage_service import BuiltinToolManageService
from services.workflow_draft_variable_service import DraftVariableSaver, DraftVarLoader
from services.workflow_restore import apply_published_workflow_snapshot_to_draft
logger = logging.getLogger(__name__)
@ -234,6 +235,21 @@ class RagPipelineService:
return workflow
def get_published_workflow_by_id(self, pipeline: Pipeline, workflow_id: str) -> Workflow | None:
"""Fetch a published workflow snapshot by ID for restore operations."""
workflow = (
db.session.query(Workflow)
.where(
Workflow.tenant_id == pipeline.tenant_id,
Workflow.app_id == pipeline.id,
Workflow.id == workflow_id,
)
.first()
)
if workflow and workflow.version == Workflow.VERSION_DRAFT:
raise IsDraftWorkflowError("source workflow must be published")
return workflow
def get_all_published_workflow(
self,
*,
@ -327,6 +343,42 @@ class RagPipelineService:
# return draft workflow
return workflow
def restore_published_workflow_to_draft(
self,
*,
pipeline: Pipeline,
workflow_id: str,
account: Account,
) -> Workflow:
"""Restore a published pipeline workflow snapshot into the draft workflow.
Pipelines reuse the shared draft-restore field copy helper, but still own
the pipeline-specific flush/link step that wires a newly created draft
back onto ``pipeline.workflow_id``.
"""
source_workflow = self.get_published_workflow_by_id(pipeline=pipeline, workflow_id=workflow_id)
if not source_workflow:
raise WorkflowNotFoundError("Workflow not found.")
draft_workflow = self.get_draft_workflow(pipeline=pipeline)
draft_workflow, is_new_draft = apply_published_workflow_snapshot_to_draft(
tenant_id=pipeline.tenant_id,
app_id=pipeline.id,
source_workflow=source_workflow,
draft_workflow=draft_workflow,
account=account,
updated_at_factory=lambda: datetime.now(UTC).replace(tzinfo=None),
)
if is_new_draft:
db.session.add(draft_workflow)
db.session.flush()
pipeline.workflow_id = draft_workflow.id
db.session.commit()
return draft_workflow
def publish_workflow(
self,
*,

View File

@ -0,0 +1,58 @@
"""Shared helpers for restoring published workflow snapshots into drafts.
Both app workflows and RAG pipeline workflows restore the same workflow fields
from a published snapshot into a draft. Keeping that field-copy logic in one
place prevents the two restore paths from drifting when we add or adjust draft
state in the future. Restore stays within a tenant, so we can safely reuse the
serialized workflow storage blobs without decrypting and re-encrypting secrets.
"""
from collections.abc import Callable
from datetime import datetime
from models import Account
from models.workflow import Workflow, WorkflowType
UpdatedAtFactory = Callable[[], datetime]
def apply_published_workflow_snapshot_to_draft(
*,
tenant_id: str,
app_id: str,
source_workflow: Workflow,
draft_workflow: Workflow | None,
account: Account,
updated_at_factory: UpdatedAtFactory,
) -> tuple[Workflow, bool]:
"""Copy a published workflow snapshot into a draft workflow record.
The caller remains responsible for source lookup, validation, flushing, and
post-commit side effects. This helper only centralizes the shared draft
creation/update semantics used by both restore entry points. Features are
copied from the stored JSON payload so restore does not normalize and dirty
the published source row before the caller commits.
"""
if not draft_workflow:
workflow_type = (
source_workflow.type.value if isinstance(source_workflow.type, WorkflowType) else source_workflow.type
)
draft_workflow = Workflow(
tenant_id=tenant_id,
app_id=app_id,
type=workflow_type,
version=Workflow.VERSION_DRAFT,
graph=source_workflow.graph,
features=source_workflow.serialized_features,
created_by=account.id,
)
draft_workflow.copy_serialized_variable_storage_from(source_workflow)
return draft_workflow, True
draft_workflow.graph = source_workflow.graph
draft_workflow.features = source_workflow.serialized_features
draft_workflow.updated_by = account.id
draft_workflow.updated_at = updated_at_factory()
draft_workflow.copy_serialized_variable_storage_from(source_workflow)
return draft_workflow, False

View File

@ -63,7 +63,12 @@ from models.workflow import Workflow, WorkflowNodeExecutionModel, WorkflowNodeEx
from repositories.factory import DifyAPIRepositoryFactory
from services.billing_service import BillingService
from services.enterprise.plugin_manager_service import PluginCredentialType
from services.errors.app import IsDraftWorkflowError, TriggerNodeLimitExceededError, WorkflowHashNotEqualError
from services.errors.app import (
IsDraftWorkflowError,
TriggerNodeLimitExceededError,
WorkflowHashNotEqualError,
WorkflowNotFoundError,
)
from services.workflow.workflow_converter import WorkflowConverter
from .errors.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError
@ -75,6 +80,7 @@ from .human_input_delivery_test_service import (
HumanInputDeliveryTestService,
)
from .workflow_draft_variable_service import DraftVariableSaver, DraftVarLoader, WorkflowDraftVariableService
from .workflow_restore import apply_published_workflow_snapshot_to_draft
class WorkflowService:
@ -279,6 +285,43 @@ class WorkflowService:
# return draft workflow
return workflow
def restore_published_workflow_to_draft(
self,
*,
app_model: App,
workflow_id: str,
account: Account,
) -> Workflow:
"""Restore a published workflow snapshot into the draft workflow.
Secret environment variables are copied server-side from the selected
published workflow so the normal draft sync flow stays stateless.
"""
source_workflow = self.get_published_workflow_by_id(app_model=app_model, workflow_id=workflow_id)
if not source_workflow:
raise WorkflowNotFoundError("Workflow not found.")
self.validate_features_structure(app_model=app_model, features=source_workflow.normalized_features_dict)
self.validate_graph_structure(graph=source_workflow.graph_dict)
draft_workflow = self.get_draft_workflow(app_model=app_model)
draft_workflow, is_new_draft = apply_published_workflow_snapshot_to_draft(
tenant_id=app_model.tenant_id,
app_id=app_model.id,
source_workflow=source_workflow,
draft_workflow=draft_workflow,
account=account,
updated_at_factory=naive_utc_now,
)
if is_new_draft:
db.session.add(draft_workflow)
db.session.commit()
app_draft_workflow_was_synced.send(app_model, synced_draft_workflow=draft_workflow)
return draft_workflow
def publish_workflow(
self,
*,

View File

@ -802,6 +802,81 @@ class TestWorkflowService:
with pytest.raises(ValueError, match="No valid workflow found"):
workflow_service.publish_workflow(session=db_session_with_containers, app_model=app, account=account)
def test_restore_published_workflow_to_draft_does_not_persist_normalized_source_features(
self, db_session_with_containers: Session
):
"""Restore copies legacy feature JSON into draft without rewriting the source row."""
fake = Faker()
account = self._create_test_account(db_session_with_containers, fake)
app = self._create_test_app(db_session_with_containers, fake)
app.mode = AppMode.ADVANCED_CHAT
legacy_features = {
"file_upload": {
"image": {
"enabled": True,
"number_limits": 6,
"transfer_methods": ["remote_url", "local_file"],
}
},
"opening_statement": "",
"retriever_resource": {"enabled": True},
"sensitive_word_avoidance": {"enabled": False},
"speech_to_text": {"enabled": False},
"suggested_questions": [],
"suggested_questions_after_answer": {"enabled": False},
"text_to_speech": {"enabled": False, "language": "", "voice": ""},
}
published_workflow = Workflow(
id=fake.uuid4(),
tenant_id=app.tenant_id,
app_id=app.id,
type=WorkflowType.WORKFLOW,
version="2026.03.19.001",
graph=json.dumps({"nodes": [], "edges": []}),
features=json.dumps(legacy_features),
created_by=account.id,
updated_by=account.id,
environment_variables=[],
conversation_variables=[],
)
draft_workflow = Workflow(
id=fake.uuid4(),
tenant_id=app.tenant_id,
app_id=app.id,
type=WorkflowType.WORKFLOW,
version=Workflow.VERSION_DRAFT,
graph=json.dumps({"nodes": [], "edges": []}),
features=json.dumps({}),
created_by=account.id,
updated_by=account.id,
environment_variables=[],
conversation_variables=[],
)
db_session_with_containers.add(published_workflow)
db_session_with_containers.add(draft_workflow)
db_session_with_containers.commit()
workflow_service = WorkflowService()
restored_workflow = workflow_service.restore_published_workflow_to_draft(
app_model=app,
workflow_id=published_workflow.id,
account=account,
)
db_session_with_containers.expire_all()
refreshed_published_workflow = (
db_session_with_containers.query(Workflow).filter_by(id=published_workflow.id).first()
)
refreshed_draft_workflow = db_session_with_containers.query(Workflow).filter_by(id=draft_workflow.id).first()
assert restored_workflow.id == draft_workflow.id
assert refreshed_published_workflow is not None
assert refreshed_draft_workflow is not None
assert refreshed_published_workflow.serialized_features == json.dumps(legacy_features)
assert refreshed_draft_workflow.serialized_features == json.dumps(legacy_features)
def test_get_default_block_configs(self, db_session_with_containers: Session):
"""
Test retrieval of default block configurations for all node types.

View File

@ -129,6 +129,136 @@ def test_sync_draft_workflow_hash_mismatch(app, monkeypatch: pytest.MonkeyPatch)
handler(api, app_model=SimpleNamespace(id="app"))
def test_restore_published_workflow_to_draft_success(app, monkeypatch: pytest.MonkeyPatch) -> None:
workflow = SimpleNamespace(
unique_hash="restored-hash",
updated_at=None,
created_at=datetime(2024, 1, 1),
)
user = SimpleNamespace(id="account-1")
monkeypatch.setattr(workflow_module, "current_account_with_tenant", lambda: (user, "t1"))
monkeypatch.setattr(
workflow_module,
"WorkflowService",
lambda: SimpleNamespace(restore_published_workflow_to_draft=lambda **_kwargs: workflow),
)
api = workflow_module.DraftWorkflowRestoreApi()
handler = _unwrap(api.post)
with app.test_request_context(
"/apps/app/workflows/published-workflow/restore",
method="POST",
):
response = handler(
api,
app_model=SimpleNamespace(id="app", tenant_id="tenant-1"),
workflow_id="published-workflow",
)
assert response["result"] == "success"
assert response["hash"] == "restored-hash"
def test_restore_published_workflow_to_draft_not_found(app, monkeypatch: pytest.MonkeyPatch) -> None:
user = SimpleNamespace(id="account-1")
monkeypatch.setattr(workflow_module, "current_account_with_tenant", lambda: (user, "t1"))
monkeypatch.setattr(
workflow_module,
"WorkflowService",
lambda: SimpleNamespace(
restore_published_workflow_to_draft=lambda **_kwargs: (_ for _ in ()).throw(
workflow_module.WorkflowNotFoundError("Workflow not found")
)
),
)
api = workflow_module.DraftWorkflowRestoreApi()
handler = _unwrap(api.post)
with app.test_request_context(
"/apps/app/workflows/published-workflow/restore",
method="POST",
):
with pytest.raises(NotFound):
handler(
api,
app_model=SimpleNamespace(id="app", tenant_id="tenant-1"),
workflow_id="published-workflow",
)
def test_restore_published_workflow_to_draft_returns_400_for_draft_source(app, monkeypatch: pytest.MonkeyPatch) -> None:
user = SimpleNamespace(id="account-1")
monkeypatch.setattr(workflow_module, "current_account_with_tenant", lambda: (user, "t1"))
monkeypatch.setattr(
workflow_module,
"WorkflowService",
lambda: SimpleNamespace(
restore_published_workflow_to_draft=lambda **_kwargs: (_ for _ in ()).throw(
workflow_module.IsDraftWorkflowError(
"Cannot use draft workflow version. Workflow ID: draft-workflow. "
"Please use a published workflow version or leave workflow_id empty."
)
)
),
)
api = workflow_module.DraftWorkflowRestoreApi()
handler = _unwrap(api.post)
with app.test_request_context(
"/apps/app/workflows/draft-workflow/restore",
method="POST",
):
with pytest.raises(HTTPException) as exc:
handler(
api,
app_model=SimpleNamespace(id="app", tenant_id="tenant-1"),
workflow_id="draft-workflow",
)
assert exc.value.code == 400
assert exc.value.description == workflow_module.RESTORE_SOURCE_WORKFLOW_MUST_BE_PUBLISHED_MESSAGE
def test_restore_published_workflow_to_draft_returns_400_for_invalid_structure(
app, monkeypatch: pytest.MonkeyPatch
) -> None:
user = SimpleNamespace(id="account-1")
monkeypatch.setattr(workflow_module, "current_account_with_tenant", lambda: (user, "t1"))
monkeypatch.setattr(
workflow_module,
"WorkflowService",
lambda: SimpleNamespace(
restore_published_workflow_to_draft=lambda **_kwargs: (_ for _ in ()).throw(
ValueError("invalid workflow graph")
)
),
)
api = workflow_module.DraftWorkflowRestoreApi()
handler = _unwrap(api.post)
with app.test_request_context(
"/apps/app/workflows/published-workflow/restore",
method="POST",
):
with pytest.raises(HTTPException) as exc:
handler(
api,
app_model=SimpleNamespace(id="app", tenant_id="tenant-1"),
workflow_id="published-workflow",
)
assert exc.value.code == 400
assert exc.value.description == "invalid workflow graph"
def test_draft_workflow_get_not_found(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
workflow_module, "WorkflowService", lambda: SimpleNamespace(get_draft_workflow=lambda **_k: None)

View File

@ -2,7 +2,7 @@ from datetime import datetime
from unittest.mock import MagicMock, patch
import pytest
from werkzeug.exceptions import Forbidden, NotFound
from werkzeug.exceptions import Forbidden, HTTPException, NotFound
import services
from controllers.console import console_ns
@ -19,13 +19,14 @@ from controllers.console.datasets.rag_pipeline.rag_pipeline_workflow import (
RagPipelineDraftNodeRunApi,
RagPipelineDraftRunIterationNodeApi,
RagPipelineDraftRunLoopNodeApi,
RagPipelineDraftWorkflowRestoreApi,
RagPipelineRecommendedPluginApi,
RagPipelineTaskStopApi,
RagPipelineTransformApi,
RagPipelineWorkflowLastRunApi,
)
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
from services.errors.app import WorkflowHashNotEqualError
from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError
from services.errors.llm import InvokeRateLimitError
@ -116,6 +117,86 @@ class TestDraftWorkflowApi:
response, status = method(api, pipeline)
assert status == 400
def test_restore_published_workflow_to_draft_success(self, app):
api = RagPipelineDraftWorkflowRestoreApi()
method = unwrap(api.post)
pipeline = MagicMock()
user = MagicMock(id="account-1")
workflow = MagicMock(unique_hash="restored-hash", updated_at=None, created_at=datetime(2024, 1, 1))
service = MagicMock()
service.restore_published_workflow_to_draft.return_value = workflow
with (
app.test_request_context("/", method="POST"),
patch(
"controllers.console.datasets.rag_pipeline.rag_pipeline_workflow.current_account_with_tenant",
return_value=(user, "t"),
),
patch(
"controllers.console.datasets.rag_pipeline.rag_pipeline_workflow.RagPipelineService",
return_value=service,
),
):
result = method(api, pipeline, "published-workflow")
assert result["result"] == "success"
assert result["hash"] == "restored-hash"
def test_restore_published_workflow_to_draft_not_found(self, app):
api = RagPipelineDraftWorkflowRestoreApi()
method = unwrap(api.post)
pipeline = MagicMock()
user = MagicMock(id="account-1")
service = MagicMock()
service.restore_published_workflow_to_draft.side_effect = WorkflowNotFoundError("Workflow not found")
with (
app.test_request_context("/", method="POST"),
patch(
"controllers.console.datasets.rag_pipeline.rag_pipeline_workflow.current_account_with_tenant",
return_value=(user, "t"),
),
patch(
"controllers.console.datasets.rag_pipeline.rag_pipeline_workflow.RagPipelineService",
return_value=service,
),
):
with pytest.raises(NotFound):
method(api, pipeline, "published-workflow")
def test_restore_published_workflow_to_draft_returns_400_for_draft_source(self, app):
api = RagPipelineDraftWorkflowRestoreApi()
method = unwrap(api.post)
pipeline = MagicMock()
user = MagicMock(id="account-1")
service = MagicMock()
service.restore_published_workflow_to_draft.side_effect = IsDraftWorkflowError(
"source workflow must be published"
)
with (
app.test_request_context("/", method="POST"),
patch(
"controllers.console.datasets.rag_pipeline.rag_pipeline_workflow.current_account_with_tenant",
return_value=(user, "t"),
),
patch(
"controllers.console.datasets.rag_pipeline.rag_pipeline_workflow.RagPipelineService",
return_value=service,
),
):
with pytest.raises(HTTPException) as exc:
method(api, pipeline, "draft-workflow")
assert exc.value.code == 400
assert exc.value.description == "source workflow must be published"
class TestDraftRunNodes:
def test_iteration_node_success(self, app):

View File

@ -4,12 +4,18 @@ from unittest import mock
from uuid import uuid4
from constants import HIDDEN_VALUE
from core.helper import encrypter
from dify_graph.file.enums import FileTransferMethod, FileType
from dify_graph.file.models import File
from dify_graph.variables import FloatVariable, IntegerVariable, SecretVariable, StringVariable
from dify_graph.variables.segments import IntegerSegment, Segment
from factories.variable_factory import build_segment
from models.workflow import Workflow, WorkflowDraftVariable, WorkflowNodeExecutionModel, is_system_variable_editable
from models.workflow import (
Workflow,
WorkflowDraftVariable,
WorkflowNodeExecutionModel,
is_system_variable_editable,
)
def test_environment_variables():
@ -144,6 +150,36 @@ def test_to_dict():
assert workflow_dict["environment_variables"][1]["value"] == "text"
def test_normalize_environment_variable_mappings_converts_full_mask_to_hidden_value():
normalized = Workflow.normalize_environment_variable_mappings(
[
{
"id": str(uuid4()),
"name": "secret",
"value": encrypter.full_mask_token(),
"value_type": "secret",
}
]
)
assert normalized[0]["value"] == HIDDEN_VALUE
def test_normalize_environment_variable_mappings_keeps_hidden_value():
normalized = Workflow.normalize_environment_variable_mappings(
[
{
"id": str(uuid4()),
"name": "secret",
"value": HIDDEN_VALUE,
"value_type": "secret",
}
]
)
assert normalized[0]["value"] == HIDDEN_VALUE
class TestWorkflowNodeExecution:
def test_execution_metadata_dict(self):
node_exec = WorkflowNodeExecutionModel()

View File

@ -544,6 +544,89 @@ class TestWorkflowService:
conversation_variables=[],
)
def test_restore_published_workflow_to_draft_keeps_source_features_unmodified(
self, workflow_service, mock_db_session
):
app = TestWorkflowAssociatedDataFactory.create_app_mock()
account = TestWorkflowAssociatedDataFactory.create_account_mock()
legacy_features = {
"file_upload": {
"image": {
"enabled": True,
"number_limits": 6,
"transfer_methods": ["remote_url", "local_file"],
}
},
"opening_statement": "",
"retriever_resource": {"enabled": True},
"sensitive_word_avoidance": {"enabled": False},
"speech_to_text": {"enabled": False},
"suggested_questions": [],
"suggested_questions_after_answer": {"enabled": False},
"text_to_speech": {"enabled": False, "language": "", "voice": ""},
}
normalized_features = {
"file_upload": {
"enabled": True,
"allowed_file_types": ["image"],
"allowed_file_extensions": [],
"allowed_file_upload_methods": ["remote_url", "local_file"],
"number_limits": 6,
},
"opening_statement": "",
"retriever_resource": {"enabled": True},
"sensitive_word_avoidance": {"enabled": False},
"speech_to_text": {"enabled": False},
"suggested_questions": [],
"suggested_questions_after_answer": {"enabled": False},
"text_to_speech": {"enabled": False, "language": "", "voice": ""},
}
source_workflow = Workflow(
id="published-workflow-id",
tenant_id=app.tenant_id,
app_id=app.id,
type=WorkflowType.WORKFLOW.value,
version="2026-03-19T00:00:00",
graph=json.dumps(TestWorkflowAssociatedDataFactory.create_valid_workflow_graph()),
features=json.dumps(legacy_features),
created_by=account.id,
environment_variables=[],
conversation_variables=[],
rag_pipeline_variables=[],
)
draft_workflow = Workflow(
id="draft-workflow-id",
tenant_id=app.tenant_id,
app_id=app.id,
type=WorkflowType.WORKFLOW.value,
version=Workflow.VERSION_DRAFT,
graph=json.dumps({"nodes": [], "edges": []}),
features=json.dumps({}),
created_by=account.id,
environment_variables=[],
conversation_variables=[],
rag_pipeline_variables=[],
)
with (
patch.object(workflow_service, "get_published_workflow_by_id", return_value=source_workflow),
patch.object(workflow_service, "get_draft_workflow", return_value=draft_workflow),
patch.object(workflow_service, "validate_graph_structure"),
patch.object(workflow_service, "validate_features_structure") as mock_validate_features,
patch("services.workflow_service.app_draft_workflow_was_synced"),
):
result = workflow_service.restore_published_workflow_to_draft(
app_model=app,
workflow_id=source_workflow.id,
account=account,
)
mock_validate_features.assert_called_once_with(app_model=app, features=normalized_features)
assert result is draft_workflow
assert source_workflow.serialized_features == json.dumps(legacy_features)
assert draft_workflow.serialized_features == json.dumps(legacy_features)
mock_db_session.session.commit.assert_called_once()
# ==================== Workflow Validation Tests ====================
# These tests verify graph structure and feature configuration validation

View File

@ -0,0 +1,77 @@
import json
from types import SimpleNamespace
from models.workflow import Workflow
from services.workflow_restore import apply_published_workflow_snapshot_to_draft
LEGACY_FEATURES = {
"file_upload": {
"image": {
"enabled": True,
"number_limits": 6,
"transfer_methods": ["remote_url", "local_file"],
}
},
"opening_statement": "",
"retriever_resource": {"enabled": True},
"sensitive_word_avoidance": {"enabled": False},
"speech_to_text": {"enabled": False},
"suggested_questions": [],
"suggested_questions_after_answer": {"enabled": False},
"text_to_speech": {"enabled": False, "language": "", "voice": ""},
}
NORMALIZED_FEATURES = {
"file_upload": {
"enabled": True,
"allowed_file_types": ["image"],
"allowed_file_extensions": [],
"allowed_file_upload_methods": ["remote_url", "local_file"],
"number_limits": 6,
},
"opening_statement": "",
"retriever_resource": {"enabled": True},
"sensitive_word_avoidance": {"enabled": False},
"speech_to_text": {"enabled": False},
"suggested_questions": [],
"suggested_questions_after_answer": {"enabled": False},
"text_to_speech": {"enabled": False, "language": "", "voice": ""},
}
def _create_workflow(*, workflow_id: str, version: str, features: dict[str, object]) -> Workflow:
return Workflow(
id=workflow_id,
tenant_id="tenant-id",
app_id="app-id",
type="workflow",
version=version,
graph=json.dumps({"nodes": [], "edges": []}),
features=json.dumps(features),
created_by="account-id",
environment_variables=[],
conversation_variables=[],
rag_pipeline_variables=[],
)
def test_apply_published_workflow_snapshot_to_draft_copies_serialized_features_without_mutating_source() -> None:
source_workflow = _create_workflow(
workflow_id="published-workflow-id",
version="2026-03-19T00:00:00",
features=LEGACY_FEATURES,
)
draft_workflow, is_new_draft = apply_published_workflow_snapshot_to_draft(
tenant_id="tenant-id",
app_id="app-id",
source_workflow=source_workflow,
draft_workflow=None,
account=SimpleNamespace(id="account-id"),
updated_at_factory=lambda: source_workflow.updated_at,
)
assert is_new_draft is True
assert source_workflow.serialized_features == json.dumps(LEGACY_FEATURES)
assert source_workflow.normalized_features_dict == NORMALIZED_FEATURES
assert draft_workflow.serialized_features == json.dumps(LEGACY_FEATURES)

View File

@ -1,4 +1,4 @@
import { fireEvent, render, screen, within } from '@testing-library/react'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import * as React from 'react'
import { AppModeEnum } from '@/types/app'
import AppTypeSelector, { AppTypeIcon, AppTypeLabel } from './index'
@ -14,7 +14,7 @@ describe('AppTypeSelector', () => {
render(<AppTypeSelector value={[]} onChange={vi.fn()} />)
expect(screen.getByText('app.typeSelector.all')).toBeInTheDocument()
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
expect(screen.queryByText('app.typeSelector.workflow')).not.toBeInTheDocument()
})
})
@ -39,24 +39,27 @@ describe('AppTypeSelector', () => {
// Covers opening/closing the dropdown and selection updates.
describe('User interactions', () => {
it('should toggle option list when clicking the trigger', () => {
it('should close option list when clicking outside', () => {
render(<AppTypeSelector value={[]} onChange={vi.fn()} />)
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
expect(screen.queryByRole('list')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('app.typeSelector.all'))
expect(screen.getByRole('tooltip')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'app.typeSelector.all' }))
expect(screen.getByRole('list')).toBeInTheDocument()
fireEvent.click(screen.getByText('app.typeSelector.all'))
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
fireEvent.pointerDown(document.body)
fireEvent.click(document.body)
return waitFor(() => {
expect(screen.queryByRole('list')).not.toBeInTheDocument()
})
})
it('should call onChange with added type when selecting an unselected item', () => {
const onChange = vi.fn()
render(<AppTypeSelector value={[]} onChange={onChange} />)
fireEvent.click(screen.getByText('app.typeSelector.all'))
fireEvent.click(within(screen.getByRole('tooltip')).getByText('app.typeSelector.workflow'))
fireEvent.click(screen.getByRole('button', { name: 'app.typeSelector.all' }))
fireEvent.click(within(screen.getByRole('list')).getByRole('button', { name: 'app.typeSelector.workflow' }))
expect(onChange).toHaveBeenCalledWith([AppModeEnum.WORKFLOW])
})
@ -65,8 +68,8 @@ describe('AppTypeSelector', () => {
const onChange = vi.fn()
render(<AppTypeSelector value={[AppModeEnum.WORKFLOW]} onChange={onChange} />)
fireEvent.click(screen.getByText('app.typeSelector.workflow'))
fireEvent.click(within(screen.getByRole('tooltip')).getByText('app.typeSelector.workflow'))
fireEvent.click(screen.getByRole('button', { name: 'app.typeSelector.workflow' }))
fireEvent.click(within(screen.getByRole('list')).getByRole('button', { name: 'app.typeSelector.workflow' }))
expect(onChange).toHaveBeenCalledWith([])
})
@ -75,8 +78,8 @@ describe('AppTypeSelector', () => {
const onChange = vi.fn()
render(<AppTypeSelector value={[AppModeEnum.CHAT]} onChange={onChange} />)
fireEvent.click(screen.getByText('app.typeSelector.chatbot'))
fireEvent.click(within(screen.getByRole('tooltip')).getByText('app.typeSelector.agent'))
fireEvent.click(screen.getByRole('button', { name: 'app.typeSelector.chatbot' }))
fireEvent.click(within(screen.getByRole('list')).getByRole('button', { name: 'app.typeSelector.agent' }))
expect(onChange).toHaveBeenCalledWith([AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT])
})
@ -88,7 +91,7 @@ describe('AppTypeSelector', () => {
fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' }))
expect(onChange).toHaveBeenCalledWith([])
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
expect(screen.queryByText('app.typeSelector.workflow')).not.toBeInTheDocument()
})
})
})

View File

@ -4,13 +4,12 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { BubbleTextMod, ChatBot, ListSparkle, Logic } from '@/app/components/base/icons/src/vender/solid/communication'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
Popover,
PopoverContent,
PopoverTrigger,
} from '@/app/components/base/ui/popover'
import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames'
import Checkbox from '../../base/checkbox'
export type AppSelectorProps = {
value: Array<AppModeEnum>
@ -22,43 +21,43 @@ const allTypes: AppModeEnum[] = [AppModeEnum.WORKFLOW, AppModeEnum.ADVANCED_CHAT
const AppTypeSelector = ({ value, onChange }: AppSelectorProps) => {
const [open, setOpen] = useState(false)
const { t } = useTranslation()
const triggerLabel = value.length === 0
? t('typeSelector.all', { ns: 'app' })
: value.map(type => getAppTypeLabel(type, t)).join(', ')
return (
<PortalToFollowElem
<Popover
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={4}
>
<div className="relative">
<PortalToFollowElemTrigger
onClick={() => setOpen(v => !v)}
className="block"
>
<div className={cn(
'flex cursor-pointer items-center justify-between space-x-1 rounded-md px-2 hover:bg-state-base-hover',
<PopoverTrigger
aria-label={triggerLabel}
className={cn(
'flex cursor-pointer items-center justify-between rounded-md px-2 hover:bg-state-base-hover',
value.length > 0 && 'pr-7',
)}
>
<AppTypeSelectTrigger values={value} />
</PopoverTrigger>
{value.length > 0 && (
<button
type="button"
aria-label={t('operation.clear', { ns: 'common' })}
className="group absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2"
onClick={() => onChange([])}
>
<AppTypeSelectTrigger values={value} />
{value && value.length > 0 && (
<button
type="button"
aria-label={t('operation.clear', { ns: 'common' })}
className="group h-4 w-4"
onClick={(e) => {
e.stopPropagation()
onChange([])
}}
>
<RiCloseCircleFill
className="h-3.5 w-3.5 text-text-quaternary group-hover:text-text-tertiary"
/>
</button>
)}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[1002]">
<ul className="relative w-[240px] rounded-xl border border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]">
<RiCloseCircleFill
className="h-3.5 w-3.5 text-text-quaternary group-hover:text-text-tertiary"
/>
</button>
)}
<PopoverContent
placement="bottom-start"
sideOffset={4}
popupClassName="w-[240px] rounded-xl border border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]"
>
<ul className="relative w-full p-1">
{allTypes.map(mode => (
<AppTypeSelectorItem
key={mode}
@ -73,9 +72,9 @@ const AppTypeSelector = ({ value, onChange }: AppSelectorProps) => {
/>
))}
</ul>
</PortalToFollowElemContent>
</PopoverContent>
</div>
</PortalToFollowElem>
</Popover>
)
}
@ -173,33 +172,54 @@ type AppTypeSelectorItemProps = {
}
function AppTypeSelectorItem({ checked, type, onClick }: AppTypeSelectorItemProps) {
return (
<li className="flex cursor-pointer items-center space-x-2 rounded-lg py-1 pl-2 pr-1 hover:bg-state-base-hover" onClick={onClick}>
<Checkbox checked={checked} />
<AppTypeIcon type={type} />
<div className="grow p-1 pl-0">
<AppTypeLabel type={type} className="system-sm-medium text-components-menu-item-text" />
</div>
<li>
<button
type="button"
className="flex w-full items-center space-x-2 rounded-lg py-1 pl-2 pr-1 text-left hover:bg-state-base-hover"
aria-pressed={checked}
onClick={onClick}
>
<span
aria-hidden="true"
className={cn(
'flex h-4 w-4 shrink-0 items-center justify-center rounded-[4px] shadow-xs shadow-shadow-shadow-3',
checked
? 'bg-components-checkbox-bg text-components-checkbox-icon'
: 'border border-components-checkbox-border bg-components-checkbox-bg-unchecked',
)}
>
{checked && <span className="i-ri-check-line h-3 w-3" />}
</span>
<AppTypeIcon type={type} />
<div className="grow p-1 pl-0">
<AppTypeLabel type={type} className="system-sm-medium text-components-menu-item-text" />
</div>
</button>
</li>
)
}
function getAppTypeLabel(type: AppModeEnum, t: ReturnType<typeof useTranslation>['t']) {
if (type === AppModeEnum.CHAT)
return t('typeSelector.chatbot', { ns: 'app' })
if (type === AppModeEnum.AGENT_CHAT)
return t('typeSelector.agent', { ns: 'app' })
if (type === AppModeEnum.COMPLETION)
return t('typeSelector.completion', { ns: 'app' })
if (type === AppModeEnum.ADVANCED_CHAT)
return t('typeSelector.advanced', { ns: 'app' })
if (type === AppModeEnum.WORKFLOW)
return t('typeSelector.workflow', { ns: 'app' })
return ''
}
type AppTypeLabelProps = {
type: AppModeEnum
className?: string
}
export function AppTypeLabel({ type, className }: AppTypeLabelProps) {
const { t } = useTranslation()
let label = ''
if (type === AppModeEnum.CHAT)
label = t('typeSelector.chatbot', { ns: 'app' })
if (type === AppModeEnum.AGENT_CHAT)
label = t('typeSelector.agent', { ns: 'app' })
if (type === AppModeEnum.COMPLETION)
label = t('typeSelector.completion', { ns: 'app' })
if (type === AppModeEnum.ADVANCED_CHAT)
label = t('typeSelector.advanced', { ns: 'app' })
if (type === AppModeEnum.WORKFLOW)
label = t('typeSelector.workflow', { ns: 'app' })
return <span className={className}>{label}</span>
return <span className={className}>{getAppTypeLabel(type, t)}</span>
}

View File

@ -13,12 +13,20 @@ vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: vi.fn(),
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
const { mockToastNotify } = vi.hoisted(() => ({
mockToastNotify: vi.fn(),
}))
vi.mock('@/app/components/base/toast', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/toast')>()
return {
...actual,
default: Object.assign(actual.default, {
notify: mockToastNotify,
}),
}
})
const mockCreateEmptyDataset = vi.fn()
const mockInvalidDatasetList = vi.fn()
@ -37,6 +45,8 @@ vi.mock('@/service/knowledge/use-dataset', () => ({
describe('CreateCard', () => {
beforeEach(() => {
vi.clearAllMocks()
mockToastNotify.mockReset()
mockToastNotify.mockImplementation(() => ({ clear: vi.fn() }))
})
describe('Rendering', () => {

View File

@ -1,8 +1,6 @@
import type { PipelineTemplate } from '@/models/pipeline'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Toast from '@/app/components/base/toast'
import { ChunkingMode } from '@/models/datasets'
import EditPipelineInfo from '../edit-pipeline-info'
@ -16,12 +14,21 @@ vi.mock('@/service/use-pipeline', () => ({
useInvalidCustomizedTemplateList: () => mockInvalidCustomizedTemplateList,
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
const { mockToastAdd } = vi.hoisted(() => ({
mockToastAdd: vi.fn(),
}))
vi.mock('@/app/components/base/ui/toast', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/ui/toast')>()
return {
...actual,
toast: {
...actual.toast,
add: mockToastAdd,
},
}
})
// Mock AppIconPicker to capture interactions
let _mockOnSelect: ((icon: { type: 'emoji' | 'image', icon?: string, background?: string, fileId?: string, url?: string }) => void) | undefined
let _mockOnClose: (() => void) | undefined
@ -88,6 +95,7 @@ describe('EditPipelineInfo', () => {
beforeEach(() => {
vi.clearAllMocks()
mockToastAdd.mockReset()
_mockOnSelect = undefined
_mockOnClose = undefined
})
@ -235,9 +243,9 @@ describe('EditPipelineInfo', () => {
fireEvent.click(saveButton)
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
message: 'Please enter a name for the Knowledge Base.',
title: 'datasetPipeline.editPipelineInfoNameRequired',
})
})
})

View File

@ -1,7 +1,6 @@
import type { PipelineTemplate } from '@/models/pipeline'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Toast from '@/app/components/base/toast'
import { ChunkingMode } from '@/models/datasets'
import TemplateCard from '../index'
@ -15,12 +14,21 @@ vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: vi.fn(),
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
const { mockToastAdd } = vi.hoisted(() => ({
mockToastAdd: vi.fn(),
}))
vi.mock('@/app/components/base/ui/toast', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/ui/toast')>()
return {
...actual,
toast: {
...actual.toast,
add: mockToastAdd,
},
}
})
// Mock download utilities
vi.mock('@/utils/download', () => ({
downloadBlob: vi.fn(),
@ -174,6 +182,7 @@ describe('TemplateCard', () => {
beforeEach(() => {
vi.clearAllMocks()
mockToastAdd.mockReset()
mockIsExporting = false
_capturedOnConfirm = undefined
_capturedOnCancel = undefined
@ -228,9 +237,9 @@ describe('TemplateCard', () => {
fireEvent.click(chooseButton)
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
message: expect.any(String),
title: expect.any(String),
})
})
})
@ -291,9 +300,9 @@ describe('TemplateCard', () => {
fireEvent.click(chooseButton)
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'success',
message: expect.any(String),
title: expect.any(String),
})
})
})
@ -309,9 +318,9 @@ describe('TemplateCard', () => {
fireEvent.click(chooseButton)
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
message: expect.any(String),
title: expect.any(String),
})
})
})
@ -458,9 +467,9 @@ describe('TemplateCard', () => {
fireEvent.click(exportButton)
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'success',
message: expect.any(String),
title: expect.any(String),
})
})
})
@ -476,9 +485,9 @@ describe('TemplateCard', () => {
fireEvent.click(exportButton)
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
message: expect.any(String),
title: expect.any(String),
})
})
})

View File

@ -32,16 +32,21 @@ vi.mock('@/service/base', () => ({
ssePost: mockSsePost,
}))
// Mock Toast.notify - static method that manipulates DOM, needs mocking to verify calls
const { mockToastNotify } = vi.hoisted(() => ({
mockToastNotify: vi.fn(),
// Mock toast.add because the component reports errors through the UI toast manager.
const { mockToastAdd } = vi.hoisted(() => ({
mockToastAdd: vi.fn(),
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: mockToastNotify,
},
}))
vi.mock('@/app/components/base/ui/toast', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/ui/toast')>()
return {
...actual,
toast: {
...actual.toast,
add: mockToastAdd,
},
}
})
// Mock useGetDataSourceAuth - API service hook requires mocking
const { mockUseGetDataSourceAuth } = vi.hoisted(() => ({
@ -192,6 +197,7 @@ const createDefaultProps = (overrides?: Partial<OnlineDocumentsProps>): OnlineDo
describe('OnlineDocuments', () => {
beforeEach(() => {
vi.clearAllMocks()
mockToastAdd.mockReset()
// Reset store state
mockStoreState.documentsData = []
@ -509,9 +515,9 @@ describe('OnlineDocuments', () => {
render(<OnlineDocuments {...props} />)
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
message: 'Something went wrong',
title: 'Something went wrong',
})
})
})
@ -774,9 +780,9 @@ describe('OnlineDocuments', () => {
render(<OnlineDocuments {...props} />)
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
message: 'API Error Message',
title: 'API Error Message',
})
})
})
@ -1094,9 +1100,9 @@ describe('OnlineDocuments', () => {
render(<OnlineDocuments {...props} />)
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
message: 'Failed to fetch documents',
title: 'Failed to fetch documents',
})
})

View File

@ -45,15 +45,20 @@ vi.mock('@/service/use-datasource', () => ({
useGetDataSourceAuth: mockUseGetDataSourceAuth,
}))
const { mockToastNotify } = vi.hoisted(() => ({
mockToastNotify: vi.fn(),
const { mockToastAdd } = vi.hoisted(() => ({
mockToastAdd: vi.fn(),
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: mockToastNotify,
},
}))
vi.mock('@/app/components/base/ui/toast', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/ui/toast')>()
return {
...actual,
toast: {
...actual.toast,
add: mockToastAdd,
},
}
})
// Note: zustand/react/shallow useShallow is imported directly (simple utility function)
@ -231,6 +236,7 @@ const resetMockStoreState = () => {
describe('OnlineDrive', () => {
beforeEach(() => {
vi.clearAllMocks()
mockToastAdd.mockReset()
// Reset store state
resetMockStoreState()
@ -541,9 +547,9 @@ describe('OnlineDrive', () => {
render(<OnlineDrive {...props} />)
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
message: errorMessage,
title: errorMessage,
})
})
})
@ -915,9 +921,9 @@ describe('OnlineDrive', () => {
render(<OnlineDrive {...props} />)
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
message: errorMessage,
title: errorMessage,
})
})
})

View File

@ -1,13 +1,26 @@
import type { MockInstance } from 'vitest'
import type { RAGPipelineVariables } from '@/models/pipeline'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { BaseFieldType } from '@/app/components/base/form/form-scenarios/base/types'
import Toast from '@/app/components/base/toast'
import { CrawlStep } from '@/models/datasets'
import { PipelineInputVarType } from '@/models/pipeline'
import Options from '../index'
const { mockToastAdd } = vi.hoisted(() => ({
mockToastAdd: vi.fn(),
}))
vi.mock('@/app/components/base/ui/toast', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/ui/toast')>()
return {
...actual,
toast: {
...actual.toast,
add: mockToastAdd,
},
}
})
// Mock useInitialData and useConfigurations hooks
const { mockUseInitialData, mockUseConfigurations } = vi.hoisted(() => ({
mockUseInitialData: vi.fn(),
@ -116,13 +129,9 @@ const createDefaultProps = (overrides?: Partial<OptionsProps>): OptionsProps =>
})
describe('Options', () => {
let toastNotifySpy: MockInstance
beforeEach(() => {
vi.clearAllMocks()
// Spy on Toast.notify instead of mocking the entire module
toastNotifySpy = vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
mockToastAdd.mockReset()
// Reset mock form values
Object.keys(mockFormValues).forEach(key => delete mockFormValues[key])
@ -132,10 +141,6 @@ describe('Options', () => {
mockUseConfigurations.mockReturnValue([createMockConfiguration()])
})
afterEach(() => {
toastNotifySpy.mockRestore()
})
describe('Rendering', () => {
it('should render without crashing', () => {
const props = createDefaultProps()
@ -638,7 +643,7 @@ describe('Options', () => {
fireEvent.click(screen.getByRole('button'))
// Assert - Toast should be called with error message
expect(toastNotifySpy).toHaveBeenCalledWith(
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
}),
@ -660,10 +665,10 @@ describe('Options', () => {
fireEvent.click(screen.getByRole('button'))
// Assert - Toast message should contain field path
expect(toastNotifySpy).toHaveBeenCalledWith(
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
message: expect.stringContaining('email_address'),
title: expect.stringContaining('email_address'),
}),
)
})
@ -714,8 +719,8 @@ describe('Options', () => {
fireEvent.click(screen.getByRole('button'))
// Assert - Toast should be called once (only first error)
expect(toastNotifySpy).toHaveBeenCalledTimes(1)
expect(toastNotifySpy).toHaveBeenCalledWith(
expect(mockToastAdd).toHaveBeenCalledTimes(1)
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
}),
@ -738,7 +743,7 @@ describe('Options', () => {
fireEvent.click(screen.getByRole('button'))
// Assert - No toast error, onSubmit called
expect(toastNotifySpy).not.toHaveBeenCalled()
expect(mockToastAdd).not.toHaveBeenCalled()
expect(mockOnSubmit).toHaveBeenCalled()
})
@ -835,7 +840,7 @@ describe('Options', () => {
fireEvent.click(screen.getByRole('button'))
expect(mockOnSubmit).toHaveBeenCalled()
expect(toastNotifySpy).not.toHaveBeenCalled()
expect(mockToastAdd).not.toHaveBeenCalled()
})
it('should fail validation with invalid data', () => {
@ -854,7 +859,7 @@ describe('Options', () => {
fireEvent.click(screen.getByRole('button'))
expect(mockOnSubmit).not.toHaveBeenCalled()
expect(toastNotifySpy).toHaveBeenCalled()
expect(mockToastAdd).toHaveBeenCalled()
})
it('should show error toast message when validation fails', () => {
@ -871,10 +876,10 @@ describe('Options', () => {
fireEvent.click(screen.getByRole('button'))
expect(toastNotifySpy).toHaveBeenCalledWith(
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
message: expect.any(String),
title: expect.any(String),
}),
)
})

View File

@ -1,13 +1,24 @@
import type { NotionPage } from '@/models/common'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import Toast from '@/app/components/base/toast'
import OnlineDocumentPreview from '../online-document-preview'
// Uses global react-i18next mock from web/vitest.setup.ts
// Spy on Toast.notify
const toastNotifySpy = vi.spyOn(Toast, 'notify')
const { mockToastAdd } = vi.hoisted(() => ({
mockToastAdd: vi.fn(),
}))
vi.mock('@/app/components/base/ui/toast', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/ui/toast')>()
return {
...actual,
toast: {
...actual.toast,
add: mockToastAdd,
},
}
})
// Mock dataset-detail context - needs mock to control return values
const mockPipelineId = vi.fn()
@ -56,6 +67,7 @@ const defaultProps = {
describe('OnlineDocumentPreview', () => {
beforeEach(() => {
vi.clearAllMocks()
mockToastAdd.mockReset()
mockPipelineId.mockReturnValue('pipeline-123')
mockUsePreviewOnlineDocument.mockReturnValue({
mutateAsync: mockMutateAsync,
@ -258,9 +270,9 @@ describe('OnlineDocumentPreview', () => {
render(<OnlineDocumentPreview {...defaultProps} />)
await waitFor(() => {
expect(toastNotifySpy).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
message: errorMessage,
title: errorMessage,
})
})
})
@ -276,9 +288,9 @@ describe('OnlineDocumentPreview', () => {
render(<OnlineDocumentPreview {...defaultProps} />)
await waitFor(() => {
expect(toastNotifySpy).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
message: 'Network Error',
title: 'Network Error',
})
})
})

View File

@ -3,13 +3,24 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import * as z from 'zod'
import { BaseFieldType } from '@/app/components/base/form/form-scenarios/base/types'
import Toast from '@/app/components/base/toast'
import Actions from '../actions'
import Form from '../form'
import Header from '../header'
// Spy on Toast.notify for validation tests
const toastNotifySpy = vi.spyOn(Toast, 'notify')
const { mockToastAdd } = vi.hoisted(() => ({
mockToastAdd: vi.fn(),
}))
vi.mock('@/app/components/base/ui/toast', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/ui/toast')>()
return {
...actual,
toast: {
...actual.toast,
add: mockToastAdd,
},
}
})
// Test Data Factory Functions
@ -335,7 +346,7 @@ describe('Form', () => {
beforeEach(() => {
vi.clearAllMocks()
toastNotifySpy.mockClear()
mockToastAdd.mockReset()
})
describe('Rendering', () => {
@ -444,9 +455,9 @@ describe('Form', () => {
// Assert - validation error should be shown
await waitFor(() => {
expect(toastNotifySpy).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
message: '"field1" is required',
title: '"field1" is required',
})
})
})
@ -566,9 +577,9 @@ describe('Form', () => {
fireEvent.submit(form)
await waitFor(() => {
expect(toastNotifySpy).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
message: '"field1" is required',
title: '"field1" is required',
})
})
})
@ -583,7 +594,7 @@ describe('Form', () => {
// Assert - wait a bit and verify onSubmit was not called
await waitFor(() => {
expect(toastNotifySpy).toHaveBeenCalled()
expect(mockToastAdd).toHaveBeenCalled()
})
expect(onSubmit).not.toHaveBeenCalled()
})

View File

@ -2,10 +2,23 @@ import type { BaseConfiguration } from '@/app/components/base/form/form-scenario
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { z } from 'zod'
import Toast from '@/app/components/base/toast'
import Form from '../form'
const { mockToastAdd } = vi.hoisted(() => ({
mockToastAdd: vi.fn(),
}))
vi.mock('@/app/components/base/ui/toast', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/ui/toast')>()
return {
...actual,
toast: {
...actual.toast,
add: mockToastAdd,
},
}
})
// Mock the Header component (sibling component, not a base component)
vi.mock('../header', () => ({
default: ({ onReset, resetDisabled, onPreview, previewDisabled }: {
@ -44,7 +57,7 @@ const defaultProps = {
describe('Form (process-documents)', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
mockToastAdd.mockReset()
})
// Verify basic rendering of form structure
@ -106,8 +119,11 @@ describe('Form (process-documents)', () => {
fireEvent.submit(form)
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
title: '"name" Name is required',
}),
)
})
})
@ -121,7 +137,7 @@ describe('Form (process-documents)', () => {
await waitFor(() => {
expect(defaultProps.onSubmit).toHaveBeenCalled()
})
expect(Toast.notify).not.toHaveBeenCalled()
expect(mockToastAdd).not.toHaveBeenCalled()
})
})

View File

@ -164,7 +164,7 @@ describe('ExternalKnowledgeBaseConnector', () => {
// Verify success notification
expect(mockNotify).toHaveBeenCalledWith({
type: 'success',
title: 'External Knowledge Base Connected Successfully',
title: 'dataset.externalKnowledgeForm.connectedSuccess',
})
// Verify navigation back
@ -206,7 +206,7 @@ describe('ExternalKnowledgeBaseConnector', () => {
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
title: 'Failed to connect External Knowledge Base',
title: 'dataset.externalKnowledgeForm.connectedFailed',
})
})
@ -228,7 +228,7 @@ describe('ExternalKnowledgeBaseConnector', () => {
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
title: 'Failed to connect External Knowledge Base',
title: 'dataset.externalKnowledgeForm.connectedFailed',
})
})
@ -274,7 +274,7 @@ describe('ExternalKnowledgeBaseConnector', () => {
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'success',
title: 'External Knowledge Base Connected Successfully',
title: 'dataset.externalKnowledgeForm.connectedSuccess',
})
})
})

View File

@ -3,6 +3,7 @@
import type { CreateKnowledgeBaseReq } from '@/app/components/datasets/external-knowledge-base/create/declarations'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { trackEvent } from '@/app/components/base/amplitude'
import { toast } from '@/app/components/base/ui/toast'
import ExternalKnowledgeBaseCreate from '@/app/components/datasets/external-knowledge-base/create'
@ -12,13 +13,14 @@ import { createExternalKnowledgeBase } from '@/service/datasets'
const ExternalKnowledgeBaseConnector = () => {
const [loading, setLoading] = useState(false)
const router = useRouter()
const { t } = useTranslation()
const handleConnect = async (formValue: CreateKnowledgeBaseReq) => {
try {
setLoading(true)
const result = await createExternalKnowledgeBase({ body: formValue })
if (result && result.id) {
toast.add({ type: 'success', title: 'External Knowledge Base Connected Successfully' })
toast.add({ type: 'success', title: t('externalKnowledgeForm.connectedSuccess', { ns: 'dataset' }) })
trackEvent('create_external_knowledge_base', {
provider: formValue.provider,
name: formValue.name,
@ -29,7 +31,7 @@ const ExternalKnowledgeBaseConnector = () => {
}
catch (error) {
console.error('Error creating external knowledge base:', error)
toast.add({ type: 'error', title: 'Failed to connect External Knowledge Base' })
toast.add({ type: 'error', title: t('externalKnowledgeForm.connectedFailed', { ns: 'dataset' }) })
}
setLoading(false)
}

View File

@ -1,4 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Import component after mocks
@ -17,44 +18,73 @@ vi.mock('@/i18n-config/language', () => ({
],
}))
// Mock PortalSelect component
vi.mock('@/app/components/base/select', () => ({
PortalSelect: ({
const MockSelectContext = React.createContext<{
value: string
onValueChange: (value: string) => void
}>({
value: '',
onValueChange: () => {},
})
vi.mock('@/app/components/base/ui/select', () => ({
Select: ({
value,
items,
onSelect,
triggerClassName,
popupClassName,
popupInnerClassName,
onValueChange,
children,
}: {
value: string
items: Array<{ value: string, name: string }>
onSelect: (item: { value: string }) => void
triggerClassName?: string
popupClassName?: string
popupInnerClassName?: string
onValueChange: (value: string) => void
children: React.ReactNode
}) => (
<div
data-testid="portal-select"
data-value={value}
data-trigger-class={triggerClassName}
data-popup-class={popupClassName}
data-popup-inner-class={popupInnerClassName}
>
<span data-testid="selected-value">{value}</span>
<div data-testid="items-container">
{items.map(item => (
<button
key={item.value}
data-testid={`select-item-${item.value}`}
onClick={() => onSelect({ value: item.value })}
>
{item.name}
</button>
))}
</div>
<MockSelectContext.Provider value={{ value, onValueChange }}>
<div data-testid="select-root">{children}</div>
</MockSelectContext.Provider>
),
SelectTrigger: ({
children,
className,
'data-testid': testId,
}: {
'children': React.ReactNode
'className'?: string
'data-testid'?: string
}) => (
<button data-testid={testId ?? 'select-trigger'} data-class={className}>
{children}
</button>
),
SelectValue: () => {
const { value } = React.useContext(MockSelectContext)
return <span data-testid="selected-value">{value}</span>
},
SelectContent: ({
children,
popupClassName,
}: {
children: React.ReactNode
popupClassName?: string
}) => (
<div data-testid="select-content" data-popup-class={popupClassName}>
{children}
</div>
),
SelectItem: ({
children,
value,
}: {
children: React.ReactNode
value: string
}) => {
const { onValueChange } = React.useContext(MockSelectContext)
return (
<button
data-testid={`select-item-${value}`}
onClick={() => onValueChange(value)}
>
{children}
</button>
)
},
}))
// ==================== Test Utilities ====================
@ -139,7 +169,7 @@ describe('TTSParamsPanel', () => {
expect(screen.getByText('appDebug.voice.voiceSettings.voice')).toBeInTheDocument()
})
it('should render two PortalSelect components', () => {
it('should render two Select components', () => {
// Arrange
const props = createDefaultProps()
@ -147,7 +177,7 @@ describe('TTSParamsPanel', () => {
render(<TTSParamsPanel {...props} />)
// Assert
const selects = screen.getAllByTestId('portal-select')
const selects = screen.getAllByTestId('select-root')
expect(selects).toHaveLength(2)
})
@ -159,8 +189,8 @@ describe('TTSParamsPanel', () => {
render(<TTSParamsPanel {...props} />)
// Assert
const selects = screen.getAllByTestId('portal-select')
expect(selects[0]).toHaveAttribute('data-value', 'zh-Hans')
const values = screen.getAllByTestId('selected-value')
expect(values[0]).toHaveTextContent('zh-Hans')
})
it('should render voice select with correct value', () => {
@ -171,8 +201,8 @@ describe('TTSParamsPanel', () => {
render(<TTSParamsPanel {...props} />)
// Assert
const selects = screen.getAllByTestId('portal-select')
expect(selects[1]).toHaveAttribute('data-value', 'echo')
const values = screen.getAllByTestId('selected-value')
expect(values[1]).toHaveTextContent('echo')
})
it('should only show supported languages in language select', () => {
@ -205,7 +235,7 @@ describe('TTSParamsPanel', () => {
// ==================== Props Testing ====================
describe('Props', () => {
it('should apply trigger className to PortalSelect', () => {
it('should apply trigger className to SelectTrigger', () => {
// Arrange
const props = createDefaultProps()
@ -213,12 +243,11 @@ describe('TTSParamsPanel', () => {
render(<TTSParamsPanel {...props} />)
// Assert
const selects = screen.getAllByTestId('portal-select')
expect(selects[0]).toHaveAttribute('data-trigger-class', 'h-8')
expect(selects[1]).toHaveAttribute('data-trigger-class', 'h-8')
expect(screen.getByTestId('tts-language-select-trigger')).toHaveAttribute('data-class', 'w-full')
expect(screen.getByTestId('tts-voice-select-trigger')).toHaveAttribute('data-class', 'w-full')
})
it('should apply popup className to PortalSelect', () => {
it('should apply popup className to SelectContent', () => {
// Arrange
const props = createDefaultProps()
@ -226,22 +255,9 @@ describe('TTSParamsPanel', () => {
render(<TTSParamsPanel {...props} />)
// Assert
const selects = screen.getAllByTestId('portal-select')
expect(selects[0]).toHaveAttribute('data-popup-class', 'z-[1000]')
expect(selects[1]).toHaveAttribute('data-popup-class', 'z-[1000]')
})
it('should apply popup inner className to PortalSelect', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<TTSParamsPanel {...props} />)
// Assert
const selects = screen.getAllByTestId('portal-select')
expect(selects[0]).toHaveAttribute('data-popup-inner-class', 'w-[354px]')
expect(selects[1]).toHaveAttribute('data-popup-inner-class', 'w-[354px]')
const contents = screen.getAllByTestId('select-content')
expect(contents[0]).toHaveAttribute('data-popup-class', 'w-[354px]')
expect(contents[1]).toHaveAttribute('data-popup-class', 'w-[354px]')
})
})
@ -411,10 +427,8 @@ describe('TTSParamsPanel', () => {
render(<TTSParamsPanel {...props} />)
// Assert - no voice items (except language items)
const voiceSelects = screen.getAllByTestId('portal-select')
// Second select is voice select, should have no voice items in items-container
const voiceItemsContainer = voiceSelects[1].querySelector('[data-testid="items-container"]')
expect(voiceItemsContainer?.children).toHaveLength(0)
expect(screen.getAllByTestId('select-content')[1].children).toHaveLength(0)
expect(screen.queryByTestId('select-item-alloy')).not.toBeInTheDocument()
})
it('should handle currentModel with single voice', () => {
@ -443,8 +457,8 @@ describe('TTSParamsPanel', () => {
render(<TTSParamsPanel {...props} />)
// Assert
const selects = screen.getAllByTestId('portal-select')
expect(selects[0]).toHaveAttribute('data-value', '')
const values = screen.getAllByTestId('selected-value')
expect(values[0]).toHaveTextContent('')
})
it('should handle empty voice value', () => {
@ -455,8 +469,8 @@ describe('TTSParamsPanel', () => {
render(<TTSParamsPanel {...props} />)
// Assert
const selects = screen.getAllByTestId('portal-select')
expect(selects[1]).toHaveAttribute('data-value', '')
const values = screen.getAllByTestId('selected-value')
expect(values[1]).toHaveTextContent('')
})
it('should handle many voices', () => {
@ -514,14 +528,14 @@ describe('TTSParamsPanel', () => {
// Act
const { rerender } = render(<TTSParamsPanel {...props} />)
const selects = screen.getAllByTestId('portal-select')
expect(selects[0]).toHaveAttribute('data-value', 'en-US')
const values = screen.getAllByTestId('selected-value')
expect(values[0]).toHaveTextContent('en-US')
rerender(<TTSParamsPanel {...props} language="zh-Hans" />)
// Assert
const updatedSelects = screen.getAllByTestId('portal-select')
expect(updatedSelects[0]).toHaveAttribute('data-value', 'zh-Hans')
const updatedValues = screen.getAllByTestId('selected-value')
expect(updatedValues[0]).toHaveTextContent('zh-Hans')
})
it('should update when voice prop changes', () => {
@ -530,14 +544,14 @@ describe('TTSParamsPanel', () => {
// Act
const { rerender } = render(<TTSParamsPanel {...props} />)
const selects = screen.getAllByTestId('portal-select')
expect(selects[1]).toHaveAttribute('data-value', 'alloy')
const values = screen.getAllByTestId('selected-value')
expect(values[1]).toHaveTextContent('alloy')
rerender(<TTSParamsPanel {...props} voice="echo" />)
// Assert
const updatedSelects = screen.getAllByTestId('portal-select')
expect(updatedSelects[1]).toHaveAttribute('data-value', 'echo')
const updatedValues = screen.getAllByTestId('selected-value')
expect(updatedValues[1]).toHaveTextContent('echo')
})
it('should update voice list when currentModel changes', () => {

View File

@ -1,9 +1,8 @@
import * as React from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { PortalSelect } from '@/app/components/base/select'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/app/components/base/ui/select'
import { languages } from '@/i18n-config/language'
import { cn } from '@/utils/classnames'
type Props = {
currentModel: any
@ -12,6 +11,8 @@ type Props = {
onChange: (language: string, voice: string) => void
}
const supportedLanguages = languages.filter(item => item.supported)
const TTSParamsPanel = ({
currentModel,
language,
@ -19,11 +20,11 @@ const TTSParamsPanel = ({
onChange,
}: Props) => {
const { t } = useTranslation()
const voiceList = useMemo(() => {
const voiceList = useMemo<Array<{ label: string, value: string }>>(() => {
if (!currentModel)
return []
return currentModel.model_properties.voices.map((item: { mode: any }) => ({
...item,
return currentModel.model_properties.voices.map((item: { mode: string, name: string }) => ({
label: item.name,
value: item.mode,
}))
}, [currentModel])
@ -39,27 +40,57 @@ const TTSParamsPanel = ({
<div className="system-sm-semibold mb-1 flex items-center py-1 text-text-secondary">
{t('voice.voiceSettings.language', { ns: 'appDebug' })}
</div>
<PortalSelect
triggerClassName="h-8"
popupClassName={cn('z-[1000]')}
popupInnerClassName={cn('w-[354px]')}
<Select
value={language}
items={languages.filter(item => item.supported)}
onSelect={item => setLanguage(item.value as string)}
/>
onValueChange={(value) => {
if (value == null)
return
setLanguage(value)
}}
>
<SelectTrigger
className="w-full"
data-testid="tts-language-select-trigger"
aria-label={t('voice.voiceSettings.language', { ns: 'appDebug' })}
>
<SelectValue />
</SelectTrigger>
<SelectContent popupClassName="w-[354px]">
{supportedLanguages.map(item => (
<SelectItem key={item.value} value={item.value}>
{item.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="mb-3">
<div className="system-sm-semibold mb-1 flex items-center py-1 text-text-secondary">
{t('voice.voiceSettings.voice', { ns: 'appDebug' })}
</div>
<PortalSelect
triggerClassName="h-8"
popupClassName={cn('z-[1000]')}
popupInnerClassName={cn('w-[354px]')}
<Select
value={voice}
items={voiceList}
onSelect={item => setVoice(item.value as string)}
/>
onValueChange={(value) => {
if (value == null)
return
setVoice(value)
}}
>
<SelectTrigger
className="w-full"
data-testid="tts-voice-select-trigger"
aria-label={t('voice.voiceSettings.voice', { ns: 'appDebug' })}
>
<SelectValue />
</SelectTrigger>
<SelectContent popupClassName="w-[354px]">
{voiceList.map(item => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)

View File

@ -1333,12 +1333,9 @@ describe('CommonCreateModal', () => {
mockVerifyCredentials.mockImplementation((params, { onSuccess }) => {
onSuccess()
})
const builder = createMockSubscriptionBuilder()
render(<CommonCreateModal {...defaultProps} />)
await waitFor(() => {
expect(mockCreateBuilder).toHaveBeenCalled()
})
render(<CommonCreateModal {...defaultProps} builder={builder} />)
fireEvent.click(screen.getByTestId('modal-confirm'))

View File

@ -8,6 +8,8 @@ import { usePluginInstallation } from '@/hooks/use-query-params'
import { fetchBundleInfoFromMarketPlace, fetchManifestFromMarketPlace } from '@/service/plugins'
import PluginPageWithContext from '../index'
let mockEnableMarketplace = true
// Mock external dependencies
vi.mock('@/service/plugins', () => ({
fetchManifestFromMarketPlace: vi.fn(),
@ -31,7 +33,7 @@ vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: vi.fn((selector) => {
const state = {
systemFeatures: {
enable_marketplace: true,
enable_marketplace: mockEnableMarketplace,
},
}
return selector(state)
@ -138,6 +140,7 @@ const createDefaultProps = (): PluginPageProps => ({
describe('PluginPage Component', () => {
beforeEach(() => {
vi.clearAllMocks()
mockEnableMarketplace = true
// Reset to default mock values
vi.mocked(usePluginInstallation).mockReturnValue([
{ packageId: null, bundleInfo: null },
@ -630,18 +633,7 @@ describe('PluginPage Component', () => {
})
it('should handle marketplace disabled', () => {
// Mock marketplace disabled
vi.mock('@/context/global-public-context', async () => ({
useGlobalPublicStore: vi.fn((selector) => {
const state = {
systemFeatures: {
enable_marketplace: false,
},
}
return selector(state)
}),
}))
mockEnableMarketplace = false
vi.mocked(useQueryState).mockReturnValue(['discover', vi.fn()])
render(<PluginPageWithContext {...createDefaultProps()} />)

View File

@ -1,5 +1,6 @@
import type { EnvironmentVariable } from '@/app/components/workflow/types'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { useState } from 'react'
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
import Conversion from '../conversion'
@ -347,11 +348,67 @@ vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({
),
}))
vi.mock('@/app/components/base/app-icon-picker', () => ({
default: function MockAppIconPicker({ onSelect, onClose }: {
onSelect?: (payload:
| { type: 'emoji', icon: string, background: string }
| { type: 'image', fileId: string, url: string },
) => void
onClose?: () => void
}) {
const [activeTab, setActiveTab] = useState<'emoji' | 'image'>('emoji')
const [selectedEmoji, setSelectedEmoji] = useState({ icon: '😀', background: '#FFFFFF' })
return (
<div data-testid="app-icon-picker">
<button type="button" onClick={() => setActiveTab('emoji')}>iconPicker.emoji</button>
<button type="button" onClick={() => setActiveTab('image')}>iconPicker.image</button>
{activeTab === 'emoji' && (
<button
type="button"
data-testid="picker-emoji-option"
onClick={() => setSelectedEmoji({ icon: '🎯', background: '#FFAA00' })}
>
picker-emoji-option
</button>
)}
{activeTab === 'image' && <div data-testid="picker-image-panel">picker-image-panel</div>}
<button type="button" onClick={() => onClose?.()}>iconPicker.cancel</button>
<button
type="button"
onClick={() => {
if (activeTab === 'emoji') {
onSelect?.({
type: 'emoji',
icon: selectedEmoji.icon,
background: selectedEmoji.background,
})
return
}
onSelect?.({
type: 'image',
fileId: 'test-file-id',
url: 'https://example.com/icon.png',
})
}}
>
iconPicker.ok
</button>
</div>
)
},
}))
// Silence expected console.error from Dialog/Modal rendering
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
vi.restoreAllMocks()
})
// Helper to find the name input in PublishAsKnowledgePipelineModal
function getNameInput() {
return screen.getByPlaceholderText('pipeline.common.publishAsPipeline.namePlaceholder')
@ -708,10 +765,7 @@ describe('PublishAsKnowledgePipelineModal', () => {
const appIcon = getAppIcon()
fireEvent.click(appIcon)
// Click the first emoji in the grid (search full document since Dialog uses portal)
const gridEmojis = document.querySelectorAll('.grid em-emoji')
expect(gridEmojis.length).toBeGreaterThan(0)
fireEvent.click(gridEmojis[0].parentElement!.parentElement!)
fireEvent.click(screen.getByTestId('picker-emoji-option'))
// Click OK to confirm selection
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
@ -1031,11 +1085,8 @@ describe('Integration Tests', () => {
// Open picker and select an emoji
const appIcon = getAppIcon()
fireEvent.click(appIcon)
const gridEmojis = document.querySelectorAll('.grid em-emoji')
if (gridEmojis.length > 0) {
fireEvent.click(gridEmojis[0].parentElement!.parentElement!)
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
}
fireEvent.click(screen.getByTestId('picker-emoji-option'))
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
fireEvent.click(screen.getByRole('button', { name: /workflow\.common\.publish/i }))

View File

@ -62,6 +62,7 @@ const RagPipelinePanel = () => {
return {
getVersionListUrl: `/rag/pipelines/${pipelineId}/workflows`,
deleteVersionUrl: (versionId: string) => `/rag/pipelines/${pipelineId}/workflows/${versionId}`,
restoreVersionUrl: (versionId: string) => `/rag/pipelines/${pipelineId}/workflows/${versionId}/restore`,
updateVersionUrl: (versionId: string) => `/rag/pipelines/${pipelineId}/workflows/${versionId}`,
latestVersionId: '',
}

View File

@ -231,6 +231,25 @@ describe('useNodesSyncDraft', () => {
expect(mockSyncWorkflowDraft).toHaveBeenCalled()
})
it('should not include source_workflow_id in sync payloads', async () => {
mockGetNodesReadOnly.mockReturnValue(false)
mockGetNodes.mockReturnValue([
{ id: 'node-1', data: { type: 'start' }, position: { x: 0, y: 0 } },
])
const { result } = renderHook(() => useNodesSyncDraft())
await act(async () => {
await result.current.doSyncWorkflowDraft()
})
expect(mockSyncWorkflowDraft).toHaveBeenCalledWith(expect.objectContaining({
params: expect.not.objectContaining({
source_workflow_id: expect.anything(),
}),
}))
})
it('should call onSuccess callback when sync succeeds', async () => {
mockGetNodesReadOnly.mockReturnValue(false)
mockGetNodes.mockReturnValue([
@ -421,6 +440,21 @@ describe('useNodesSyncDraft', () => {
expect(sentParams.rag_pipeline_variables).toEqual([{ variable: 'input', type: 'text-input' }])
})
it('should not include source_workflow_id when syncing on page close', () => {
mockGetNodes.mockReturnValue([
{ id: 'node-1', data: { type: 'start' }, position: { x: 0, y: 0 } },
])
const { result } = renderHook(() => useNodesSyncDraft())
act(() => {
result.current.syncWorkflowDraftWhenPageClose()
})
const sentParams = mockPostWithKeepalive.mock.calls[0][1]
expect(sentParams.source_workflow_id).toBeUndefined()
})
it('should remove underscore-prefixed keys from edges', () => {
mockStoreGetState.mockReturnValue({
getNodes: mockGetNodes,

View File

@ -35,6 +35,7 @@ describe('usePipelineRefreshDraft', () => {
const mockSetIsSyncingWorkflowDraft = vi.fn()
const mockSetEnvironmentVariables = vi.fn()
const mockSetEnvSecrets = vi.fn()
const mockSetRagPipelineVariables = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
@ -45,6 +46,7 @@ describe('usePipelineRefreshDraft', () => {
setIsSyncingWorkflowDraft: mockSetIsSyncingWorkflowDraft,
setEnvironmentVariables: mockSetEnvironmentVariables,
setEnvSecrets: mockSetEnvSecrets,
setRagPipelineVariables: mockSetRagPipelineVariables,
})
mockFetchWorkflowDraft.mockResolvedValue({
@ -55,6 +57,7 @@ describe('usePipelineRefreshDraft', () => {
},
hash: 'new-hash',
environment_variables: [],
rag_pipeline_variables: [],
})
})
@ -116,6 +119,29 @@ describe('usePipelineRefreshDraft', () => {
})
})
it('should update rag pipeline variables after fetch', async () => {
mockFetchWorkflowDraft.mockResolvedValue({
graph: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
hash: 'new-hash',
environment_variables: [],
rag_pipeline_variables: [{ variable: 'query', type: 'text-input' }],
})
const { result } = renderHook(() => usePipelineRefreshDraft())
act(() => {
result.current.handleRefreshWorkflowDraft()
})
await waitFor(() => {
expect(mockSetRagPipelineVariables).toHaveBeenCalledWith([{ variable: 'query', type: 'text-input' }])
})
})
it('should set syncing state to false after completion', async () => {
const { result } = renderHook(() => usePipelineRefreshDraft())

View File

@ -1,3 +1,4 @@
import type { SyncDraftCallback } from '@/app/components/workflow/hooks-store'
import { produce } from 'immer'
import { useCallback } from 'react'
import { useStoreApi } from 'reactflow'
@ -83,11 +84,7 @@ export const useNodesSyncDraft = () => {
const performSync = useCallback(async (
notRefreshWhenSyncError?: boolean,
callback?: {
onSuccess?: () => void
onError?: () => void
onSettled?: () => void
},
callback?: SyncDraftCallback,
) => {
if (getNodesReadOnly())
return

View File

@ -16,6 +16,7 @@ export const usePipelineRefreshDraft = () => {
setIsSyncingWorkflowDraft,
setEnvironmentVariables,
setEnvSecrets,
setRagPipelineVariables,
} = workflowStore.getState()
setIsSyncingWorkflowDraft(true)
fetchWorkflowDraft(`/rag/pipelines/${pipelineId}/workflows/draft`).then((response) => {
@ -34,6 +35,7 @@ export const usePipelineRefreshDraft = () => {
return acc
}, {} as Record<string, string>))
setEnvironmentVariables(response.environment_variables?.map(env => env.value_type === 'secret' ? { ...env, value: '[__HIDDEN__]' } : env) || [])
setRagPipelineVariables?.(response.rag_pipeline_variables || [])
}).finally(() => setIsSyncingWorkflowDraft(false))
}, [handleUpdateWorkflowCanvas, workflowStore])

View File

@ -1,21 +1,24 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { importSchemaFromURL } from '@/service/tools'
import Toast from '../../../base/toast'
import examples from '../examples'
import GetSchema from '../get-schema'
vi.mock('@/service/tools', () => ({
importSchemaFromURL: vi.fn(),
}))
const mockToastAdd = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
add: mockToastAdd,
},
}))
const importSchemaFromURLMock = vi.mocked(importSchemaFromURL)
describe('GetSchema', () => {
const notifySpy = vi.spyOn(Toast, 'notify')
const mockOnChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
notifySpy.mockClear()
importSchemaFromURLMock.mockReset()
render(<GetSchema onChange={mockOnChange} />)
})
@ -27,9 +30,9 @@ describe('GetSchema', () => {
fireEvent.change(input, { target: { value: 'ftp://invalid' } })
fireEvent.click(screen.getByText('common.operation.ok'))
expect(notifySpy).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
message: 'tools.createTool.urlError',
title: 'tools.createTool.urlError',
})
})

View File

@ -10,8 +10,8 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import { toast } from '@/app/components/base/ui/toast'
import { importSchemaFromURL } from '@/service/tools'
import Toast from '../../base/toast'
import examples from './examples'
type Props = {
@ -27,9 +27,9 @@ const GetSchema: FC<Props> = ({
const [isParsing, setIsParsing] = useState(false)
const handleImportFromUrl = async () => {
if (!importUrl.startsWith('http://') && !importUrl.startsWith('https://')) {
Toast.notify({
toast.add({
type: 'error',
message: t('createTool.urlError', { ns: 'tools' }),
title: t('createTool.urlError', { ns: 'tools' }),
})
return
}

View File

@ -18,32 +18,11 @@ vi.mock('@/app/components/plugins/hooks', () => ({
}),
}))
// Mock useDebounceFn to store the function and allow manual triggering
let debouncedFn: (() => void) | null = null
vi.mock('ahooks', () => ({
useDebounceFn: (fn: () => void) => {
debouncedFn = fn
return {
run: () => {
// Schedule to run after React state updates
setTimeout(() => debouncedFn?.(), 0)
},
cancel: vi.fn(),
}
},
}))
describe('LabelFilter', () => {
const mockOnChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
debouncedFn = null
})
afterEach(() => {
vi.useRealTimers()
})
// Rendering Tests
@ -81,36 +60,23 @@ describe('LabelFilter', () => {
const trigger = screen.getByText('common.tag.placeholder')
await act(async () => {
fireEvent.click(trigger)
vi.advanceTimersByTime(10)
})
await act(async () => fireEvent.click(trigger))
mockTags.forEach((tag) => {
expect(screen.getByText(tag.label)).toBeInTheDocument()
})
})
it('should close dropdown when trigger is clicked again', async () => {
it('should render search input when dropdown is open', async () => {
render(<LabelFilter value={[]} onChange={mockOnChange} />)
const trigger = screen.getByText('common.tag.placeholder')
const trigger = screen.getByText('common.tag.placeholder').closest('button')
expect(trigger).toBeInTheDocument()
// Open
await act(async () => {
fireEvent.click(trigger)
vi.advanceTimersByTime(10)
})
await act(async () => fireEvent.click(trigger!))
expect(screen.getByText('Agent')).toBeInTheDocument()
// Close
await act(async () => {
fireEvent.click(trigger)
vi.advanceTimersByTime(10)
})
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
})
@ -119,17 +85,11 @@ describe('LabelFilter', () => {
it('should call onChange with selected label when clicking a label', async () => {
render(<LabelFilter value={[]} onChange={mockOnChange} />)
await act(async () => {
fireEvent.click(screen.getByText('common.tag.placeholder'))
vi.advanceTimersByTime(10)
})
await act(async () => fireEvent.click(screen.getByText('common.tag.placeholder')))
expect(screen.getByText('Agent')).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByText('Agent'))
vi.advanceTimersByTime(10)
})
await act(async () => fireEvent.click(screen.getByText('Agent')))
expect(mockOnChange).toHaveBeenCalledWith(['agent'])
})
@ -137,10 +97,7 @@ describe('LabelFilter', () => {
it('should remove label from selection when clicking already selected label', async () => {
render(<LabelFilter value={['agent']} onChange={mockOnChange} />)
await act(async () => {
fireEvent.click(screen.getByText('Agent'))
vi.advanceTimersByTime(10)
})
await act(async () => fireEvent.click(screen.getByText('Agent')))
// Find the label item in the dropdown list
const labelItems = screen.getAllByText('Agent')
@ -149,7 +106,6 @@ describe('LabelFilter', () => {
await act(async () => {
if (dropdownItem)
fireEvent.click(dropdownItem)
vi.advanceTimersByTime(10)
})
expect(mockOnChange).toHaveBeenCalledWith([])
@ -158,17 +114,11 @@ describe('LabelFilter', () => {
it('should add label to existing selection', async () => {
render(<LabelFilter value={['agent']} onChange={mockOnChange} />)
await act(async () => {
fireEvent.click(screen.getByText('Agent'))
vi.advanceTimersByTime(10)
})
await act(async () => fireEvent.click(screen.getByText('Agent')))
expect(screen.getByText('RAG')).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByText('RAG'))
vi.advanceTimersByTime(10)
})
await act(async () => fireEvent.click(screen.getByText('RAG')))
expect(mockOnChange).toHaveBeenCalledWith(['agent', 'rag'])
})
@ -179,8 +129,7 @@ describe('LabelFilter', () => {
it('should clear all selections when clear button is clicked', async () => {
render(<LabelFilter value={['agent', 'rag']} onChange={mockOnChange} />)
// Find and click the clear button (XCircle icon's parent)
const clearButton = document.querySelector('.group\\/clear')
const clearButton = screen.getByRole('button', { name: 'common.operation.clear' })
expect(clearButton).toBeInTheDocument()
fireEvent.click(clearButton!)
@ -203,21 +152,16 @@ describe('LabelFilter', () => {
await act(async () => {
fireEvent.click(screen.getByText('common.tag.placeholder'))
vi.advanceTimersByTime(10)
})
expect(screen.getByRole('textbox')).toBeInTheDocument()
await act(async () => {
const searchInput = screen.getByRole('textbox')
// Filter by 'rag' which only matches 'rag' name
fireEvent.change(searchInput, { target: { value: 'rag' } })
vi.advanceTimersByTime(10)
})
// Only RAG should be visible (rag contains 'rag')
expect(screen.getByTitle('RAG')).toBeInTheDocument()
// Agent should not be in the dropdown list (agent doesn't contain 'rag')
expect(screen.queryByTitle('Agent')).not.toBeInTheDocument()
})
@ -226,7 +170,6 @@ describe('LabelFilter', () => {
await act(async () => {
fireEvent.click(screen.getByText('common.tag.placeholder'))
vi.advanceTimersByTime(10)
})
expect(screen.getByRole('textbox')).toBeInTheDocument()
@ -234,7 +177,6 @@ describe('LabelFilter', () => {
await act(async () => {
const searchInput = screen.getByRole('textbox')
fireEvent.change(searchInput, { target: { value: 'nonexistent' } })
vi.advanceTimersByTime(10)
})
expect(screen.getByText('common.tag.noTag')).toBeInTheDocument()
@ -245,26 +187,21 @@ describe('LabelFilter', () => {
await act(async () => {
fireEvent.click(screen.getByText('common.tag.placeholder'))
vi.advanceTimersByTime(10)
})
expect(screen.getByRole('textbox')).toBeInTheDocument()
await act(async () => {
const searchInput = screen.getByRole('textbox')
// First filter to show only RAG
fireEvent.change(searchInput, { target: { value: 'rag' } })
vi.advanceTimersByTime(10)
})
expect(screen.getByTitle('RAG')).toBeInTheDocument()
expect(screen.queryByTitle('Agent')).not.toBeInTheDocument()
await act(async () => {
// Clear the input
const searchInput = screen.getByRole('textbox')
fireEvent.change(searchInput, { target: { value: '' } })
vi.advanceTimersByTime(10)
})
// All labels should be visible again
@ -310,17 +247,11 @@ describe('LabelFilter', () => {
it('should call onChange with updated array', async () => {
render(<LabelFilter value={[]} onChange={mockOnChange} />)
await act(async () => {
fireEvent.click(screen.getByText('common.tag.placeholder'))
vi.advanceTimersByTime(10)
})
await act(async () => fireEvent.click(screen.getByText('common.tag.placeholder')))
expect(screen.getByText('Agent')).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByText('Agent'))
vi.advanceTimersByTime(10)
})
await act(async () => fireEvent.click(screen.getByText('Agent')))
expect(mockOnChange).toHaveBeenCalledTimes(1)
expect(mockOnChange).toHaveBeenCalledWith(['agent'])

View File

@ -1,7 +1,6 @@
import type { FC } from 'react'
import type { Label } from '@/app/components/tools/labels/constant'
import { RiArrowDownSLine } from '@remixicon/react'
import { useDebounceFn } from 'ahooks'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Tag01, Tag03 } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
@ -9,10 +8,10 @@ import { Check } from '@/app/components/base/icons/src/vender/line/general'
import { XCircle } from '@/app/components/base/icons/src/vender/solid/general'
import Input from '@/app/components/base/input'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
Popover,
PopoverContent,
PopoverTrigger,
} from '@/app/components/base/ui/popover'
import { useTags } from '@/app/components/plugins/hooks'
import { cn } from '@/utils/classnames'
@ -30,18 +29,10 @@ const LabelFilter: FC<LabelFilterProps> = ({
const { tags: labelList } = useTags()
const [keywords, setKeywords] = useState('')
const [searchKeywords, setSearchKeywords] = useState('')
const { run: handleSearch } = useDebounceFn(() => {
setSearchKeywords(keywords)
}, { wait: 500 })
const handleKeywordsChange = (value: string) => {
setKeywords(value)
handleSearch()
}
const filteredLabelList = useMemo(() => {
return labelList.filter(label => label.name.includes(searchKeywords))
}, [labelList, searchKeywords])
return labelList.filter(label => label.name.includes(keywords))
}, [labelList, keywords])
const currentLabel = useMemo(() => {
return labelList.find(label => label.name === value[0])
@ -55,72 +46,70 @@ const LabelFilter: FC<LabelFilterProps> = ({
}
return (
<PortalToFollowElem
<Popover
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={4}
>
<div className="relative">
<PortalToFollowElemTrigger
onClick={() => setOpen(v => !v)}
className="block"
>
<div className={cn(
'flex h-8 cursor-pointer select-none items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 hover:bg-components-input-bg-hover',
!open && !!value.length && 'shadow-xs',
open && !!value.length && 'shadow-xs',
<PopoverTrigger
className={cn(
'flex h-8 cursor-pointer select-none items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 text-left hover:bg-components-input-bg-hover',
!!value.length && 'pr-6 shadow-xs',
)}
>
<div className="p-[1px]">
<Tag01 className="h-3.5 w-3.5 text-text-tertiary" />
</div>
<div className="text-[13px] leading-[18px] text-text-tertiary">
{!value.length && t('tag.placeholder', { ns: 'common' })}
{!!value.length && currentLabel?.label}
</div>
{value.length > 1 && (
<div className="text-xs font-medium leading-[18px] text-text-tertiary">{`+${value.length - 1}`}</div>
)}
{!value.length && (
<div className="p-[1px]">
<RiArrowDownSLine className="h-3.5 w-3.5 text-text-tertiary" />
</div>
)}
{!!value.length && (
<div
className="group/clear cursor-pointer p-[1px]"
onClick={(e) => {
e.stopPropagation()
onChange([])
}}
>
<XCircle className="h-3.5 w-3.5 text-text-tertiary group-hover/clear:text-text-secondary" />
</div>
)}
>
<div className="p-[1px]">
<Tag01 className="h-3.5 w-3.5 text-text-tertiary" />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[1002]">
<div className="relative w-[240px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]">
<div className="min-w-0 truncate text-[13px] leading-[18px] text-text-tertiary">
{!value.length && t('tag.placeholder', { ns: 'common' })}
{!!value.length && currentLabel?.label}
</div>
{value.length > 1 && (
<div className="shrink-0 text-xs font-medium leading-[18px] text-text-tertiary">{`+${value.length - 1}`}</div>
)}
{!value.length && (
<div className="shrink-0 p-[1px]">
<RiArrowDownSLine className="h-3.5 w-3.5 text-text-tertiary" />
</div>
)}
</PopoverTrigger>
{!!value.length && (
<button
type="button"
aria-label={t('operation.clear', { ns: 'common' })}
className="group/clear absolute right-2 top-1/2 -translate-y-1/2 p-[1px]"
data-testid="label-filter-clear-button"
onClick={() => onChange([])}
>
<XCircle className="h-3.5 w-3.5 text-text-tertiary group-hover/clear:text-text-secondary" />
</button>
)}
<PopoverContent
placement="bottom-start"
sideOffset={4}
popupClassName="w-[240px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]"
>
<div className="relative">
<div className="p-2">
<Input
showLeftIcon
showClearIcon
value={keywords}
onChange={e => handleKeywordsChange(e.target.value)}
onClear={() => handleKeywordsChange('')}
onChange={e => setKeywords(e.target.value)}
onClear={() => setKeywords('')}
/>
</div>
<div className="p-1">
{filteredLabelList.map(label => (
<div
<button
key={label.name}
className="flex cursor-pointer select-none items-center gap-2 rounded-lg py-[6px] pl-3 pr-2 hover:bg-state-base-hover"
type="button"
className="flex w-full select-none items-center gap-2 rounded-lg py-[6px] pl-3 pr-2 text-left hover:bg-state-base-hover"
onClick={() => selectLabel(label)}
>
<div title={label.label} className="grow truncate text-sm leading-5 text-text-secondary">{label.label}</div>
{value.includes(label.name) && <Check className="h-4 w-4 shrink-0 text-text-accent" />}
</div>
</button>
))}
{!filteredLabelList.length && (
<div className="flex flex-col items-center gap-1 p-3">
@ -130,9 +119,9 @@ const LabelFilter: FC<LabelFilterProps> = ({
)}
</div>
</div>
</PortalToFollowElemContent>
</PopoverContent>
</div>
</PortalToFollowElem>
</Popover>
)
}

View File

@ -3,7 +3,7 @@ import type { ToolWithProvider } from '@/app/components/workflow/types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import MCPModal from '../modal'
// Mock the service API
@ -48,7 +48,18 @@ vi.mock('@/service/use-plugins', () => ({
}),
}))
const mockToastAdd = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
add: mockToastAdd,
},
}))
describe('MCPModal', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
@ -299,6 +310,10 @@ describe('MCPModal', () => {
// Wait a bit and verify onConfirm was not called
await new Promise(resolve => setTimeout(resolve, 100))
expect(onConfirm).not.toHaveBeenCalled()
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
title: 'tools.mcp.modal.invalidServerUrl',
})
})
it('should not call onConfirm with invalid server identifier', async () => {
@ -320,6 +335,10 @@ describe('MCPModal', () => {
// Wait a bit and verify onConfirm was not called
await new Promise(resolve => setTimeout(resolve, 100))
expect(onConfirm).not.toHaveBeenCalled()
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
title: 'tools.mcp.modal.invalidServerIdentifier',
})
})
})

View File

@ -14,7 +14,7 @@ import { Mcp } from '@/app/components/base/icons/src/vender/other'
import Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal'
import TabSlider from '@/app/components/base/tab-slider'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import { MCPAuthMethod } from '@/app/components/tools/types'
import { cn } from '@/utils/classnames'
import { shouldUseMcpIconForAppIcon } from '@/utils/mcp'
@ -82,11 +82,11 @@ const MCPModalContent: FC<MCPModalContentProps> = ({
const submit = async () => {
if (!isValidUrl(state.url)) {
Toast.notify({ type: 'error', message: 'invalid server url' })
toast.add({ type: 'error', title: t('mcp.modal.invalidServerUrl', { ns: 'tools' }) })
return
}
if (!isValidServerID(state.serverIdentifier.trim())) {
Toast.notify({ type: 'error', message: 'invalid server identifier' })
toast.add({ type: 'error', title: t('mcp.modal.invalidServerIdentifier', { ns: 'tools' }) })
return
}
const formattedHeaders = state.headers.reduce((acc, item) => {

View File

@ -70,11 +70,11 @@ vi.mock('@/app/components/tools/edit-custom-collection-modal', () => ({
},
}))
// Mock Toast
// Mock toast
const mockToastNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: (options: { type: string, message: string }) => mockToastNotify(options),
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
add: (options: { type: string, title: string }) => mockToastNotify(options),
},
}))
@ -200,7 +200,7 @@ describe('CustomCreateCard', () => {
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith({
type: 'success',
message: expect.any(String),
title: expect.any(String),
})
})
})

View File

@ -92,8 +92,9 @@ vi.mock('@/app/components/base/confirm', () => ({
: null,
}))
vi.mock('@/app/components/base/toast', () => ({
default: { notify: vi.fn() },
const mockToastAdd = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/base/ui/toast', () => ({
toast: { add: mockToastAdd },
}))
vi.mock('@/app/components/header/indicator', () => ({

View File

@ -5,7 +5,7 @@ import {
} from '@remixicon/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import EditCustomToolModal from '@/app/components/tools/edit-custom-collection-modal'
import { useAppContext } from '@/context/app-context'
import { createCustomCollection } from '@/service/tools'
@ -21,9 +21,9 @@ const Contribute = ({ onRefreshData }: Props) => {
const [isShowEditCollectionToolModal, setIsShowEditCustomCollectionModal] = useState(false)
const doCreateCustomToolCollection = async (data: CustomCollectionBackend) => {
await createCustomCollection(data)
Toast.notify({
toast.add({
type: 'success',
message: t('api.actionSuccess', { ns: 'common' }),
title: t('api.actionSuccess', { ns: 'common' }),
})
setIsShowEditCustomCollectionModal(false)
onRefreshData()

View File

@ -13,7 +13,7 @@ import Confirm from '@/app/components/base/confirm'
import Drawer from '@/app/components/base/drawer'
import { LinkExternal02, Settings01 } from '@/app/components/base/icons/src/vender/line/general'
import Loading from '@/app/components/base/loading'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import { ConfigurationMethodEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import Indicator from '@/app/components/header/indicator'
import Icon from '@/app/components/plugins/card/base/card-icon'
@ -122,18 +122,18 @@ const ProviderDetail = ({
await getCustomProvider()
// Use fresh data from form submission to avoid race condition with collection.labels
setCustomCollection(prev => prev ? { ...prev, labels: data.labels } : null)
Toast.notify({
toast.add({
type: 'success',
message: t('api.actionSuccess', { ns: 'common' }),
title: t('api.actionSuccess', { ns: 'common' }),
})
setIsShowEditCustomCollectionModal(false)
}
const doRemoveCustomToolCollection = async () => {
await removeCustomCollection(collection?.name as string)
onRefreshData()
Toast.notify({
toast.add({
type: 'success',
message: t('api.actionSuccess', { ns: 'common' }),
title: t('api.actionSuccess', { ns: 'common' }),
})
setIsShowEditCustomCollectionModal(false)
}
@ -161,9 +161,9 @@ const ProviderDetail = ({
const removeWorkflowToolProvider = async () => {
await deleteWorkflowTool(collection.id)
onRefreshData()
Toast.notify({
toast.add({
type: 'success',
message: t('api.actionSuccess', { ns: 'common' }),
title: t('api.actionSuccess', { ns: 'common' }),
})
setIsShowEditWorkflowToolModal(false)
}
@ -175,9 +175,9 @@ const ProviderDetail = ({
invalidateAllWorkflowTools()
onRefreshData()
getWorkflowToolProvider()
Toast.notify({
toast.add({
type: 'success',
message: t('api.actionSuccess', { ns: 'common' }),
title: t('api.actionSuccess', { ns: 'common' }),
})
setIsShowEditWorkflowToolModal(false)
}
@ -385,18 +385,18 @@ const ProviderDetail = ({
onCancel={() => setShowSettingAuth(false)}
onSaved={async (value) => {
await updateBuiltInToolCredential(collection.name, value)
Toast.notify({
toast.add({
type: 'success',
message: t('api.actionSuccess', { ns: 'common' }),
title: t('api.actionSuccess', { ns: 'common' }),
})
await onRefreshData()
setShowSettingAuth(false)
}}
onRemove={async () => {
await removeBuiltInToolCredential(collection.name)
Toast.notify({
toast.add({
type: 'success',
message: t('api.actionSuccess', { ns: 'common' }),
title: t('api.actionSuccess', { ns: 'common' }),
})
await onRefreshData()
setShowSettingAuth(false)

View File

@ -110,6 +110,7 @@ const WorkflowPanel = () => {
return {
getVersionListUrl: `/apps/${appId}/workflows`,
deleteVersionUrl: (versionId: string) => `/apps/${appId}/workflows/${versionId}`,
restoreVersionUrl: (versionId: string) => `/apps/${appId}/workflows/${versionId}/restore`,
updateVersionUrl: (versionId: string) => `/apps/${appId}/workflows/${versionId}`,
latestVersionId: appDetail?.workflow?.id,
}

View File

@ -108,4 +108,18 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () =>
expect(mockHandleRefreshWorkflowDraft).not.toHaveBeenCalled()
})
it('should not include source_workflow_id in draft sync payloads', async () => {
const { result } = renderHook(() => useNodesSyncDraft())
await act(async () => {
await result.current.doSyncWorkflowDraft(false)
})
expect(mockSyncWorkflowDraft).toHaveBeenCalledWith(expect.objectContaining({
params: expect.not.objectContaining({
source_workflow_id: expect.anything(),
}),
}))
})
})

View File

@ -1,3 +1,4 @@
import type { SyncDraftCallback } from '@/app/components/workflow/hooks-store'
import { produce } from 'immer'
import { useCallback } from 'react'
import { useStoreApi } from 'reactflow'
@ -91,11 +92,7 @@ export const useNodesSyncDraft = () => {
const performSync = useCallback(async (
notRefreshWhenSyncError?: boolean,
callback?: {
onSuccess?: () => void
onError?: () => void
onSettled?: () => void
},
callback?: SyncDraftCallback,
) => {
if (getNodesReadOnly())
return

View File

@ -0,0 +1,126 @@
import type { VersionHistory } from '@/types/workflow'
import { screen } from '@testing-library/react'
import { FlowType } from '@/types/common'
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import { WorkflowVersion } from '../../types'
import HeaderInRestoring from '../header-in-restoring'
const mockRestoreWorkflow = vi.fn()
const mockInvalidAllLastRun = vi.fn()
const mockHandleLoadBackupDraft = vi.fn()
const mockHandleRefreshWorkflowDraft = vi.fn()
vi.mock('@/hooks/use-theme', () => ({
default: () => ({
theme: 'light',
}),
}))
vi.mock('@/hooks/use-timestamp', () => ({
default: () => ({
formatTime: vi.fn(() => '09:30:00'),
}),
}))
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: vi.fn(() => '3 hours ago'),
}),
}))
vi.mock('@/service/use-workflow', () => ({
useInvalidAllLastRun: () => mockInvalidAllLastRun,
useRestoreWorkflow: () => ({
mutateAsync: mockRestoreWorkflow,
}),
}))
vi.mock('../../hooks', () => ({
useWorkflowRun: () => ({
handleLoadBackupDraft: mockHandleLoadBackupDraft,
}),
useWorkflowRefreshDraft: () => ({
handleRefreshWorkflowDraft: mockHandleRefreshWorkflowDraft,
}),
}))
const createVersion = (overrides: Partial<VersionHistory> = {}): VersionHistory => ({
id: 'version-1',
graph: {
nodes: [],
edges: [],
},
created_at: 1_700_000_000,
created_by: {
id: 'user-1',
name: 'Alice',
email: 'alice@example.com',
},
hash: 'hash-1',
updated_at: 1_700_000_100,
updated_by: {
id: 'user-2',
name: 'Bob',
email: 'bob@example.com',
},
tool_published: false,
version: 'v1',
marked_name: 'Release 1',
marked_comment: '',
...overrides,
})
describe('HeaderInRestoring', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should disable restore when the flow id is not ready yet', () => {
renderWorkflowComponent(<HeaderInRestoring />, {
initialStoreState: {
currentVersion: createVersion(),
},
hooksStoreProps: {
configsMap: undefined,
},
})
expect(screen.getByRole('button', { name: 'workflow.common.restore' })).toBeDisabled()
})
it('should enable restore when version and flow config are both ready', () => {
renderWorkflowComponent(<HeaderInRestoring />, {
initialStoreState: {
currentVersion: createVersion(),
},
hooksStoreProps: {
configsMap: {
flowId: 'app-1',
flowType: FlowType.appFlow,
fileSettings: {} as never,
},
},
})
expect(screen.getByRole('button', { name: 'workflow.common.restore' })).toBeEnabled()
})
it('should keep restore disabled for draft versions even when flow config is ready', () => {
renderWorkflowComponent(<HeaderInRestoring />, {
initialStoreState: {
currentVersion: createVersion({
version: WorkflowVersion.Draft,
}),
},
hooksStoreProps: {
configsMap: {
flowId: 'app-1',
flowType: FlowType.appFlow,
fileSettings: {} as never,
},
},
})
expect(screen.getByRole('button', { name: 'workflow.common.restore' })).toBeDisabled()
})
})

View File

@ -5,11 +5,12 @@ import {
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import useTheme from '@/hooks/use-theme'
import { useInvalidAllLastRun } from '@/service/use-workflow'
import { useInvalidAllLastRun, useRestoreWorkflow } from '@/service/use-workflow'
import { getFlowPrefix } from '@/service/utils'
import { cn } from '@/utils/classnames'
import Toast from '../../base/toast'
import {
useNodesSyncDraft,
useWorkflowRefreshDraft,
useWorkflowRun,
} from '../hooks'
import { useHooksStore } from '../hooks-store'
@ -42,7 +43,9 @@ const HeaderInRestoring = ({
const {
handleLoadBackupDraft,
} = useWorkflowRun()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft()
const { mutateAsync: restoreWorkflow } = useRestoreWorkflow()
const canRestore = !!currentVersion?.id && !!configsMap?.flowId && currentVersion.version !== WorkflowVersion.Draft
const handleCancelRestore = useCallback(() => {
handleLoadBackupDraft()
@ -50,30 +53,35 @@ const HeaderInRestoring = ({
setShowWorkflowVersionHistoryPanel(false)
}, [workflowStore, handleLoadBackupDraft, setShowWorkflowVersionHistoryPanel])
const handleRestore = useCallback(() => {
const handleRestore = useCallback(async () => {
if (!canRestore)
return
setShowWorkflowVersionHistoryPanel(false)
workflowStore.setState({ isRestoring: false })
workflowStore.setState({ backupDraft: undefined })
handleSyncWorkflowDraft(true, false, {
onSuccess: () => {
Toast.notify({
type: 'success',
message: t('versionHistory.action.restoreSuccess', { ns: 'workflow' }),
})
},
onError: () => {
Toast.notify({
type: 'error',
message: t('versionHistory.action.restoreFailure', { ns: 'workflow' }),
})
},
onSettled: () => {
onRestoreSettled?.()
},
})
deleteAllInspectVars()
invalidAllLastRun()
}, [setShowWorkflowVersionHistoryPanel, workflowStore, handleSyncWorkflowDraft, deleteAllInspectVars, invalidAllLastRun, t, onRestoreSettled])
const restoreUrl = `/${getFlowPrefix(configsMap.flowType)}/${configsMap.flowId}/workflows/${currentVersion.id}/restore`
try {
await restoreWorkflow(restoreUrl)
workflowStore.setState({ isRestoring: false })
workflowStore.setState({ backupDraft: undefined })
handleRefreshWorkflowDraft()
Toast.notify({
type: 'success',
message: t('versionHistory.action.restoreSuccess', { ns: 'workflow' }),
})
deleteAllInspectVars()
invalidAllLastRun()
}
catch {
Toast.notify({
type: 'error',
message: t('versionHistory.action.restoreFailure', { ns: 'workflow' }),
})
}
finally {
onRestoreSettled?.()
}
}, [canRestore, currentVersion?.id, configsMap, setShowWorkflowVersionHistoryPanel, workflowStore, restoreWorkflow, handleRefreshWorkflowDraft, deleteAllInspectVars, invalidAllLastRun, t, onRestoreSettled])
return (
<>
@ -83,7 +91,7 @@ const HeaderInRestoring = ({
<div className=" flex items-center justify-end gap-x-2">
<Button
onClick={handleRestore}
disabled={!currentVersion || currentVersion.version === WorkflowVersion.Draft}
disabled={!canRestore}
variant="primary"
className={cn(
'rounded-lg border border-transparent',

View File

@ -22,14 +22,15 @@ export type AvailableNodesMetaData = {
nodes: NodeDefault[]
nodesMap?: Record<BlockEnum, NodeDefault<any>>
}
export type SyncDraftCallback = {
onSuccess?: () => void
onError?: () => void
onSettled?: () => void
}
export type CommonHooksFnMap = {
doSyncWorkflowDraft: (
notRefreshWhenSyncError?: boolean,
callback?: {
onSuccess?: () => void
onError?: () => void
onSettled?: () => void
},
callback?: SyncDraftCallback,
) => Promise<void>
syncWorkflowDraftWhenPageClose: () => void
handleRefreshWorkflowDraft: () => void

View File

@ -1,13 +1,10 @@
import type { SyncDraftCallback } from '../hooks-store'
import { useCallback } from 'react'
import { useHooksStore } from '@/app/components/workflow/hooks-store'
import { useStore } from '../store'
import { useNodesReadOnly } from './use-workflow'
export type SyncCallback = {
onSuccess?: () => void
onError?: () => void
onSettled?: () => void
}
export type SyncCallback = SyncDraftCallback
export const useNodesSyncDraft = () => {
const { getNodesReadOnly } = useNodesReadOnly()
@ -18,7 +15,7 @@ export const useNodesSyncDraft = () => {
const handleSyncWorkflowDraft = useCallback((
sync?: boolean,
notRefreshWhenSyncError?: boolean,
callback?: SyncCallback,
callback?: SyncDraftCallback,
) => {
if (getNodesReadOnly())
return

View File

@ -0,0 +1,115 @@
import type { PanelProps } from '../index'
import { screen } from '@testing-library/react'
import { createNode } from '../../__tests__/fixtures'
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import Panel from '../index'
const mockVersionHistoryPanel = vi.hoisted(() => vi.fn())
class MockResizeObserver implements ResizeObserver {
observe = vi.fn()
unobserve = vi.fn()
disconnect = vi.fn()
constructor(_callback: ResizeObserverCallback) {}
}
vi.mock('@/next/dynamic', () => ({
default: () => (props: { latestVersionId?: string }) => {
mockVersionHistoryPanel(props)
return <div data-testid="version-history-panel">{props.latestVersionId}</div>
},
}))
vi.mock('reactflow', async () => {
const mod = await import('../../__tests__/reactflow-mock-state')
const base = mod.createReactFlowModuleMock()
return {
...base,
useStore: vi.fn(selector => selector({
getNodes: () => mod.rfState.nodes,
})),
}
})
vi.mock('../env-panel', () => ({
default: () => <div data-testid="env-panel" />,
}))
vi.mock('../../nodes', () => ({
Panel: ({ id }: { id: string }) => <div data-testid="node-panel">{id}</div>,
}))
const versionHistoryPanelProps = {
latestVersionId: 'version-1',
restoreVersionUrl: (versionId: string) => `/workflows/${versionId}/restore`,
} satisfies NonNullable<PanelProps['versionHistoryPanelProps']>
describe('Panel', () => {
beforeEach(() => {
vi.clearAllMocks()
resetReactFlowMockState()
vi.stubGlobal('ResizeObserver', MockResizeObserver)
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('Version History Panel', () => {
it('should render the version history panel when the panel is open and props are provided', () => {
renderWorkflowComponent(
<Panel versionHistoryPanelProps={versionHistoryPanelProps} />,
{
initialStoreState: {
showWorkflowVersionHistoryPanel: true,
},
},
)
expect(screen.getByTestId('version-history-panel')).toHaveTextContent('version-1')
expect(mockVersionHistoryPanel).toHaveBeenCalledWith(expect.objectContaining({
latestVersionId: 'version-1',
}))
})
it('should not render the version history panel when the panel is open but props are missing', () => {
renderWorkflowComponent(
<Panel />,
{
initialStoreState: {
showWorkflowVersionHistoryPanel: true,
},
},
)
expect(screen.queryByTestId('version-history-panel')).not.toBeInTheDocument()
expect(mockVersionHistoryPanel).not.toHaveBeenCalled()
})
it('should not render the version history panel when the panel is closed', () => {
rfState.nodes = [
createNode({
id: 'selected-node',
data: {
selected: true,
},
}),
] as typeof rfState.nodes
renderWorkflowComponent(
<Panel versionHistoryPanelProps={versionHistoryPanelProps} />,
{
initialStoreState: {
showWorkflowVersionHistoryPanel: false,
},
},
)
expect(screen.queryByTestId('version-history-panel')).not.toBeInTheDocument()
expect(screen.getByTestId('node-panel')).toHaveTextContent('selected-node')
})
})
})

View File

@ -140,7 +140,7 @@ const Panel: FC<PanelProps> = ({
components?.right
}
{
showWorkflowVersionHistoryPanel && (
showWorkflowVersionHistoryPanel && versionHistoryPanelProps && (
<VersionHistoryPanel {...versionHistoryPanelProps} />
)
}

View File

@ -1,14 +1,55 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { WorkflowVersion } from '../../../types'
import type { Shape } from '../../../store'
import type { VersionHistory } from '@/types/workflow'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { useEffect } from 'react'
import { VersionHistoryContextMenuOptions, WorkflowVersion } from '../../../types'
const mockHandleRestoreFromPublishedWorkflow = vi.fn()
const mockHandleLoadBackupDraft = vi.fn()
const mockHandleRefreshWorkflowDraft = vi.fn()
const mockRestoreWorkflow = vi.fn()
const mockSetCurrentVersion = vi.fn()
const mockSetShowWorkflowVersionHistoryPanel = vi.fn()
const mockWorkflowStoreSetState = vi.fn()
type MockWorkflowStoreState = {
setShowWorkflowVersionHistoryPanel: ReturnType<typeof vi.fn>
currentVersion: null
setCurrentVersion: typeof mockSetCurrentVersion
const createVersionHistory = (overrides: Partial<VersionHistory> = {}): VersionHistory => ({
id: 'version-id',
version: WorkflowVersion.Draft,
graph: { nodes: [], edges: [] },
features: {
opening_statement: '',
suggested_questions: [],
suggested_questions_after_answer: { enabled: false },
text_to_speech: { enabled: false },
speech_to_text: { enabled: false },
retriever_resource: { enabled: false },
sensitive_word_avoidance: { enabled: false },
file_upload: { image: { enabled: false } },
},
created_at: Date.now() / 1000,
created_by: { id: 'user-1', name: 'User 1', email: 'user-1@example.com' },
hash: 'test-hash',
updated_at: Date.now() / 1000,
updated_by: { id: 'user-1', name: 'User 1', email: 'user-1@example.com' },
tool_published: false,
environment_variables: [],
marked_name: '',
marked_comment: '',
...overrides,
})
let mockCurrentVersion: VersionHistory | null = null
type MockVersionStoreState = Pick<Shape, 'currentVersion' | 'setCurrentVersion' | 'setShowWorkflowVersionHistoryPanel'>
type MockRestoreConfirmModalProps = {
isOpen: boolean
versionInfo: VersionHistory
onRestore: (item: VersionHistory) => void
}
type MockVersionHistoryItemProps = {
item: VersionHistory
onClick: (item: VersionHistory) => void
handleClickMenuItem: (operation: VersionHistoryContextMenuOptions) => void
}
vi.mock('@/context/app-context', () => ({
@ -19,52 +60,23 @@ vi.mock('@/service/use-workflow', () => ({
useDeleteWorkflow: () => ({ mutateAsync: vi.fn() }),
useInvalidAllLastRun: () => vi.fn(),
useResetWorkflowVersionHistory: () => vi.fn(),
useRestoreWorkflow: () => ({ mutateAsync: mockRestoreWorkflow }),
useUpdateWorkflow: () => ({ mutateAsync: vi.fn() }),
useWorkflowVersionHistory: () => ({
data: {
pages: [
{
items: [
{
createVersionHistory({
id: 'draft-version-id',
version: WorkflowVersion.Draft,
graph: { nodes: [], edges: [], viewport: null },
features: {
opening_statement: '',
suggested_questions: [],
suggested_questions_after_answer: { enabled: false },
text_to_speech: { enabled: false },
speech_to_text: { enabled: false },
retriever_resource: { enabled: false },
sensitive_word_avoidance: { enabled: false },
file_upload: { image: { enabled: false } },
},
created_at: Date.now() / 1000,
created_by: { id: 'user-1', name: 'User 1' },
environment_variables: [],
marked_name: '',
marked_comment: '',
},
{
}),
createVersionHistory({
id: 'published-version-id',
version: '2024-01-01T00:00:00Z',
graph: { nodes: [], edges: [], viewport: null },
features: {
opening_statement: '',
suggested_questions: [],
suggested_questions_after_answer: { enabled: false },
text_to_speech: { enabled: false },
speech_to_text: { enabled: false },
retriever_resource: { enabled: false },
sensitive_word_avoidance: { enabled: false },
file_upload: { image: { enabled: false } },
},
created_at: Date.now() / 1000,
created_by: { id: 'user-1', name: 'User 1' },
environment_variables: [],
marked_name: 'v1.0',
marked_comment: 'First release',
},
}),
],
},
],
@ -77,7 +89,7 @@ vi.mock('@/service/use-workflow', () => ({
vi.mock('../../../hooks', () => ({
useDSL: () => ({ handleExportDSL: vi.fn() }),
useNodesSyncDraft: () => ({ handleSyncWorkflowDraft: vi.fn() }),
useWorkflowRefreshDraft: () => ({ handleRefreshWorkflowDraft: mockHandleRefreshWorkflowDraft }),
useWorkflowRun: () => ({
handleRestoreFromPublishedWorkflow: mockHandleRestoreFromPublishedWorkflow,
handleLoadBackupDraft: mockHandleLoadBackupDraft,
@ -92,10 +104,10 @@ vi.mock('../../../hooks-store', () => ({
}))
vi.mock('../../../store', () => ({
useStore: <T,>(selector: (state: MockWorkflowStoreState) => T) => {
const state: MockWorkflowStoreState = {
setShowWorkflowVersionHistoryPanel: vi.fn(),
currentVersion: null,
useStore: <T,>(selector: (state: MockVersionStoreState) => T) => {
const state: MockVersionStoreState = {
setShowWorkflowVersionHistoryPanel: mockSetShowWorkflowVersionHistoryPanel,
currentVersion: mockCurrentVersion,
setCurrentVersion: mockSetCurrentVersion,
}
return selector(state)
@ -103,10 +115,10 @@ vi.mock('../../../store', () => ({
useWorkflowStore: () => ({
getState: () => ({
deleteAllInspectVars: vi.fn(),
setShowWorkflowVersionHistoryPanel: vi.fn(),
setShowWorkflowVersionHistoryPanel: mockSetShowWorkflowVersionHistoryPanel,
setCurrentVersion: mockSetCurrentVersion,
}),
setState: vi.fn(),
setState: mockWorkflowStoreSetState,
}),
}))
@ -115,16 +127,54 @@ vi.mock('../delete-confirm-modal', () => ({
}))
vi.mock('../restore-confirm-modal', () => ({
default: () => null,
default: (props: MockRestoreConfirmModalProps) => {
const MockRestoreConfirmModal = () => {
const { isOpen, versionInfo, onRestore } = props
if (!isOpen)
return null
return <button onClick={() => onRestore(versionInfo)}>confirm restore</button>
}
return <MockRestoreConfirmModal />
},
}))
vi.mock('@/app/components/app/app-publisher/version-info-modal', () => ({
default: () => null,
}))
vi.mock('../version-history-item', () => ({
default: (props: MockVersionHistoryItemProps) => {
const MockVersionHistoryItem = () => {
const { item, onClick, handleClickMenuItem } = props
useEffect(() => {
if (item.version === WorkflowVersion.Draft)
onClick(item)
}, [item, onClick])
return (
<div>
<button onClick={() => onClick(item)}>{item.marked_name || item.version}</button>
{item.version !== WorkflowVersion.Draft && (
<button onClick={() => handleClickMenuItem(VersionHistoryContextMenuOptions.restore)}>
{`restore-${item.id}`}
</button>
)}
</div>
)
}
return <MockVersionHistoryItem />
},
}))
describe('VersionHistoryPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCurrentVersion = null
})
describe('Version Click Behavior', () => {
@ -134,10 +184,10 @@ describe('VersionHistoryPanel', () => {
render(
<VersionHistoryPanel
latestVersionId="published-version-id"
restoreVersionUrl={versionId => `/apps/app-1/workflows/${versionId}/restore`}
/>,
)
// Draft version auto-clicks on mount via useEffect in VersionHistoryItem
expect(mockHandleLoadBackupDraft).toHaveBeenCalled()
expect(mockHandleRestoreFromPublishedWorkflow).not.toHaveBeenCalled()
})
@ -148,17 +198,72 @@ describe('VersionHistoryPanel', () => {
render(
<VersionHistoryPanel
latestVersionId="published-version-id"
restoreVersionUrl={versionId => `/apps/app-1/workflows/${versionId}/restore`}
/>,
)
// Clear mocks after initial render (draft version auto-clicks on mount)
vi.clearAllMocks()
const publishedItem = screen.getByText('v1.0')
fireEvent.click(publishedItem)
fireEvent.click(screen.getByText('v1.0'))
expect(mockHandleRestoreFromPublishedWorkflow).toHaveBeenCalled()
expect(mockHandleLoadBackupDraft).not.toHaveBeenCalled()
})
})
it('should set current version before confirming restore from context menu', async () => {
const { VersionHistoryPanel } = await import('../index')
render(
<VersionHistoryPanel
latestVersionId="published-version-id"
restoreVersionUrl={versionId => `/apps/app-1/workflows/${versionId}/restore`}
/>,
)
vi.clearAllMocks()
fireEvent.click(screen.getByText('restore-published-version-id'))
fireEvent.click(screen.getByText('confirm restore'))
await waitFor(() => {
expect(mockSetCurrentVersion).toHaveBeenCalledWith(expect.objectContaining({
id: 'published-version-id',
}))
expect(mockRestoreWorkflow).toHaveBeenCalledWith('/apps/app-1/workflows/published-version-id/restore')
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ isRestoring: false })
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ backupDraft: undefined })
expect(mockHandleRefreshWorkflowDraft).toHaveBeenCalled()
})
})
it('should keep restore mode backup state when restore request fails', async () => {
const { VersionHistoryPanel } = await import('../index')
mockRestoreWorkflow.mockRejectedValueOnce(new Error('restore failed'))
mockCurrentVersion = createVersionHistory({
id: 'draft-version-id',
version: WorkflowVersion.Draft,
})
render(
<VersionHistoryPanel
latestVersionId="published-version-id"
restoreVersionUrl={versionId => `/apps/app-1/workflows/${versionId}/restore`}
/>,
)
vi.clearAllMocks()
fireEvent.click(screen.getByText('restore-published-version-id'))
fireEvent.click(screen.getByText('confirm restore'))
await waitFor(() => {
expect(mockRestoreWorkflow).toHaveBeenCalledWith('/apps/app-1/workflows/published-version-id/restore')
})
expect(mockWorkflowStoreSetState).not.toHaveBeenCalledWith({ isRestoring: false })
expect(mockWorkflowStoreSetState).not.toHaveBeenCalledWith({ backupDraft: undefined })
expect(mockSetCurrentVersion).not.toHaveBeenCalled()
expect(mockHandleRefreshWorkflowDraft).not.toHaveBeenCalled()
})
})

View File

@ -9,8 +9,8 @@ import VersionInfoModal from '@/app/components/app/app-publisher/version-info-mo
import Divider from '@/app/components/base/divider'
import { toast } from '@/app/components/base/ui/toast'
import { useSelector as useAppContextSelector } from '@/context/app-context'
import { useDeleteWorkflow, useInvalidAllLastRun, useResetWorkflowVersionHistory, useUpdateWorkflow, useWorkflowVersionHistory } from '@/service/use-workflow'
import { useDSL, useNodesSyncDraft, useWorkflowRun } from '../../hooks'
import { useDeleteWorkflow, useInvalidAllLastRun, useResetWorkflowVersionHistory, useRestoreWorkflow, useUpdateWorkflow, useWorkflowVersionHistory } from '@/service/use-workflow'
import { useDSL, useWorkflowRefreshDraft, useWorkflowRun } from '../../hooks'
import { useHooksStore } from '../../hooks-store'
import { useStore, useWorkflowStore } from '../../store'
import { VersionHistoryContextMenuOptions, WorkflowVersion, WorkflowVersionFilterOptions } from '../../types'
@ -27,12 +27,14 @@ const INITIAL_PAGE = 1
export type VersionHistoryPanelProps = {
getVersionListUrl?: string
deleteVersionUrl?: (versionId: string) => string
restoreVersionUrl: (versionId: string) => string
updateVersionUrl?: (versionId: string) => string
latestVersionId?: string
}
export const VersionHistoryPanel = ({
getVersionListUrl,
deleteVersionUrl,
restoreVersionUrl,
updateVersionUrl,
latestVersionId,
}: VersionHistoryPanelProps) => {
@ -43,8 +45,8 @@ export const VersionHistoryPanel = ({
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const [editModalOpen, setEditModalOpen] = useState(false)
const workflowStore = useWorkflowStore()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const { handleRestoreFromPublishedWorkflow, handleLoadBackupDraft } = useWorkflowRun()
const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft()
const { handleExportDSL } = useDSL()
const setShowWorkflowVersionHistoryPanel = useStore(s => s.setShowWorkflowVersionHistoryPanel)
const currentVersion = useStore(s => s.currentVersion)
@ -144,32 +146,33 @@ export const VersionHistoryPanel = ({
}, [])
const resetWorkflowVersionHistory = useResetWorkflowVersionHistory()
const { mutateAsync: restoreWorkflow } = useRestoreWorkflow()
const handleRestore = useCallback((item: VersionHistory) => {
const handleRestore = useCallback(async (item: VersionHistory) => {
setShowWorkflowVersionHistoryPanel(false)
handleRestoreFromPublishedWorkflow(item)
workflowStore.setState({ isRestoring: false })
workflowStore.setState({ backupDraft: undefined })
handleSyncWorkflowDraft(true, false, {
onSuccess: () => {
toast.add({
type: 'success',
title: t('versionHistory.action.restoreSuccess', { ns: 'workflow' }),
})
deleteAllInspectVars()
invalidAllLastRun()
},
onError: () => {
toast.add({
type: 'error',
title: t('versionHistory.action.restoreFailure', { ns: 'workflow' }),
})
},
onSettled: () => {
resetWorkflowVersionHistory()
},
})
}, [setShowWorkflowVersionHistoryPanel, handleRestoreFromPublishedWorkflow, workflowStore, handleSyncWorkflowDraft, deleteAllInspectVars, invalidAllLastRun, t, resetWorkflowVersionHistory])
try {
await restoreWorkflow(restoreVersionUrl(item.id))
setCurrentVersion(item)
workflowStore.setState({ isRestoring: false })
workflowStore.setState({ backupDraft: undefined })
handleRefreshWorkflowDraft()
toast.add({
type: 'success',
title: t('versionHistory.action.restoreSuccess', { ns: 'workflow' }),
})
deleteAllInspectVars()
invalidAllLastRun()
}
catch {
toast.add({
type: 'error',
title: t('versionHistory.action.restoreFailure', { ns: 'workflow' }),
})
}
finally {
resetWorkflowVersionHistory()
}
}, [setShowWorkflowVersionHistoryPanel, setCurrentVersion, workflowStore, restoreWorkflow, restoreVersionUrl, handleRefreshWorkflowDraft, deleteAllInspectVars, invalidAllLastRun, t, resetWorkflowVersionHistory])
const { mutateAsync: deleteWorkflow } = useDeleteWorkflow()

View File

@ -1325,9 +1325,6 @@
}
},
"app/components/app/type-selector/index.tsx": {
"no-restricted-imports": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 3
}
@ -5211,14 +5208,11 @@
}
},
"app/components/plugins/plugin-detail-panel/model-selector/tts-params-panel.tsx": {
"no-restricted-imports": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 2
},
"ts/no-explicit-any": {
"count": 2
"count": 1
}
},
"app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.tsx": {
@ -5934,9 +5928,6 @@
}
},
"app/components/tools/edit-custom-collection-modal/get-schema.tsx": {
"no-restricted-imports": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 3
},
@ -5975,14 +5966,6 @@
"count": 1
}
},
"app/components/tools/labels/filter.tsx": {
"no-restricted-imports": {
"count": 1
},
"tailwindcss/no-unnecessary-whitespace": {
"count": 1
}
},
"app/components/tools/labels/selector.tsx": {
"no-restricted-imports": {
"count": 1
@ -6070,7 +6053,7 @@
},
"app/components/tools/mcp/modal.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 7
@ -6111,16 +6094,13 @@
}
},
"app/components/tools/provider/custom-create-card.tsx": {
"no-restricted-imports": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 1
}
},
"app/components/tools/provider/detail.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 10

View File

@ -35,6 +35,7 @@
"details.structureTooltip": "يحدد هيكل القطعة كيفية تقسيم المستندات وفهرستها - تقديم أوضاع عامة، الأصل والطفل، والأسئلة والأجوبة - وهي فريدة لكل قاعدة معرفة.",
"documentSettings.title": "إعدادات المستند",
"editPipelineInfo": "تعديل معلومات سير العمل",
"editPipelineInfoNameRequired": "يرجى إدخال اسم لقاعدة المعرفة.",
"exportDSL.errorTip": "فشل تصدير DSL لسير العمل",
"exportDSL.successTip": "تم تصدير DSL لسير العمل بنجاح",
"inputField": "حقل الإدخال",

View File

@ -35,6 +35,7 @@
"details.structureTooltip": "Die Blockstruktur bestimmt, wie Dokumente aufgeteilt und indiziert werden, und bietet die Modi \"Allgemein\", \"Über-Eltern-Kind\" und \"Q&A\" und ist für jede Wissensdatenbank einzigartig.",
"documentSettings.title": "Dokument-Einstellungen",
"editPipelineInfo": "Bearbeiten von Pipeline-Informationen",
"editPipelineInfoNameRequired": "Bitte geben Sie einen Namen für die Wissensdatenbank ein.",
"exportDSL.errorTip": "Fehler beim Exportieren der Pipeline-DSL",
"exportDSL.successTip": "Pipeline-DSL erfolgreich exportieren",
"inputField": "Eingabefeld",

View File

@ -77,6 +77,8 @@
"externalKnowledgeDescriptionPlaceholder": "Describe what's in this Knowledge Base (optional)",
"externalKnowledgeForm.cancel": "Cancel",
"externalKnowledgeForm.connect": "Connect",
"externalKnowledgeForm.connectedFailed": "Failed to connect External Knowledge Base",
"externalKnowledgeForm.connectedSuccess": "External Knowledge Base Connected Successfully",
"externalKnowledgeId": "External Knowledge ID",
"externalKnowledgeIdPlaceholder": "Please enter the Knowledge ID",
"externalKnowledgeName": "External Knowledge Name",

View File

@ -126,6 +126,8 @@
"mcp.modal.headerValuePlaceholder": "e.g., Bearer token123",
"mcp.modal.headers": "Headers",
"mcp.modal.headersTip": "Additional HTTP headers to send with MCP server requests",
"mcp.modal.invalidServerIdentifier": "Please enter a valid server identifier",
"mcp.modal.invalidServerUrl": "Please enter a valid server URL",
"mcp.modal.maskedHeadersTip": "Header values are masked for security. Changes will update the actual values.",
"mcp.modal.name": "Name & Icon",
"mcp.modal.namePlaceholder": "Name your MCP server",

View File

@ -35,6 +35,7 @@
"details.structureTooltip": "La estructura de fragmentos determina cómo se dividen e indexan los documentos, ofreciendo modos General, Principal-Secundario y Preguntas y respuestas, y es única para cada base de conocimiento.",
"documentSettings.title": "Parametrizaciones de documentos",
"editPipelineInfo": "Editar información de canalización",
"editPipelineInfoNameRequired": "Por favor, ingrese un nombre para la Base de Conocimiento.",
"exportDSL.errorTip": "No se pudo exportar DSL de canalización",
"exportDSL.successTip": "Exportar DSL de canalización correctamente",
"inputField": "Campo de entrada",

View File

@ -35,6 +35,7 @@
"details.structureTooltip": "ساختار Chunk نحوه تقسیم و نمایه سازی اسناد را تعیین می کند - حالت های عمومی، والد-فرزند و پرسش و پاسخ را ارائه می دهد - و برای هر پایگاه دانش منحصر به فرد است.",
"documentSettings.title": "تنظیمات سند",
"editPipelineInfo": "ویرایش اطلاعات خط لوله",
"editPipelineInfoNameRequired": "لطفاً یک نام برای پایگاه دانش وارد کنید.",
"exportDSL.errorTip": "صادرات DSL خط لوله انجام نشد",
"exportDSL.successTip": "DSL خط لوله را با موفقیت صادر کنید",
"inputField": "فیلد ورودی",

View File

@ -35,6 +35,7 @@
"details.structureTooltip": "La structure par blocs détermine la façon dont les documents sont divisés et indexés (en proposant les modes Général, Parent-Enfant et Q&R) et est unique à chaque base de connaissances.",
"documentSettings.title": "Paramètres du document",
"editPipelineInfo": "Modifier les informations sur le pipeline",
"editPipelineInfoNameRequired": "Veuillez saisir un nom pour la Base de connaissances.",
"exportDSL.errorTip": "Echec de lexportation du DSL du pipeline",
"exportDSL.successTip": "Pipeline dexportation DSL réussi",
"inputField": "Champ de saisie",

View File

@ -35,6 +35,7 @@
"details.structureTooltip": "चंक संरचना यह निर्धारित करती है कि दस्तावेज कैसे विभाजित और अनुक्रमित होते हैं—सामान्य, माता-पिता- बच्चे, और प्रश्नोत्तर मोड प्रदान करते हुए—और यह प्रत्येक ज्ञान आधार के लिए अद्वितीय होती है।",
"documentSettings.title": "डॉक्यूमेंट सेटिंग्स",
"editPipelineInfo": "पाइपलाइन जानकारी संपादित करें",
"editPipelineInfoNameRequired": "कृपया ज्ञान आधार के लिए एक नाम दर्ज करें।",
"exportDSL.errorTip": "पाइपलाइन DSL निर्यात करने में विफल",
"exportDSL.successTip": "निर्यात पाइपलाइन DSL सफलतापूर्वक",
"inputField": "इनपुट फ़ील्ड",

View File

@ -35,6 +35,7 @@
"details.structureTooltip": "Struktur Potongan menentukan bagaimana dokumen dibagi dan diindeks—menawarkan mode Umum, Induk-Anak, dan Tanya Jawab—dan unik untuk setiap basis pengetahuan.",
"documentSettings.title": "Pengaturan Dokumen",
"editPipelineInfo": "Mengedit info alur",
"editPipelineInfoNameRequired": "Silakan masukkan nama untuk Basis Pengetahuan.",
"exportDSL.errorTip": "Gagal mengekspor DSL alur",
"exportDSL.successTip": "Ekspor DSL pipeline berhasil",
"inputField": "Bidang Masukan",

View File

@ -35,6 +35,7 @@
"details.structureTooltip": "La struttura a blocchi determina il modo in cui i documenti vengono suddivisi e indicizzati, offrendo le modalità Generale, Padre-Figlio e Domande e risposte, ed è univoca per ogni knowledge base.",
"documentSettings.title": "Impostazioni documento",
"editPipelineInfo": "Modificare le informazioni sulla pipeline",
"editPipelineInfoNameRequired": "Inserisci un nome per la Knowledge Base.",
"exportDSL.errorTip": "Impossibile esportare il DSL della pipeline",
"exportDSL.successTip": "Esporta DSL pipeline con successo",
"inputField": "Campo di input",

View File

@ -35,6 +35,7 @@
"details.structureTooltip": "チャンク構造は、ドキュメントがどのように分割され、インデックスされるかを決定します。一般、親子、Q&Aモードを提供し、各ナレッジベースにユニークです。",
"documentSettings.title": "ドキュメント設定",
"editPipelineInfo": "パイプライン情報を編集する",
"editPipelineInfoNameRequired": "ナレッジベースの名前を入力してください。",
"exportDSL.errorTip": "パイプラインDSLのエクスポートに失敗しました",
"exportDSL.successTip": "エクスポートパイプラインDSLが成功しました",
"inputField": "入力フィールド",

View File

@ -35,6 +35,7 @@
"details.structureTooltip": "청크 구조는 문서를 분할하고 인덱싱하는 방법(일반, 부모-자식 및 Q&A 모드를 제공)을 결정하며 각 기술 자료에 고유합니다.",
"documentSettings.title": "문서 설정",
"editPipelineInfo": "파이프라인 정보 편집",
"editPipelineInfoNameRequired": "기술 자료의 이름을 입력해 주세요.",
"exportDSL.errorTip": "파이프라인 DSL을 내보내지 못했습니다.",
"exportDSL.successTip": "파이프라인 DSL 내보내기 성공",
"inputField": "입력 필드",

View File

@ -35,6 +35,7 @@
"details.structureTooltip": "Chunk Structure determines how documents are split and indexed—offering General, Parent-Child, and Q&A modes—and is unique to each knowledge base.",
"documentSettings.title": "Document Settings",
"editPipelineInfo": "Edit pipeline info",
"editPipelineInfoNameRequired": "Voer een naam in voor de Kennisbank.",
"exportDSL.errorTip": "Failed to export pipeline DSL",
"exportDSL.successTip": "Export pipeline DSL successfully",
"inputField": "Input Field",

View File

@ -35,6 +35,7 @@
"details.structureTooltip": "Struktura fragmentów określa sposób dzielenia i indeksowania dokumentów — oferując tryby Ogólne, Nadrzędny-Podrzędny oraz Q&A — i jest unikatowa dla każdej bazy wiedzy.",
"documentSettings.title": "Ustawienia dokumentu",
"editPipelineInfo": "Edytowanie informacji o potoku",
"editPipelineInfoNameRequired": "Proszę podać nazwę Bazy Wiedzy.",
"exportDSL.errorTip": "Nie można wyeksportować DSL potoku",
"exportDSL.successTip": "Pomyślnie wyeksportowano potok DSL",
"inputField": "Pole wejściowe",

View File

@ -35,6 +35,7 @@
"details.structureTooltip": "A Estrutura de Partes determina como os documentos são divididos e indexados, oferecendo os modos Geral, Pai-Filho e P e Resposta, e é exclusiva para cada base de conhecimento.",
"documentSettings.title": "Configurações do documento",
"editPipelineInfo": "Editar informações do pipeline",
"editPipelineInfoNameRequired": "Por favor, insira um nome para a Base de Conhecimento.",
"exportDSL.errorTip": "Falha ao exportar DSL de pipeline",
"exportDSL.successTip": "Exportar DSL de pipeline com êxito",
"inputField": "Campo de entrada",

View File

@ -35,6 +35,7 @@
"details.structureTooltip": "Structura de bucăți determină modul în care documentele sunt împărțite și indexate - oferind modurile General, Părinte-Copil și Întrebări și răspunsuri - și este unică pentru fiecare bază de cunoștințe.",
"documentSettings.title": "Setări document",
"editPipelineInfo": "Editați informațiile despre conductă",
"editPipelineInfoNameRequired": "Vă rugăm să introduceți un nume pentru Baza de Cunoștințe.",
"exportDSL.errorTip": "Nu s-a reușit exportul DSL al conductei",
"exportDSL.successTip": "Exportați cu succes DSL",
"inputField": "Câmp de intrare",

View File

@ -35,6 +35,7 @@
"details.structureTooltip": "Структура блоков определяет порядок разделения и индексирования документов (в соответствии с режимами «Общие», «Родитель-потомок» и «Вопросы и ответы») и является уникальной для каждой базы знаний.",
"documentSettings.title": "Настройки документа",
"editPipelineInfo": "Редактирование сведений о воронке продаж",
"editPipelineInfoNameRequired": "Пожалуйста, введите название базы знаний.",
"exportDSL.errorTip": "Не удалось экспортировать DSL конвейера",
"exportDSL.successTip": "Экспорт конвейера DSL успешно",
"inputField": "Поле ввода",

View File

@ -35,6 +35,7 @@
"details.structureTooltip": "Struktura kosov določa, kako so dokumenti razdeljeni in indeksirani ponuja načine Splošno, Nadrejeno-podrejeno in Vprašanja in odgovori in je edinstvena za vsako zbirko znanja.",
"documentSettings.title": "Nastavitve dokumenta",
"editPipelineInfo": "Urejanje informacij o cevovodu",
"editPipelineInfoNameRequired": "Prosim vnesite ime za Bazo znanja.",
"exportDSL.errorTip": "Izvoz cevovoda DSL ni uspel",
"exportDSL.successTip": "Uspešno izvozite DSL",
"inputField": "Vnosno polje",

View File

@ -35,6 +35,7 @@
"details.structureTooltip": "โครงสร้างก้อนกําหนดวิธีการแยกและจัดทําดัชนีเอกสาร โดยเสนอโหมดทั่วไป ผู้ปกครอง-รอง และ Q&A และไม่ซ้ํากันสําหรับแต่ละฐานความรู้",
"documentSettings.title": "การตั้งค่าเอกสาร",
"editPipelineInfo": "แก้ไขข้อมูลไปป์ไลน์",
"editPipelineInfoNameRequired": "โปรดป้อนชื่อสำหรับฐานความรู้",
"exportDSL.errorTip": "ไม่สามารถส่งออก DSL ไปป์ไลน์ได้",
"exportDSL.successTip": "ส่งออก DSL ไปป์ไลน์สําเร็จ",
"inputField": "ฟิลด์อินพุต",

View File

@ -35,6 +35,7 @@
"details.structureTooltip": "Yığın Yapısı, belgelerin nasıl bölündüğünü ve dizine eklendiğini belirler (Genel, Üst-Alt ve Soru-Cevap modları sunar) ve her bilgi bankası için benzersizdir.",
"documentSettings.title": "Belge Ayarları",
"editPipelineInfo": "İşlem hattı bilgilerini düzenleme",
"editPipelineInfoNameRequired": "Lütfen Bilgi Bankası için bir ad girin.",
"exportDSL.errorTip": "İşlem hattı DSL'si dışarı aktarılamadı",
"exportDSL.successTip": "İşlem hattı DSL'sini başarıyla dışarı aktarın",
"inputField": "Giriş Alanı",

View File

@ -35,6 +35,7 @@
"details.structureTooltip": "Структура фрагментів визначає, як документи розділяються та індексуються (пропонуючи режими «Загальні», «Батьки-дочірні елементи» та «Запитання й відповіді»), і є унікальною для кожної бази знань.",
"documentSettings.title": "Параметри документа",
"editPipelineInfo": "Як редагувати інформацію про воронку продажів",
"editPipelineInfoNameRequired": "Будь ласка, введіть назву Бази знань.",
"exportDSL.errorTip": "Не вдалося експортувати DSL пайплайну",
"exportDSL.successTip": "Успішний експорт DSL воронки продажів",
"inputField": "Поле введення",

View File

@ -35,6 +35,7 @@
"details.structureTooltip": "Chunk Structure xác định cách các tài liệu được phân tách và lập chỉ mục — cung cấp các chế độ General, Parent-Child và Q&A — và là duy nhất cho mỗi cơ sở tri thức.",
"documentSettings.title": "Cài đặt tài liệu",
"editPipelineInfo": "Chỉnh sửa thông tin quy trình",
"editPipelineInfoNameRequired": "Vui lòng nhập tên cho Cơ sở Kiến thức.",
"exportDSL.errorTip": "Không thể xuất DSL đường ống",
"exportDSL.successTip": "Xuất DSL quy trình thành công",
"inputField": "Trường đầu vào",

View File

@ -35,6 +35,7 @@
"details.structureTooltip": "文档结构决定了文档的拆分和索引方式Dify 提供了通用、父子和问答模式,每个知识库的文档结构是唯一的。",
"documentSettings.title": "文档设置",
"editPipelineInfo": "编辑知识流水线信息",
"editPipelineInfoNameRequired": "请输入知识库的名称。",
"exportDSL.errorTip": "导出知识流水线 DSL 失败",
"exportDSL.successTip": "成功导出知识流水线 DSL",
"inputField": "输入字段",

View File

@ -35,6 +35,7 @@
"details.structureTooltip": "區塊結構會決定文件的分割和索引方式 (提供一般、父子和問答模式),而且每個知識庫都是唯一的。",
"documentSettings.title": "文件設定",
"editPipelineInfo": "編輯管線資訊",
"editPipelineInfoNameRequired": "請輸入知識庫的名稱。",
"exportDSL.errorTip": "無法匯出管線 DSL",
"exportDSL.successTip": "成功匯出管線 DSL",
"inputField": "輸入欄位",

View File

@ -113,6 +113,13 @@ export const useDeleteWorkflow = () => {
})
}
export const useRestoreWorkflow = () => {
return useMutation({
mutationKey: [NAME_SPACE, 'restore'],
mutationFn: (url: string) => post<CommonResponse & { updated_at: number, hash: string }>(url, {}, { silent: true }),
})
}
export const usePublishWorkflow = () => {
return useMutation({
mutationKey: [NAME_SPACE, 'publish'],