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 flask_restx import Resource, fields, marshal_with
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound
import services import services
from controllers.console import console_ns from controllers.console import console_ns
@ -46,13 +46,14 @@ from models import App
from models.model import AppMode from models.model import AppMode
from models.workflow import Workflow from models.workflow import Workflow
from services.app_generate_service import AppGenerateService 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.errors.llm import InvokeRateLimitError
from services.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError, WorkflowService from services.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError, WorkflowService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
LISTENING_RETRY_IN = 2000 LISTENING_RETRY_IN = 2000
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" 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 models for flask_restx to avoid dict type issues in Swagger
# Register in dependency order: base models first, then dependent models # Register in dependency order: base models first, then dependent models
@ -284,7 +285,9 @@ class DraftWorkflowApi(Resource):
workflow_service = WorkflowService() workflow_service = WorkflowService()
try: try:
environment_variables_list = args.get("environment_variables") or [] environment_variables_list = Workflow.normalize_environment_variable_mappings(
args.get("environment_variables") or [],
)
environment_variables = [ environment_variables = [
variable_factory.build_environment_variable_from_mapping(obj) for obj in environment_variables_list 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>") @console_ns.route("/apps/<uuid:app_id>/workflows/<string:workflow_id>")
class WorkflowByIdApi(Resource): class WorkflowByIdApi(Resource):
@console_ns.doc("update_workflow_by_id") @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 flask_restx import Resource, marshal_with # type: ignore
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound
import services import services
from controllers.common.schema import register_schema_models from controllers.common.schema import register_schema_models
@ -16,7 +16,11 @@ from controllers.console.app.error import (
DraftWorkflowNotExist, DraftWorkflowNotExist,
DraftWorkflowNotSync, 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 ( from controllers.console.app.workflow_run import (
workflow_run_detail_model, workflow_run_detail_model,
workflow_run_node_execution_list_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 import Account
from models.dataset import Pipeline from models.dataset import Pipeline
from models.model import EndUser 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.errors.llm import InvokeRateLimitError
from services.rag_pipeline.pipeline_generate_service import PipelineGenerateService from services.rag_pipeline.pipeline_generate_service import PipelineGenerateService
from services.rag_pipeline.rag_pipeline import RagPipelineService from services.rag_pipeline.rag_pipeline import RagPipelineService
@ -203,9 +208,12 @@ class DraftRagPipelineApi(Resource):
abort(415) abort(415)
payload = DraftWorkflowSyncPayload.model_validate(payload_dict) payload = DraftWorkflowSyncPayload.model_validate(payload_dict)
rag_pipeline_service = RagPipelineService()
try: try:
environment_variables_list = payload.environment_variables or [] environment_variables_list = Workflow.normalize_environment_variable_mappings(
payload.environment_variables or [],
)
environment_variables = [ environment_variables = [
variable_factory.build_environment_variable_from_mapping(obj) for obj in environment_variables_list variable_factory.build_environment_variable_from_mapping(obj) for obj in environment_variables_list
] ]
@ -213,7 +221,6 @@ class DraftRagPipelineApi(Resource):
conversation_variables = [ conversation_variables = [
variable_factory.build_conversation_variable_from_mapping(obj) for obj in conversation_variables_list 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( workflow = rag_pipeline_service.sync_draft_workflow(
pipeline=pipeline, pipeline=pipeline,
graph=payload.graph, 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>") @console_ns.route("/rag/pipelines/<uuid:pipeline_id>/workflows/<string:workflow_id>")
class RagPipelineByIdApi(Resource): class RagPipelineByIdApi(Resource):
@setup_required @setup_required

View File

@ -101,6 +101,9 @@ class HttpRequestNode(Node[HttpRequestNodeData]):
timeout=self._get_request_timeout(self.node_data), timeout=self._get_request_timeout(self.node_data),
variable_pool=self.graph_runtime_state.variable_pool, variable_pool=self.graph_runtime_state.variable_pool,
http_request_config=self._http_request_config, 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, ssl_verify=self.node_data.ssl_verify,
http_client=self._http_client, http_client=self._http_client,
file_manager=self._file_manager, file_manager=self._file_manager,

View File

@ -1,3 +1,4 @@
import copy
import json import json
import logging import logging
from collections.abc import Generator, Mapping, Sequence from collections.abc import Generator, Mapping, Sequence
@ -302,26 +303,40 @@ class Workflow(Base): # bug
def features(self) -> str: def features(self) -> str:
""" """
Convert old features structure to new features structure. 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: if not self._features:
return self._features return self._features
features = json.loads(self._features) # Parse once and deep-copy before normalization to detect in-place changes.
if features.get("file_upload", {}).get("image", {}).get("enabled", False): original_dict = self._decode_features_payload(self._features)
image_enabled = True if original_dict is None:
image_number_limits = int(features["file_upload"]["image"].get("number_limits", DEFAULT_FILE_NUMBER_LIMITS)) return self._features
image_transfer_methods = features["file_upload"]["image"].get(
"transfer_methods", ["remote_url", "local_file"] # Fast-path: if the legacy file_upload.image.enabled shape is absent, skip
) # deep-copy and normalization entirely and return the stored JSON.
features["file_upload"]["enabled"] = image_enabled file_upload_payload = original_dict.get("file_upload")
features["file_upload"]["number_limits"] = image_number_limits if not isinstance(file_upload_payload, dict):
features["file_upload"]["allowed_file_upload_methods"] = image_transfer_methods return self._features
features["file_upload"]["allowed_file_types"] = features["file_upload"].get("allowed_file_types", ["image"]) file_upload = cast(dict[str, Any], file_upload_payload)
features["file_upload"]["allowed_file_extensions"] = features["file_upload"].get(
"allowed_file_extensions", [] image_payload = file_upload.get("image")
) if not isinstance(image_payload, dict):
del features["file_upload"]["image"] return self._features
self._features = json.dumps(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 return self._features
@features.setter @features.setter
@ -332,6 +347,44 @@ class Workflow(Base): # bug
def features_dict(self) -> dict[str, Any]: def features_dict(self) -> dict[str, Any]:
return json.loads(self.features) if self.features else {} 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( def walk_nodes(
self, specific_node_type: NodeType | None = None self, specific_node_type: NodeType | None = None
) -> Generator[tuple[str, Mapping[str, Any]], None, None]: ) -> Generator[tuple[str, Mapping[str, Any]], None, None]:
@ -517,6 +570,31 @@ class Workflow(Base): # bug
) )
self._environment_variables = environment_variables_json 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: def to_dict(self, *, include_secret: bool = False) -> WorkflowContentDict:
environment_variables = list(self.environment_variables) environment_variables = list(self.environment_variables)
environment_variables = [ environment_variables = [
@ -564,6 +642,12 @@ class Workflow(Base): # bug
ensure_ascii=False, 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 @staticmethod
def version_from_datetime(d: datetime) -> str: def version_from_datetime(d: datetime) -> str:
return str(d) return str(d)

View File

@ -79,10 +79,11 @@ from services.entities.knowledge_entities.rag_pipeline_entities import (
KnowledgeConfiguration, KnowledgeConfiguration,
PipelineTemplateInfoEntity, 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.rag_pipeline.pipeline_template.pipeline_template_factory import PipelineTemplateRetrievalFactory
from services.tools.builtin_tools_manage_service import BuiltinToolManageService from services.tools.builtin_tools_manage_service import BuiltinToolManageService
from services.workflow_draft_variable_service import DraftVariableSaver, DraftVarLoader from services.workflow_draft_variable_service import DraftVariableSaver, DraftVarLoader
from services.workflow_restore import apply_published_workflow_snapshot_to_draft
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -234,6 +235,21 @@ class RagPipelineService:
return workflow 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( def get_all_published_workflow(
self, self,
*, *,
@ -327,6 +343,42 @@ class RagPipelineService:
# return draft workflow # return draft workflow
return 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( def publish_workflow(
self, 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 repositories.factory import DifyAPIRepositoryFactory
from services.billing_service import BillingService from services.billing_service import BillingService
from services.enterprise.plugin_manager_service import PluginCredentialType 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 services.workflow.workflow_converter import WorkflowConverter
from .errors.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError from .errors.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError
@ -75,6 +80,7 @@ from .human_input_delivery_test_service import (
HumanInputDeliveryTestService, HumanInputDeliveryTestService,
) )
from .workflow_draft_variable_service import DraftVariableSaver, DraftVarLoader, WorkflowDraftVariableService from .workflow_draft_variable_service import DraftVariableSaver, DraftVarLoader, WorkflowDraftVariableService
from .workflow_restore import apply_published_workflow_snapshot_to_draft
class WorkflowService: class WorkflowService:
@ -279,6 +285,43 @@ class WorkflowService:
# return draft workflow # return draft workflow
return 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( def publish_workflow(
self, self,
*, *,

View File

@ -802,6 +802,81 @@ class TestWorkflowService:
with pytest.raises(ValueError, match="No valid workflow found"): with pytest.raises(ValueError, match="No valid workflow found"):
workflow_service.publish_workflow(session=db_session_with_containers, app_model=app, account=account) 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): def test_get_default_block_configs(self, db_session_with_containers: Session):
""" """
Test retrieval of default block configurations for all node types. 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")) 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: def test_draft_workflow_get_not_found(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr( monkeypatch.setattr(
workflow_module, "WorkflowService", lambda: SimpleNamespace(get_draft_workflow=lambda **_k: None) 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 from unittest.mock import MagicMock, patch
import pytest import pytest
from werkzeug.exceptions import Forbidden, NotFound from werkzeug.exceptions import Forbidden, HTTPException, NotFound
import services import services
from controllers.console import console_ns from controllers.console import console_ns
@ -19,13 +19,14 @@ from controllers.console.datasets.rag_pipeline.rag_pipeline_workflow import (
RagPipelineDraftNodeRunApi, RagPipelineDraftNodeRunApi,
RagPipelineDraftRunIterationNodeApi, RagPipelineDraftRunIterationNodeApi,
RagPipelineDraftRunLoopNodeApi, RagPipelineDraftRunLoopNodeApi,
RagPipelineDraftWorkflowRestoreApi,
RagPipelineRecommendedPluginApi, RagPipelineRecommendedPluginApi,
RagPipelineTaskStopApi, RagPipelineTaskStopApi,
RagPipelineTransformApi, RagPipelineTransformApi,
RagPipelineWorkflowLastRunApi, RagPipelineWorkflowLastRunApi,
) )
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError 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 from services.errors.llm import InvokeRateLimitError
@ -116,6 +117,86 @@ class TestDraftWorkflowApi:
response, status = method(api, pipeline) response, status = method(api, pipeline)
assert status == 400 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: class TestDraftRunNodes:
def test_iteration_node_success(self, app): def test_iteration_node_success(self, app):

View File

@ -4,12 +4,18 @@ from unittest import mock
from uuid import uuid4 from uuid import uuid4
from constants import HIDDEN_VALUE from constants import HIDDEN_VALUE
from core.helper import encrypter
from dify_graph.file.enums import FileTransferMethod, FileType from dify_graph.file.enums import FileTransferMethod, FileType
from dify_graph.file.models import File from dify_graph.file.models import File
from dify_graph.variables import FloatVariable, IntegerVariable, SecretVariable, StringVariable from dify_graph.variables import FloatVariable, IntegerVariable, SecretVariable, StringVariable
from dify_graph.variables.segments import IntegerSegment, Segment from dify_graph.variables.segments import IntegerSegment, Segment
from factories.variable_factory import build_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(): def test_environment_variables():
@ -144,6 +150,36 @@ def test_to_dict():
assert workflow_dict["environment_variables"][1]["value"] == "text" 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: class TestWorkflowNodeExecution:
def test_execution_metadata_dict(self): def test_execution_metadata_dict(self):
node_exec = WorkflowNodeExecutionModel() node_exec = WorkflowNodeExecutionModel()

View File

@ -544,6 +544,89 @@ class TestWorkflowService:
conversation_variables=[], 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 ==================== # ==================== Workflow Validation Tests ====================
# These tests verify graph structure and feature configuration validation # 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 * as React from 'react'
import { AppModeEnum } from '@/types/app' import { AppModeEnum } from '@/types/app'
import AppTypeSelector, { AppTypeIcon, AppTypeLabel } from './index' import AppTypeSelector, { AppTypeIcon, AppTypeLabel } from './index'
@ -14,7 +14,7 @@ describe('AppTypeSelector', () => {
render(<AppTypeSelector value={[]} onChange={vi.fn()} />) render(<AppTypeSelector value={[]} onChange={vi.fn()} />)
expect(screen.getByText('app.typeSelector.all')).toBeInTheDocument() 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. // Covers opening/closing the dropdown and selection updates.
describe('User interactions', () => { 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()} />) 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')) fireEvent.click(screen.getByRole('button', { name: 'app.typeSelector.all' }))
expect(screen.getByRole('tooltip')).toBeInTheDocument() expect(screen.getByRole('list')).toBeInTheDocument()
fireEvent.click(screen.getByText('app.typeSelector.all')) fireEvent.pointerDown(document.body)
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument() fireEvent.click(document.body)
return waitFor(() => {
expect(screen.queryByRole('list')).not.toBeInTheDocument()
})
}) })
it('should call onChange with added type when selecting an unselected item', () => { it('should call onChange with added type when selecting an unselected item', () => {
const onChange = vi.fn() const onChange = vi.fn()
render(<AppTypeSelector value={[]} onChange={onChange} />) render(<AppTypeSelector value={[]} onChange={onChange} />)
fireEvent.click(screen.getByText('app.typeSelector.all')) fireEvent.click(screen.getByRole('button', { name: 'app.typeSelector.all' }))
fireEvent.click(within(screen.getByRole('tooltip')).getByText('app.typeSelector.workflow')) fireEvent.click(within(screen.getByRole('list')).getByRole('button', { name: 'app.typeSelector.workflow' }))
expect(onChange).toHaveBeenCalledWith([AppModeEnum.WORKFLOW]) expect(onChange).toHaveBeenCalledWith([AppModeEnum.WORKFLOW])
}) })
@ -65,8 +68,8 @@ describe('AppTypeSelector', () => {
const onChange = vi.fn() const onChange = vi.fn()
render(<AppTypeSelector value={[AppModeEnum.WORKFLOW]} onChange={onChange} />) render(<AppTypeSelector value={[AppModeEnum.WORKFLOW]} onChange={onChange} />)
fireEvent.click(screen.getByText('app.typeSelector.workflow')) fireEvent.click(screen.getByRole('button', { name: 'app.typeSelector.workflow' }))
fireEvent.click(within(screen.getByRole('tooltip')).getByText('app.typeSelector.workflow')) fireEvent.click(within(screen.getByRole('list')).getByRole('button', { name: 'app.typeSelector.workflow' }))
expect(onChange).toHaveBeenCalledWith([]) expect(onChange).toHaveBeenCalledWith([])
}) })
@ -75,8 +78,8 @@ describe('AppTypeSelector', () => {
const onChange = vi.fn() const onChange = vi.fn()
render(<AppTypeSelector value={[AppModeEnum.CHAT]} onChange={onChange} />) render(<AppTypeSelector value={[AppModeEnum.CHAT]} onChange={onChange} />)
fireEvent.click(screen.getByText('app.typeSelector.chatbot')) fireEvent.click(screen.getByRole('button', { name: 'app.typeSelector.chatbot' }))
fireEvent.click(within(screen.getByRole('tooltip')).getByText('app.typeSelector.agent')) fireEvent.click(within(screen.getByRole('list')).getByRole('button', { name: 'app.typeSelector.agent' }))
expect(onChange).toHaveBeenCalledWith([AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT]) expect(onChange).toHaveBeenCalledWith([AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT])
}) })
@ -88,7 +91,7 @@ describe('AppTypeSelector', () => {
fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' })) fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' }))
expect(onChange).toHaveBeenCalledWith([]) 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 { useTranslation } from 'react-i18next'
import { BubbleTextMod, ChatBot, ListSparkle, Logic } from '@/app/components/base/icons/src/vender/solid/communication' import { BubbleTextMod, ChatBot, ListSparkle, Logic } from '@/app/components/base/icons/src/vender/solid/communication'
import { import {
PortalToFollowElem, Popover,
PortalToFollowElemContent, PopoverContent,
PortalToFollowElemTrigger, PopoverTrigger,
} from '@/app/components/base/portal-to-follow-elem' } from '@/app/components/base/ui/popover'
import { AppModeEnum } from '@/types/app' import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import Checkbox from '../../base/checkbox'
export type AppSelectorProps = { export type AppSelectorProps = {
value: Array<AppModeEnum> value: Array<AppModeEnum>
@ -22,43 +21,43 @@ const allTypes: AppModeEnum[] = [AppModeEnum.WORKFLOW, AppModeEnum.ADVANCED_CHAT
const AppTypeSelector = ({ value, onChange }: AppSelectorProps) => { const AppTypeSelector = ({ value, onChange }: AppSelectorProps) => {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const { t } = useTranslation() const { t } = useTranslation()
const triggerLabel = value.length === 0
? t('typeSelector.all', { ns: 'app' })
: value.map(type => getAppTypeLabel(type, t)).join(', ')
return ( return (
<PortalToFollowElem <Popover
open={open} open={open}
onOpenChange={setOpen} onOpenChange={setOpen}
placement="bottom-start"
offset={4}
> >
<div className="relative"> <div className="relative">
<PortalToFollowElemTrigger <PopoverTrigger
onClick={() => setOpen(v => !v)} aria-label={triggerLabel}
className="block" className={cn(
> 'flex cursor-pointer items-center justify-between rounded-md px-2 hover:bg-state-base-hover',
<div className={cn( value.length > 0 && 'pr-7',
'flex cursor-pointer items-center justify-between space-x-1 rounded-md px-2 hover:bg-state-base-hover',
)} )}
>
<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} /> <RiCloseCircleFill
{value && value.length > 0 && ( className="h-3.5 w-3.5 text-text-quaternary group-hover:text-text-tertiary"
<button />
type="button" </button>
aria-label={t('operation.clear', { ns: 'common' })} )}
className="group h-4 w-4" <PopoverContent
onClick={(e) => { placement="bottom-start"
e.stopPropagation() sideOffset={4}
onChange([]) 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">
<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]">
{allTypes.map(mode => ( {allTypes.map(mode => (
<AppTypeSelectorItem <AppTypeSelectorItem
key={mode} key={mode}
@ -73,9 +72,9 @@ const AppTypeSelector = ({ value, onChange }: AppSelectorProps) => {
/> />
))} ))}
</ul> </ul>
</PortalToFollowElemContent> </PopoverContent>
</div> </div>
</PortalToFollowElem> </Popover>
) )
} }
@ -173,33 +172,54 @@ type AppTypeSelectorItemProps = {
} }
function AppTypeSelectorItem({ checked, type, onClick }: AppTypeSelectorItemProps) { function AppTypeSelectorItem({ checked, type, onClick }: AppTypeSelectorItemProps) {
return ( 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}> <li>
<Checkbox checked={checked} /> <button
<AppTypeIcon type={type} /> type="button"
<div className="grow p-1 pl-0"> className="flex w-full items-center space-x-2 rounded-lg py-1 pl-2 pr-1 text-left hover:bg-state-base-hover"
<AppTypeLabel type={type} className="system-sm-medium text-components-menu-item-text" /> aria-pressed={checked}
</div> 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> </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 AppTypeLabelProps = {
type: AppModeEnum type: AppModeEnum
className?: string className?: string
} }
export function AppTypeLabel({ type, className }: AppTypeLabelProps) { export function AppTypeLabel({ type, className }: AppTypeLabelProps) {
const { t } = useTranslation() 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(), trackEvent: vi.fn(),
})) }))
vi.mock('@/app/components/base/toast', () => ({ const { mockToastNotify } = vi.hoisted(() => ({
default: { mockToastNotify: vi.fn(),
notify: 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 mockCreateEmptyDataset = vi.fn()
const mockInvalidDatasetList = vi.fn() const mockInvalidDatasetList = vi.fn()
@ -37,6 +45,8 @@ vi.mock('@/service/knowledge/use-dataset', () => ({
describe('CreateCard', () => { describe('CreateCard', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mockToastNotify.mockReset()
mockToastNotify.mockImplementation(() => ({ clear: vi.fn() }))
}) })
describe('Rendering', () => { describe('Rendering', () => {

View File

@ -1,8 +1,6 @@
import type { PipelineTemplate } from '@/models/pipeline' import type { PipelineTemplate } from '@/models/pipeline'
import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import Toast from '@/app/components/base/toast'
import { ChunkingMode } from '@/models/datasets' import { ChunkingMode } from '@/models/datasets'
import EditPipelineInfo from '../edit-pipeline-info' import EditPipelineInfo from '../edit-pipeline-info'
@ -16,12 +14,21 @@ vi.mock('@/service/use-pipeline', () => ({
useInvalidCustomizedTemplateList: () => mockInvalidCustomizedTemplateList, useInvalidCustomizedTemplateList: () => mockInvalidCustomizedTemplateList,
})) }))
vi.mock('@/app/components/base/toast', () => ({ const { mockToastAdd } = vi.hoisted(() => ({
default: { mockToastAdd: vi.fn(),
notify: 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 // Mock AppIconPicker to capture interactions
let _mockOnSelect: ((icon: { type: 'emoji' | 'image', icon?: string, background?: string, fileId?: string, url?: string }) => void) | undefined let _mockOnSelect: ((icon: { type: 'emoji' | 'image', icon?: string, background?: string, fileId?: string, url?: string }) => void) | undefined
let _mockOnClose: (() => void) | undefined let _mockOnClose: (() => void) | undefined
@ -88,6 +95,7 @@ describe('EditPipelineInfo', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mockToastAdd.mockReset()
_mockOnSelect = undefined _mockOnSelect = undefined
_mockOnClose = undefined _mockOnClose = undefined
}) })
@ -235,9 +243,9 @@ describe('EditPipelineInfo', () => {
fireEvent.click(saveButton) fireEvent.click(saveButton)
await waitFor(() => { await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({ expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error', 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 type { PipelineTemplate } from '@/models/pipeline'
import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import Toast from '@/app/components/base/toast'
import { ChunkingMode } from '@/models/datasets' import { ChunkingMode } from '@/models/datasets'
import TemplateCard from '../index' import TemplateCard from '../index'
@ -15,12 +14,21 @@ vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: vi.fn(), trackEvent: vi.fn(),
})) }))
vi.mock('@/app/components/base/toast', () => ({ const { mockToastAdd } = vi.hoisted(() => ({
default: { mockToastAdd: vi.fn(),
notify: 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 // Mock download utilities
vi.mock('@/utils/download', () => ({ vi.mock('@/utils/download', () => ({
downloadBlob: vi.fn(), downloadBlob: vi.fn(),
@ -174,6 +182,7 @@ describe('TemplateCard', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mockToastAdd.mockReset()
mockIsExporting = false mockIsExporting = false
_capturedOnConfirm = undefined _capturedOnConfirm = undefined
_capturedOnCancel = undefined _capturedOnCancel = undefined
@ -228,9 +237,9 @@ describe('TemplateCard', () => {
fireEvent.click(chooseButton) fireEvent.click(chooseButton)
await waitFor(() => { await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({ expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error', type: 'error',
message: expect.any(String), title: expect.any(String),
}) })
}) })
}) })
@ -291,9 +300,9 @@ describe('TemplateCard', () => {
fireEvent.click(chooseButton) fireEvent.click(chooseButton)
await waitFor(() => { await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({ expect(mockToastAdd).toHaveBeenCalledWith({
type: 'success', type: 'success',
message: expect.any(String), title: expect.any(String),
}) })
}) })
}) })
@ -309,9 +318,9 @@ describe('TemplateCard', () => {
fireEvent.click(chooseButton) fireEvent.click(chooseButton)
await waitFor(() => { await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({ expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error', type: 'error',
message: expect.any(String), title: expect.any(String),
}) })
}) })
}) })
@ -458,9 +467,9 @@ describe('TemplateCard', () => {
fireEvent.click(exportButton) fireEvent.click(exportButton)
await waitFor(() => { await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({ expect(mockToastAdd).toHaveBeenCalledWith({
type: 'success', type: 'success',
message: expect.any(String), title: expect.any(String),
}) })
}) })
}) })
@ -476,9 +485,9 @@ describe('TemplateCard', () => {
fireEvent.click(exportButton) fireEvent.click(exportButton)
await waitFor(() => { await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({ expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error', type: 'error',
message: expect.any(String), title: expect.any(String),
}) })
}) })
}) })

View File

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

View File

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

View File

@ -1,13 +1,24 @@
import type { NotionPage } from '@/models/common' import type { NotionPage } from '@/models/common'
import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react' import * as React from 'react'
import Toast from '@/app/components/base/toast'
import OnlineDocumentPreview from '../online-document-preview' import OnlineDocumentPreview from '../online-document-preview'
// Uses global react-i18next mock from web/vitest.setup.ts // Uses global react-i18next mock from web/vitest.setup.ts
// Spy on Toast.notify const { mockToastAdd } = vi.hoisted(() => ({
const toastNotifySpy = vi.spyOn(Toast, 'notify') 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 // Mock dataset-detail context - needs mock to control return values
const mockPipelineId = vi.fn() const mockPipelineId = vi.fn()
@ -56,6 +67,7 @@ const defaultProps = {
describe('OnlineDocumentPreview', () => { describe('OnlineDocumentPreview', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mockToastAdd.mockReset()
mockPipelineId.mockReturnValue('pipeline-123') mockPipelineId.mockReturnValue('pipeline-123')
mockUsePreviewOnlineDocument.mockReturnValue({ mockUsePreviewOnlineDocument.mockReturnValue({
mutateAsync: mockMutateAsync, mutateAsync: mockMutateAsync,
@ -258,9 +270,9 @@ describe('OnlineDocumentPreview', () => {
render(<OnlineDocumentPreview {...defaultProps} />) render(<OnlineDocumentPreview {...defaultProps} />)
await waitFor(() => { await waitFor(() => {
expect(toastNotifySpy).toHaveBeenCalledWith({ expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error', type: 'error',
message: errorMessage, title: errorMessage,
}) })
}) })
}) })
@ -276,9 +288,9 @@ describe('OnlineDocumentPreview', () => {
render(<OnlineDocumentPreview {...defaultProps} />) render(<OnlineDocumentPreview {...defaultProps} />)
await waitFor(() => { await waitFor(() => {
expect(toastNotifySpy).toHaveBeenCalledWith({ expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error', 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 React from 'react'
import * as z from 'zod' import * as z from 'zod'
import { BaseFieldType } from '@/app/components/base/form/form-scenarios/base/types' import { BaseFieldType } from '@/app/components/base/form/form-scenarios/base/types'
import Toast from '@/app/components/base/toast'
import Actions from '../actions' import Actions from '../actions'
import Form from '../form' import Form from '../form'
import Header from '../header' import Header from '../header'
// Spy on Toast.notify for validation tests const { mockToastAdd } = vi.hoisted(() => ({
const toastNotifySpy = vi.spyOn(Toast, 'notify') 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 // Test Data Factory Functions
@ -335,7 +346,7 @@ describe('Form', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
toastNotifySpy.mockClear() mockToastAdd.mockReset()
}) })
describe('Rendering', () => { describe('Rendering', () => {
@ -444,9 +455,9 @@ describe('Form', () => {
// Assert - validation error should be shown // Assert - validation error should be shown
await waitFor(() => { await waitFor(() => {
expect(toastNotifySpy).toHaveBeenCalledWith({ expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error', type: 'error',
message: '"field1" is required', title: '"field1" is required',
}) })
}) })
}) })
@ -566,9 +577,9 @@ describe('Form', () => {
fireEvent.submit(form) fireEvent.submit(form)
await waitFor(() => { await waitFor(() => {
expect(toastNotifySpy).toHaveBeenCalledWith({ expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error', 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 // Assert - wait a bit and verify onSubmit was not called
await waitFor(() => { await waitFor(() => {
expect(toastNotifySpy).toHaveBeenCalled() expect(mockToastAdd).toHaveBeenCalled()
}) })
expect(onSubmit).not.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 { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { z } from 'zod' import { z } from 'zod'
import Toast from '@/app/components/base/toast'
import Form from '../form' 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) // Mock the Header component (sibling component, not a base component)
vi.mock('../header', () => ({ vi.mock('../header', () => ({
default: ({ onReset, resetDisabled, onPreview, previewDisabled }: { default: ({ onReset, resetDisabled, onPreview, previewDisabled }: {
@ -44,7 +57,7 @@ const defaultProps = {
describe('Form (process-documents)', () => { describe('Form (process-documents)', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() })) mockToastAdd.mockReset()
}) })
// Verify basic rendering of form structure // Verify basic rendering of form structure
@ -106,8 +119,11 @@ describe('Form (process-documents)', () => {
fireEvent.submit(form) fireEvent.submit(form)
await waitFor(() => { await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith( expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }), expect.objectContaining({
type: 'error',
title: '"name" Name is required',
}),
) )
}) })
}) })
@ -121,7 +137,7 @@ describe('Form (process-documents)', () => {
await waitFor(() => { await waitFor(() => {
expect(defaultProps.onSubmit).toHaveBeenCalled() expect(defaultProps.onSubmit).toHaveBeenCalled()
}) })
expect(Toast.notify).not.toHaveBeenCalled() expect(mockToastAdd).not.toHaveBeenCalled()
}) })
}) })

View File

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

View File

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

View File

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

View File

@ -1333,12 +1333,9 @@ describe('CommonCreateModal', () => {
mockVerifyCredentials.mockImplementation((params, { onSuccess }) => { mockVerifyCredentials.mockImplementation((params, { onSuccess }) => {
onSuccess() onSuccess()
}) })
const builder = createMockSubscriptionBuilder()
render(<CommonCreateModal {...defaultProps} />) render(<CommonCreateModal {...defaultProps} builder={builder} />)
await waitFor(() => {
expect(mockCreateBuilder).toHaveBeenCalled()
})
fireEvent.click(screen.getByTestId('modal-confirm')) 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 { fetchBundleInfoFromMarketPlace, fetchManifestFromMarketPlace } from '@/service/plugins'
import PluginPageWithContext from '../index' import PluginPageWithContext from '../index'
let mockEnableMarketplace = true
// Mock external dependencies // Mock external dependencies
vi.mock('@/service/plugins', () => ({ vi.mock('@/service/plugins', () => ({
fetchManifestFromMarketPlace: vi.fn(), fetchManifestFromMarketPlace: vi.fn(),
@ -31,7 +33,7 @@ vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: vi.fn((selector) => { useGlobalPublicStore: vi.fn((selector) => {
const state = { const state = {
systemFeatures: { systemFeatures: {
enable_marketplace: true, enable_marketplace: mockEnableMarketplace,
}, },
} }
return selector(state) return selector(state)
@ -138,6 +140,7 @@ const createDefaultProps = (): PluginPageProps => ({
describe('PluginPage Component', () => { describe('PluginPage Component', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mockEnableMarketplace = true
// Reset to default mock values // Reset to default mock values
vi.mocked(usePluginInstallation).mockReturnValue([ vi.mocked(usePluginInstallation).mockReturnValue([
{ packageId: null, bundleInfo: null }, { packageId: null, bundleInfo: null },
@ -630,18 +633,7 @@ describe('PluginPage Component', () => {
}) })
it('should handle marketplace disabled', () => { it('should handle marketplace disabled', () => {
// Mock marketplace disabled mockEnableMarketplace = false
vi.mock('@/context/global-public-context', async () => ({
useGlobalPublicStore: vi.fn((selector) => {
const state = {
systemFeatures: {
enable_marketplace: false,
},
}
return selector(state)
}),
}))
vi.mocked(useQueryState).mockReturnValue(['discover', vi.fn()]) vi.mocked(useQueryState).mockReturnValue(['discover', vi.fn()])
render(<PluginPageWithContext {...createDefaultProps()} />) render(<PluginPageWithContext {...createDefaultProps()} />)

View File

@ -1,5 +1,6 @@
import type { EnvironmentVariable } from '@/app/components/workflow/types' import type { EnvironmentVariable } from '@/app/components/workflow/types'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { useState } from 'react'
import { createMockProviderContextValue } from '@/__mocks__/provider-context' import { createMockProviderContextValue } from '@/__mocks__/provider-context'
import Conversion from '../conversion' 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 // Silence expected console.error from Dialog/Modal rendering
beforeEach(() => { beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {}) vi.spyOn(console, 'error').mockImplementation(() => {})
}) })
afterEach(() => {
vi.restoreAllMocks()
})
// Helper to find the name input in PublishAsKnowledgePipelineModal // Helper to find the name input in PublishAsKnowledgePipelineModal
function getNameInput() { function getNameInput() {
return screen.getByPlaceholderText('pipeline.common.publishAsPipeline.namePlaceholder') return screen.getByPlaceholderText('pipeline.common.publishAsPipeline.namePlaceholder')
@ -708,10 +765,7 @@ describe('PublishAsKnowledgePipelineModal', () => {
const appIcon = getAppIcon() const appIcon = getAppIcon()
fireEvent.click(appIcon) fireEvent.click(appIcon)
// Click the first emoji in the grid (search full document since Dialog uses portal) fireEvent.click(screen.getByTestId('picker-emoji-option'))
const gridEmojis = document.querySelectorAll('.grid em-emoji')
expect(gridEmojis.length).toBeGreaterThan(0)
fireEvent.click(gridEmojis[0].parentElement!.parentElement!)
// Click OK to confirm selection // Click OK to confirm selection
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ })) fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
@ -1031,11 +1085,8 @@ describe('Integration Tests', () => {
// Open picker and select an emoji // Open picker and select an emoji
const appIcon = getAppIcon() const appIcon = getAppIcon()
fireEvent.click(appIcon) fireEvent.click(appIcon)
const gridEmojis = document.querySelectorAll('.grid em-emoji') fireEvent.click(screen.getByTestId('picker-emoji-option'))
if (gridEmojis.length > 0) { fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
fireEvent.click(gridEmojis[0].parentElement!.parentElement!)
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
}
fireEvent.click(screen.getByRole('button', { name: /workflow\.common\.publish/i })) fireEvent.click(screen.getByRole('button', { name: /workflow\.common\.publish/i }))

View File

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

View File

@ -231,6 +231,25 @@ describe('useNodesSyncDraft', () => {
expect(mockSyncWorkflowDraft).toHaveBeenCalled() 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 () => { it('should call onSuccess callback when sync succeeds', async () => {
mockGetNodesReadOnly.mockReturnValue(false) mockGetNodesReadOnly.mockReturnValue(false)
mockGetNodes.mockReturnValue([ mockGetNodes.mockReturnValue([
@ -421,6 +440,21 @@ describe('useNodesSyncDraft', () => {
expect(sentParams.rag_pipeline_variables).toEqual([{ variable: 'input', type: 'text-input' }]) 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', () => { it('should remove underscore-prefixed keys from edges', () => {
mockStoreGetState.mockReturnValue({ mockStoreGetState.mockReturnValue({
getNodes: mockGetNodes, getNodes: mockGetNodes,

View File

@ -35,6 +35,7 @@ describe('usePipelineRefreshDraft', () => {
const mockSetIsSyncingWorkflowDraft = vi.fn() const mockSetIsSyncingWorkflowDraft = vi.fn()
const mockSetEnvironmentVariables = vi.fn() const mockSetEnvironmentVariables = vi.fn()
const mockSetEnvSecrets = vi.fn() const mockSetEnvSecrets = vi.fn()
const mockSetRagPipelineVariables = vi.fn()
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
@ -45,6 +46,7 @@ describe('usePipelineRefreshDraft', () => {
setIsSyncingWorkflowDraft: mockSetIsSyncingWorkflowDraft, setIsSyncingWorkflowDraft: mockSetIsSyncingWorkflowDraft,
setEnvironmentVariables: mockSetEnvironmentVariables, setEnvironmentVariables: mockSetEnvironmentVariables,
setEnvSecrets: mockSetEnvSecrets, setEnvSecrets: mockSetEnvSecrets,
setRagPipelineVariables: mockSetRagPipelineVariables,
}) })
mockFetchWorkflowDraft.mockResolvedValue({ mockFetchWorkflowDraft.mockResolvedValue({
@ -55,6 +57,7 @@ describe('usePipelineRefreshDraft', () => {
}, },
hash: 'new-hash', hash: 'new-hash',
environment_variables: [], 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 () => { it('should set syncing state to false after completion', async () => {
const { result } = renderHook(() => usePipelineRefreshDraft()) const { result } = renderHook(() => usePipelineRefreshDraft())

View File

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

View File

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

View File

@ -1,21 +1,24 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { importSchemaFromURL } from '@/service/tools' import { importSchemaFromURL } from '@/service/tools'
import Toast from '../../../base/toast'
import examples from '../examples' import examples from '../examples'
import GetSchema from '../get-schema' import GetSchema from '../get-schema'
vi.mock('@/service/tools', () => ({ vi.mock('@/service/tools', () => ({
importSchemaFromURL: vi.fn(), importSchemaFromURL: vi.fn(),
})) }))
const mockToastAdd = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
add: mockToastAdd,
},
}))
const importSchemaFromURLMock = vi.mocked(importSchemaFromURL) const importSchemaFromURLMock = vi.mocked(importSchemaFromURL)
describe('GetSchema', () => { describe('GetSchema', () => {
const notifySpy = vi.spyOn(Toast, 'notify')
const mockOnChange = vi.fn() const mockOnChange = vi.fn()
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
notifySpy.mockClear()
importSchemaFromURLMock.mockReset() importSchemaFromURLMock.mockReset()
render(<GetSchema onChange={mockOnChange} />) render(<GetSchema onChange={mockOnChange} />)
}) })
@ -27,9 +30,9 @@ describe('GetSchema', () => {
fireEvent.change(input, { target: { value: 'ftp://invalid' } }) fireEvent.change(input, { target: { value: 'ftp://invalid' } })
fireEvent.click(screen.getByText('common.operation.ok')) fireEvent.click(screen.getByText('common.operation.ok'))
expect(notifySpy).toHaveBeenCalledWith({ expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error', 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 { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input' import Input from '@/app/components/base/input'
import { toast } from '@/app/components/base/ui/toast'
import { importSchemaFromURL } from '@/service/tools' import { importSchemaFromURL } from '@/service/tools'
import Toast from '../../base/toast'
import examples from './examples' import examples from './examples'
type Props = { type Props = {
@ -27,9 +27,9 @@ const GetSchema: FC<Props> = ({
const [isParsing, setIsParsing] = useState(false) const [isParsing, setIsParsing] = useState(false)
const handleImportFromUrl = async () => { const handleImportFromUrl = async () => {
if (!importUrl.startsWith('http://') && !importUrl.startsWith('https://')) { if (!importUrl.startsWith('http://') && !importUrl.startsWith('https://')) {
Toast.notify({ toast.add({
type: 'error', type: 'error',
message: t('createTool.urlError', { ns: 'tools' }), title: t('createTool.urlError', { ns: 'tools' }),
}) })
return 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', () => { describe('LabelFilter', () => {
const mockOnChange = vi.fn() const mockOnChange = vi.fn()
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
vi.useFakeTimers()
debouncedFn = null
})
afterEach(() => {
vi.useRealTimers()
}) })
// Rendering Tests // Rendering Tests
@ -81,36 +60,23 @@ describe('LabelFilter', () => {
const trigger = screen.getByText('common.tag.placeholder') const trigger = screen.getByText('common.tag.placeholder')
await act(async () => { await act(async () => fireEvent.click(trigger))
fireEvent.click(trigger)
vi.advanceTimersByTime(10)
})
mockTags.forEach((tag) => { mockTags.forEach((tag) => {
expect(screen.getByText(tag.label)).toBeInTheDocument() 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} />) 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!))
await act(async () => {
fireEvent.click(trigger)
vi.advanceTimersByTime(10)
})
expect(screen.getByText('Agent')).toBeInTheDocument() expect(screen.getByText('Agent')).toBeInTheDocument()
expect(screen.getByRole('textbox')).toBeInTheDocument()
// Close
await act(async () => {
fireEvent.click(trigger)
vi.advanceTimersByTime(10)
})
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
}) })
}) })
@ -119,17 +85,11 @@ describe('LabelFilter', () => {
it('should call onChange with selected label when clicking a label', async () => { it('should call onChange with selected label when clicking a label', async () => {
render(<LabelFilter value={[]} onChange={mockOnChange} />) render(<LabelFilter value={[]} onChange={mockOnChange} />)
await act(async () => { await act(async () => fireEvent.click(screen.getByText('common.tag.placeholder')))
fireEvent.click(screen.getByText('common.tag.placeholder'))
vi.advanceTimersByTime(10)
})
expect(screen.getByText('Agent')).toBeInTheDocument() expect(screen.getByText('Agent')).toBeInTheDocument()
await act(async () => { await act(async () => fireEvent.click(screen.getByText('Agent')))
fireEvent.click(screen.getByText('Agent'))
vi.advanceTimersByTime(10)
})
expect(mockOnChange).toHaveBeenCalledWith(['agent']) expect(mockOnChange).toHaveBeenCalledWith(['agent'])
}) })
@ -137,10 +97,7 @@ describe('LabelFilter', () => {
it('should remove label from selection when clicking already selected label', async () => { it('should remove label from selection when clicking already selected label', async () => {
render(<LabelFilter value={['agent']} onChange={mockOnChange} />) render(<LabelFilter value={['agent']} onChange={mockOnChange} />)
await act(async () => { await act(async () => fireEvent.click(screen.getByText('Agent')))
fireEvent.click(screen.getByText('Agent'))
vi.advanceTimersByTime(10)
})
// Find the label item in the dropdown list // Find the label item in the dropdown list
const labelItems = screen.getAllByText('Agent') const labelItems = screen.getAllByText('Agent')
@ -149,7 +106,6 @@ describe('LabelFilter', () => {
await act(async () => { await act(async () => {
if (dropdownItem) if (dropdownItem)
fireEvent.click(dropdownItem) fireEvent.click(dropdownItem)
vi.advanceTimersByTime(10)
}) })
expect(mockOnChange).toHaveBeenCalledWith([]) expect(mockOnChange).toHaveBeenCalledWith([])
@ -158,17 +114,11 @@ describe('LabelFilter', () => {
it('should add label to existing selection', async () => { it('should add label to existing selection', async () => {
render(<LabelFilter value={['agent']} onChange={mockOnChange} />) render(<LabelFilter value={['agent']} onChange={mockOnChange} />)
await act(async () => { await act(async () => fireEvent.click(screen.getByText('Agent')))
fireEvent.click(screen.getByText('Agent'))
vi.advanceTimersByTime(10)
})
expect(screen.getByText('RAG')).toBeInTheDocument() expect(screen.getByText('RAG')).toBeInTheDocument()
await act(async () => { await act(async () => fireEvent.click(screen.getByText('RAG')))
fireEvent.click(screen.getByText('RAG'))
vi.advanceTimersByTime(10)
})
expect(mockOnChange).toHaveBeenCalledWith(['agent', 'rag']) expect(mockOnChange).toHaveBeenCalledWith(['agent', 'rag'])
}) })
@ -179,8 +129,7 @@ describe('LabelFilter', () => {
it('should clear all selections when clear button is clicked', async () => { it('should clear all selections when clear button is clicked', async () => {
render(<LabelFilter value={['agent', 'rag']} onChange={mockOnChange} />) render(<LabelFilter value={['agent', 'rag']} onChange={mockOnChange} />)
// Find and click the clear button (XCircle icon's parent) const clearButton = screen.getByRole('button', { name: 'common.operation.clear' })
const clearButton = document.querySelector('.group\\/clear')
expect(clearButton).toBeInTheDocument() expect(clearButton).toBeInTheDocument()
fireEvent.click(clearButton!) fireEvent.click(clearButton!)
@ -203,21 +152,16 @@ describe('LabelFilter', () => {
await act(async () => { await act(async () => {
fireEvent.click(screen.getByText('common.tag.placeholder')) fireEvent.click(screen.getByText('common.tag.placeholder'))
vi.advanceTimersByTime(10)
}) })
expect(screen.getByRole('textbox')).toBeInTheDocument() expect(screen.getByRole('textbox')).toBeInTheDocument()
await act(async () => { await act(async () => {
const searchInput = screen.getByRole('textbox') const searchInput = screen.getByRole('textbox')
// Filter by 'rag' which only matches 'rag' name
fireEvent.change(searchInput, { target: { value: 'rag' } }) fireEvent.change(searchInput, { target: { value: 'rag' } })
vi.advanceTimersByTime(10)
}) })
// Only RAG should be visible (rag contains 'rag')
expect(screen.getByTitle('RAG')).toBeInTheDocument() expect(screen.getByTitle('RAG')).toBeInTheDocument()
// Agent should not be in the dropdown list (agent doesn't contain 'rag')
expect(screen.queryByTitle('Agent')).not.toBeInTheDocument() expect(screen.queryByTitle('Agent')).not.toBeInTheDocument()
}) })
@ -226,7 +170,6 @@ describe('LabelFilter', () => {
await act(async () => { await act(async () => {
fireEvent.click(screen.getByText('common.tag.placeholder')) fireEvent.click(screen.getByText('common.tag.placeholder'))
vi.advanceTimersByTime(10)
}) })
expect(screen.getByRole('textbox')).toBeInTheDocument() expect(screen.getByRole('textbox')).toBeInTheDocument()
@ -234,7 +177,6 @@ describe('LabelFilter', () => {
await act(async () => { await act(async () => {
const searchInput = screen.getByRole('textbox') const searchInput = screen.getByRole('textbox')
fireEvent.change(searchInput, { target: { value: 'nonexistent' } }) fireEvent.change(searchInput, { target: { value: 'nonexistent' } })
vi.advanceTimersByTime(10)
}) })
expect(screen.getByText('common.tag.noTag')).toBeInTheDocument() expect(screen.getByText('common.tag.noTag')).toBeInTheDocument()
@ -245,26 +187,21 @@ describe('LabelFilter', () => {
await act(async () => { await act(async () => {
fireEvent.click(screen.getByText('common.tag.placeholder')) fireEvent.click(screen.getByText('common.tag.placeholder'))
vi.advanceTimersByTime(10)
}) })
expect(screen.getByRole('textbox')).toBeInTheDocument() expect(screen.getByRole('textbox')).toBeInTheDocument()
await act(async () => { await act(async () => {
const searchInput = screen.getByRole('textbox') const searchInput = screen.getByRole('textbox')
// First filter to show only RAG
fireEvent.change(searchInput, { target: { value: 'rag' } }) fireEvent.change(searchInput, { target: { value: 'rag' } })
vi.advanceTimersByTime(10)
}) })
expect(screen.getByTitle('RAG')).toBeInTheDocument() expect(screen.getByTitle('RAG')).toBeInTheDocument()
expect(screen.queryByTitle('Agent')).not.toBeInTheDocument() expect(screen.queryByTitle('Agent')).not.toBeInTheDocument()
await act(async () => { await act(async () => {
// Clear the input
const searchInput = screen.getByRole('textbox') const searchInput = screen.getByRole('textbox')
fireEvent.change(searchInput, { target: { value: '' } }) fireEvent.change(searchInput, { target: { value: '' } })
vi.advanceTimersByTime(10)
}) })
// All labels should be visible again // All labels should be visible again
@ -310,17 +247,11 @@ describe('LabelFilter', () => {
it('should call onChange with updated array', async () => { it('should call onChange with updated array', async () => {
render(<LabelFilter value={[]} onChange={mockOnChange} />) render(<LabelFilter value={[]} onChange={mockOnChange} />)
await act(async () => { await act(async () => fireEvent.click(screen.getByText('common.tag.placeholder')))
fireEvent.click(screen.getByText('common.tag.placeholder'))
vi.advanceTimersByTime(10)
})
expect(screen.getByText('Agent')).toBeInTheDocument() expect(screen.getByText('Agent')).toBeInTheDocument()
await act(async () => { await act(async () => fireEvent.click(screen.getByText('Agent')))
fireEvent.click(screen.getByText('Agent'))
vi.advanceTimersByTime(10)
})
expect(mockOnChange).toHaveBeenCalledTimes(1) expect(mockOnChange).toHaveBeenCalledTimes(1)
expect(mockOnChange).toHaveBeenCalledWith(['agent']) expect(mockOnChange).toHaveBeenCalledWith(['agent'])

View File

@ -1,7 +1,6 @@
import type { FC } from 'react' import type { FC } from 'react'
import type { Label } from '@/app/components/tools/labels/constant' import type { Label } from '@/app/components/tools/labels/constant'
import { RiArrowDownSLine } from '@remixicon/react' import { RiArrowDownSLine } from '@remixicon/react'
import { useDebounceFn } from 'ahooks'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Tag01, Tag03 } from '@/app/components/base/icons/src/vender/line/financeAndECommerce' 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 { XCircle } from '@/app/components/base/icons/src/vender/solid/general'
import Input from '@/app/components/base/input' import Input from '@/app/components/base/input'
import { import {
PortalToFollowElem, Popover,
PortalToFollowElemContent, PopoverContent,
PortalToFollowElemTrigger, PopoverTrigger,
} from '@/app/components/base/portal-to-follow-elem' } from '@/app/components/base/ui/popover'
import { useTags } from '@/app/components/plugins/hooks' import { useTags } from '@/app/components/plugins/hooks'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
@ -30,18 +29,10 @@ const LabelFilter: FC<LabelFilterProps> = ({
const { tags: labelList } = useTags() const { tags: labelList } = useTags()
const [keywords, setKeywords] = useState('') 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(() => { const filteredLabelList = useMemo(() => {
return labelList.filter(label => label.name.includes(searchKeywords)) return labelList.filter(label => label.name.includes(keywords))
}, [labelList, searchKeywords]) }, [labelList, keywords])
const currentLabel = useMemo(() => { const currentLabel = useMemo(() => {
return labelList.find(label => label.name === value[0]) return labelList.find(label => label.name === value[0])
@ -55,72 +46,70 @@ const LabelFilter: FC<LabelFilterProps> = ({
} }
return ( return (
<PortalToFollowElem <Popover
open={open} open={open}
onOpenChange={setOpen} onOpenChange={setOpen}
placement="bottom-start"
offset={4}
> >
<div className="relative"> <div className="relative">
<PortalToFollowElemTrigger <PopoverTrigger
onClick={() => setOpen(v => !v)} className={cn(
className="block" '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={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',
)} )}
> >
<div className="p-[1px]"> <div className="p-[1px]">
<Tag01 className="h-3.5 w-3.5 text-text-tertiary" /> <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> </div>
</PortalToFollowElemTrigger> <div className="min-w-0 truncate text-[13px] leading-[18px] text-text-tertiary">
<PortalToFollowElemContent className="z-[1002]"> {!value.length && t('tag.placeholder', { ns: 'common' })}
<div className="relative w-[240px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]"> {!!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"> <div className="p-2">
<Input <Input
showLeftIcon showLeftIcon
showClearIcon showClearIcon
value={keywords} value={keywords}
onChange={e => handleKeywordsChange(e.target.value)} onChange={e => setKeywords(e.target.value)}
onClear={() => handleKeywordsChange('')} onClear={() => setKeywords('')}
/> />
</div> </div>
<div className="p-1"> <div className="p-1">
{filteredLabelList.map(label => ( {filteredLabelList.map(label => (
<div <button
key={label.name} 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)} onClick={() => selectLabel(label)}
> >
<div title={label.label} className="grow truncate text-sm leading-5 text-text-secondary">{label.label}</div> <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" />} {value.includes(label.name) && <Check className="h-4 w-4 shrink-0 text-text-accent" />}
</div> </button>
))} ))}
{!filteredLabelList.length && ( {!filteredLabelList.length && (
<div className="flex flex-col items-center gap-1 p-3"> <div className="flex flex-col items-center gap-1 p-3">
@ -130,9 +119,9 @@ const LabelFilter: FC<LabelFilterProps> = ({
)} )}
</div> </div>
</div> </div>
</PortalToFollowElemContent> </PopoverContent>
</div> </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 { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from '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' import MCPModal from '../modal'
// Mock the service API // 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', () => { describe('MCPModal', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const createWrapper = () => { const createWrapper = () => {
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@ -299,6 +310,10 @@ describe('MCPModal', () => {
// Wait a bit and verify onConfirm was not called // Wait a bit and verify onConfirm was not called
await new Promise(resolve => setTimeout(resolve, 100)) await new Promise(resolve => setTimeout(resolve, 100))
expect(onConfirm).not.toHaveBeenCalled() expect(onConfirm).not.toHaveBeenCalled()
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
title: 'tools.mcp.modal.invalidServerUrl',
})
}) })
it('should not call onConfirm with invalid server identifier', async () => { 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 // Wait a bit and verify onConfirm was not called
await new Promise(resolve => setTimeout(resolve, 100)) await new Promise(resolve => setTimeout(resolve, 100))
expect(onConfirm).not.toHaveBeenCalled() 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 Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal' import Modal from '@/app/components/base/modal'
import TabSlider from '@/app/components/base/tab-slider' 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 { MCPAuthMethod } from '@/app/components/tools/types'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { shouldUseMcpIconForAppIcon } from '@/utils/mcp' import { shouldUseMcpIconForAppIcon } from '@/utils/mcp'
@ -82,11 +82,11 @@ const MCPModalContent: FC<MCPModalContentProps> = ({
const submit = async () => { const submit = async () => {
if (!isValidUrl(state.url)) { if (!isValidUrl(state.url)) {
Toast.notify({ type: 'error', message: 'invalid server url' }) toast.add({ type: 'error', title: t('mcp.modal.invalidServerUrl', { ns: 'tools' }) })
return return
} }
if (!isValidServerID(state.serverIdentifier.trim())) { 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 return
} }
const formattedHeaders = state.headers.reduce((acc, item) => { 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() const mockToastNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({ vi.mock('@/app/components/base/ui/toast', () => ({
default: { toast: {
notify: (options: { type: string, message: string }) => mockToastNotify(options), add: (options: { type: string, title: string }) => mockToastNotify(options),
}, },
})) }))
@ -200,7 +200,7 @@ describe('CustomCreateCard', () => {
await waitFor(() => { await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith({ expect(mockToastNotify).toHaveBeenCalledWith({
type: 'success', type: 'success',
message: expect.any(String), title: expect.any(String),
}) })
}) })
}) })

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,14 +1,55 @@
import { fireEvent, render, screen } from '@testing-library/react' import type { Shape } from '../../../store'
import { WorkflowVersion } from '../../../types' 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 mockHandleRestoreFromPublishedWorkflow = vi.fn()
const mockHandleLoadBackupDraft = vi.fn() const mockHandleLoadBackupDraft = vi.fn()
const mockHandleRefreshWorkflowDraft = vi.fn()
const mockRestoreWorkflow = vi.fn()
const mockSetCurrentVersion = vi.fn() const mockSetCurrentVersion = vi.fn()
const mockSetShowWorkflowVersionHistoryPanel = vi.fn()
const mockWorkflowStoreSetState = vi.fn()
type MockWorkflowStoreState = { const createVersionHistory = (overrides: Partial<VersionHistory> = {}): VersionHistory => ({
setShowWorkflowVersionHistoryPanel: ReturnType<typeof vi.fn> id: 'version-id',
currentVersion: null version: WorkflowVersion.Draft,
setCurrentVersion: typeof mockSetCurrentVersion 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', () => ({ vi.mock('@/context/app-context', () => ({
@ -19,52 +60,23 @@ vi.mock('@/service/use-workflow', () => ({
useDeleteWorkflow: () => ({ mutateAsync: vi.fn() }), useDeleteWorkflow: () => ({ mutateAsync: vi.fn() }),
useInvalidAllLastRun: () => vi.fn(), useInvalidAllLastRun: () => vi.fn(),
useResetWorkflowVersionHistory: () => vi.fn(), useResetWorkflowVersionHistory: () => vi.fn(),
useRestoreWorkflow: () => ({ mutateAsync: mockRestoreWorkflow }),
useUpdateWorkflow: () => ({ mutateAsync: vi.fn() }), useUpdateWorkflow: () => ({ mutateAsync: vi.fn() }),
useWorkflowVersionHistory: () => ({ useWorkflowVersionHistory: () => ({
data: { data: {
pages: [ pages: [
{ {
items: [ items: [
{ createVersionHistory({
id: 'draft-version-id', id: 'draft-version-id',
version: WorkflowVersion.Draft, version: WorkflowVersion.Draft,
graph: { nodes: [], edges: [], viewport: null }, }),
features: { createVersionHistory({
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: '',
},
{
id: 'published-version-id', id: 'published-version-id',
version: '2024-01-01T00:00:00Z', 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_name: 'v1.0',
marked_comment: 'First release', marked_comment: 'First release',
}, }),
], ],
}, },
], ],
@ -77,7 +89,7 @@ vi.mock('@/service/use-workflow', () => ({
vi.mock('../../../hooks', () => ({ vi.mock('../../../hooks', () => ({
useDSL: () => ({ handleExportDSL: vi.fn() }), useDSL: () => ({ handleExportDSL: vi.fn() }),
useNodesSyncDraft: () => ({ handleSyncWorkflowDraft: vi.fn() }), useWorkflowRefreshDraft: () => ({ handleRefreshWorkflowDraft: mockHandleRefreshWorkflowDraft }),
useWorkflowRun: () => ({ useWorkflowRun: () => ({
handleRestoreFromPublishedWorkflow: mockHandleRestoreFromPublishedWorkflow, handleRestoreFromPublishedWorkflow: mockHandleRestoreFromPublishedWorkflow,
handleLoadBackupDraft: mockHandleLoadBackupDraft, handleLoadBackupDraft: mockHandleLoadBackupDraft,
@ -92,10 +104,10 @@ vi.mock('../../../hooks-store', () => ({
})) }))
vi.mock('../../../store', () => ({ vi.mock('../../../store', () => ({
useStore: <T,>(selector: (state: MockWorkflowStoreState) => T) => { useStore: <T,>(selector: (state: MockVersionStoreState) => T) => {
const state: MockWorkflowStoreState = { const state: MockVersionStoreState = {
setShowWorkflowVersionHistoryPanel: vi.fn(), setShowWorkflowVersionHistoryPanel: mockSetShowWorkflowVersionHistoryPanel,
currentVersion: null, currentVersion: mockCurrentVersion,
setCurrentVersion: mockSetCurrentVersion, setCurrentVersion: mockSetCurrentVersion,
} }
return selector(state) return selector(state)
@ -103,10 +115,10 @@ vi.mock('../../../store', () => ({
useWorkflowStore: () => ({ useWorkflowStore: () => ({
getState: () => ({ getState: () => ({
deleteAllInspectVars: vi.fn(), deleteAllInspectVars: vi.fn(),
setShowWorkflowVersionHistoryPanel: vi.fn(), setShowWorkflowVersionHistoryPanel: mockSetShowWorkflowVersionHistoryPanel,
setCurrentVersion: mockSetCurrentVersion, setCurrentVersion: mockSetCurrentVersion,
}), }),
setState: vi.fn(), setState: mockWorkflowStoreSetState,
}), }),
})) }))
@ -115,16 +127,54 @@ vi.mock('../delete-confirm-modal', () => ({
})) }))
vi.mock('../restore-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', () => ({ vi.mock('@/app/components/app/app-publisher/version-info-modal', () => ({
default: () => null, 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', () => { describe('VersionHistoryPanel', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mockCurrentVersion = null
}) })
describe('Version Click Behavior', () => { describe('Version Click Behavior', () => {
@ -134,10 +184,10 @@ describe('VersionHistoryPanel', () => {
render( render(
<VersionHistoryPanel <VersionHistoryPanel
latestVersionId="published-version-id" 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(mockHandleLoadBackupDraft).toHaveBeenCalled()
expect(mockHandleRestoreFromPublishedWorkflow).not.toHaveBeenCalled() expect(mockHandleRestoreFromPublishedWorkflow).not.toHaveBeenCalled()
}) })
@ -148,17 +198,72 @@ describe('VersionHistoryPanel', () => {
render( render(
<VersionHistoryPanel <VersionHistoryPanel
latestVersionId="published-version-id" 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() vi.clearAllMocks()
const publishedItem = screen.getByText('v1.0') fireEvent.click(screen.getByText('v1.0'))
fireEvent.click(publishedItem)
expect(mockHandleRestoreFromPublishedWorkflow).toHaveBeenCalled() expect(mockHandleRestoreFromPublishedWorkflow).toHaveBeenCalled()
expect(mockHandleLoadBackupDraft).not.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 Divider from '@/app/components/base/divider'
import { toast } from '@/app/components/base/ui/toast' import { toast } from '@/app/components/base/ui/toast'
import { useSelector as useAppContextSelector } from '@/context/app-context' import { useSelector as useAppContextSelector } from '@/context/app-context'
import { useDeleteWorkflow, useInvalidAllLastRun, useResetWorkflowVersionHistory, useUpdateWorkflow, useWorkflowVersionHistory } from '@/service/use-workflow' import { useDeleteWorkflow, useInvalidAllLastRun, useResetWorkflowVersionHistory, useRestoreWorkflow, useUpdateWorkflow, useWorkflowVersionHistory } from '@/service/use-workflow'
import { useDSL, useNodesSyncDraft, useWorkflowRun } from '../../hooks' import { useDSL, useWorkflowRefreshDraft, useWorkflowRun } from '../../hooks'
import { useHooksStore } from '../../hooks-store' import { useHooksStore } from '../../hooks-store'
import { useStore, useWorkflowStore } from '../../store' import { useStore, useWorkflowStore } from '../../store'
import { VersionHistoryContextMenuOptions, WorkflowVersion, WorkflowVersionFilterOptions } from '../../types' import { VersionHistoryContextMenuOptions, WorkflowVersion, WorkflowVersionFilterOptions } from '../../types'
@ -27,12 +27,14 @@ const INITIAL_PAGE = 1
export type VersionHistoryPanelProps = { export type VersionHistoryPanelProps = {
getVersionListUrl?: string getVersionListUrl?: string
deleteVersionUrl?: (versionId: string) => string deleteVersionUrl?: (versionId: string) => string
restoreVersionUrl: (versionId: string) => string
updateVersionUrl?: (versionId: string) => string updateVersionUrl?: (versionId: string) => string
latestVersionId?: string latestVersionId?: string
} }
export const VersionHistoryPanel = ({ export const VersionHistoryPanel = ({
getVersionListUrl, getVersionListUrl,
deleteVersionUrl, deleteVersionUrl,
restoreVersionUrl,
updateVersionUrl, updateVersionUrl,
latestVersionId, latestVersionId,
}: VersionHistoryPanelProps) => { }: VersionHistoryPanelProps) => {
@ -43,8 +45,8 @@ export const VersionHistoryPanel = ({
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false) const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const [editModalOpen, setEditModalOpen] = useState(false) const [editModalOpen, setEditModalOpen] = useState(false)
const workflowStore = useWorkflowStore() const workflowStore = useWorkflowStore()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const { handleRestoreFromPublishedWorkflow, handleLoadBackupDraft } = useWorkflowRun() const { handleRestoreFromPublishedWorkflow, handleLoadBackupDraft } = useWorkflowRun()
const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft()
const { handleExportDSL } = useDSL() const { handleExportDSL } = useDSL()
const setShowWorkflowVersionHistoryPanel = useStore(s => s.setShowWorkflowVersionHistoryPanel) const setShowWorkflowVersionHistoryPanel = useStore(s => s.setShowWorkflowVersionHistoryPanel)
const currentVersion = useStore(s => s.currentVersion) const currentVersion = useStore(s => s.currentVersion)
@ -144,32 +146,33 @@ export const VersionHistoryPanel = ({
}, []) }, [])
const resetWorkflowVersionHistory = useResetWorkflowVersionHistory() const resetWorkflowVersionHistory = useResetWorkflowVersionHistory()
const { mutateAsync: restoreWorkflow } = useRestoreWorkflow()
const handleRestore = useCallback((item: VersionHistory) => { const handleRestore = useCallback(async (item: VersionHistory) => {
setShowWorkflowVersionHistoryPanel(false) setShowWorkflowVersionHistoryPanel(false)
handleRestoreFromPublishedWorkflow(item) try {
workflowStore.setState({ isRestoring: false }) await restoreWorkflow(restoreVersionUrl(item.id))
workflowStore.setState({ backupDraft: undefined }) setCurrentVersion(item)
handleSyncWorkflowDraft(true, false, { workflowStore.setState({ isRestoring: false })
onSuccess: () => { workflowStore.setState({ backupDraft: undefined })
toast.add({ handleRefreshWorkflowDraft()
type: 'success', toast.add({
title: t('versionHistory.action.restoreSuccess', { ns: 'workflow' }), type: 'success',
}) title: t('versionHistory.action.restoreSuccess', { ns: 'workflow' }),
deleteAllInspectVars() })
invalidAllLastRun() deleteAllInspectVars()
}, invalidAllLastRun()
onError: () => { }
toast.add({ catch {
type: 'error', toast.add({
title: t('versionHistory.action.restoreFailure', { ns: 'workflow' }), type: 'error',
}) title: t('versionHistory.action.restoreFailure', { ns: 'workflow' }),
}, })
onSettled: () => { }
resetWorkflowVersionHistory() finally {
}, resetWorkflowVersionHistory()
}) }
}, [setShowWorkflowVersionHistoryPanel, handleRestoreFromPublishedWorkflow, workflowStore, handleSyncWorkflowDraft, deleteAllInspectVars, invalidAllLastRun, t, resetWorkflowVersionHistory]) }, [setShowWorkflowVersionHistoryPanel, setCurrentVersion, workflowStore, restoreWorkflow, restoreVersionUrl, handleRefreshWorkflowDraft, deleteAllInspectVars, invalidAllLastRun, t, resetWorkflowVersionHistory])
const { mutateAsync: deleteWorkflow } = useDeleteWorkflow() const { mutateAsync: deleteWorkflow } = useDeleteWorkflow()

View File

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

View File

@ -35,6 +35,7 @@
"details.structureTooltip": "يحدد هيكل القطعة كيفية تقسيم المستندات وفهرستها - تقديم أوضاع عامة، الأصل والطفل، والأسئلة والأجوبة - وهي فريدة لكل قاعدة معرفة.", "details.structureTooltip": "يحدد هيكل القطعة كيفية تقسيم المستندات وفهرستها - تقديم أوضاع عامة، الأصل والطفل، والأسئلة والأجوبة - وهي فريدة لكل قاعدة معرفة.",
"documentSettings.title": "إعدادات المستند", "documentSettings.title": "إعدادات المستند",
"editPipelineInfo": "تعديل معلومات سير العمل", "editPipelineInfo": "تعديل معلومات سير العمل",
"editPipelineInfoNameRequired": "يرجى إدخال اسم لقاعدة المعرفة.",
"exportDSL.errorTip": "فشل تصدير DSL لسير العمل", "exportDSL.errorTip": "فشل تصدير DSL لسير العمل",
"exportDSL.successTip": "تم تصدير DSL لسير العمل بنجاح", "exportDSL.successTip": "تم تصدير DSL لسير العمل بنجاح",
"inputField": "حقل الإدخال", "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.", "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", "documentSettings.title": "Dokument-Einstellungen",
"editPipelineInfo": "Bearbeiten von Pipeline-Informationen", "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.errorTip": "Fehler beim Exportieren der Pipeline-DSL",
"exportDSL.successTip": "Pipeline-DSL erfolgreich exportieren", "exportDSL.successTip": "Pipeline-DSL erfolgreich exportieren",
"inputField": "Eingabefeld", "inputField": "Eingabefeld",

View File

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

View File

@ -126,6 +126,8 @@
"mcp.modal.headerValuePlaceholder": "e.g., Bearer token123", "mcp.modal.headerValuePlaceholder": "e.g., Bearer token123",
"mcp.modal.headers": "Headers", "mcp.modal.headers": "Headers",
"mcp.modal.headersTip": "Additional HTTP headers to send with MCP server requests", "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.maskedHeadersTip": "Header values are masked for security. Changes will update the actual values.",
"mcp.modal.name": "Name & Icon", "mcp.modal.name": "Name & Icon",
"mcp.modal.namePlaceholder": "Name your MCP server", "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.", "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", "documentSettings.title": "Parametrizaciones de documentos",
"editPipelineInfo": "Editar información de canalización", "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.errorTip": "No se pudo exportar DSL de canalización",
"exportDSL.successTip": "Exportar DSL de canalización correctamente", "exportDSL.successTip": "Exportar DSL de canalización correctamente",
"inputField": "Campo de entrada", "inputField": "Campo de entrada",

View File

@ -35,6 +35,7 @@
"details.structureTooltip": "ساختار Chunk نحوه تقسیم و نمایه سازی اسناد را تعیین می کند - حالت های عمومی، والد-فرزند و پرسش و پاسخ را ارائه می دهد - و برای هر پایگاه دانش منحصر به فرد است.", "details.structureTooltip": "ساختار Chunk نحوه تقسیم و نمایه سازی اسناد را تعیین می کند - حالت های عمومی، والد-فرزند و پرسش و پاسخ را ارائه می دهد - و برای هر پایگاه دانش منحصر به فرد است.",
"documentSettings.title": "تنظیمات سند", "documentSettings.title": "تنظیمات سند",
"editPipelineInfo": "ویرایش اطلاعات خط لوله", "editPipelineInfo": "ویرایش اطلاعات خط لوله",
"editPipelineInfoNameRequired": "لطفاً یک نام برای پایگاه دانش وارد کنید.",
"exportDSL.errorTip": "صادرات DSL خط لوله انجام نشد", "exportDSL.errorTip": "صادرات DSL خط لوله انجام نشد",
"exportDSL.successTip": "DSL خط لوله را با موفقیت صادر کنید", "exportDSL.successTip": "DSL خط لوله را با موفقیت صادر کنید",
"inputField": "فیلد ورودی", "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.", "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", "documentSettings.title": "Paramètres du document",
"editPipelineInfo": "Modifier les informations sur le pipeline", "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.errorTip": "Echec de lexportation du DSL du pipeline",
"exportDSL.successTip": "Pipeline dexportation DSL réussi", "exportDSL.successTip": "Pipeline dexportation DSL réussi",
"inputField": "Champ de saisie", "inputField": "Champ de saisie",

View File

@ -35,6 +35,7 @@
"details.structureTooltip": "चंक संरचना यह निर्धारित करती है कि दस्तावेज कैसे विभाजित और अनुक्रमित होते हैं—सामान्य, माता-पिता- बच्चे, और प्रश्नोत्तर मोड प्रदान करते हुए—और यह प्रत्येक ज्ञान आधार के लिए अद्वितीय होती है।", "details.structureTooltip": "चंक संरचना यह निर्धारित करती है कि दस्तावेज कैसे विभाजित और अनुक्रमित होते हैं—सामान्य, माता-पिता- बच्चे, और प्रश्नोत्तर मोड प्रदान करते हुए—और यह प्रत्येक ज्ञान आधार के लिए अद्वितीय होती है।",
"documentSettings.title": "डॉक्यूमेंट सेटिंग्स", "documentSettings.title": "डॉक्यूमेंट सेटिंग्स",
"editPipelineInfo": "पाइपलाइन जानकारी संपादित करें", "editPipelineInfo": "पाइपलाइन जानकारी संपादित करें",
"editPipelineInfoNameRequired": "कृपया ज्ञान आधार के लिए एक नाम दर्ज करें।",
"exportDSL.errorTip": "पाइपलाइन DSL निर्यात करने में विफल", "exportDSL.errorTip": "पाइपलाइन DSL निर्यात करने में विफल",
"exportDSL.successTip": "निर्यात पाइपलाइन DSL सफलतापूर्वक", "exportDSL.successTip": "निर्यात पाइपलाइन DSL सफलतापूर्वक",
"inputField": "इनपुट फ़ील्ड", "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.", "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", "documentSettings.title": "Pengaturan Dokumen",
"editPipelineInfo": "Mengedit info alur", "editPipelineInfo": "Mengedit info alur",
"editPipelineInfoNameRequired": "Silakan masukkan nama untuk Basis Pengetahuan.",
"exportDSL.errorTip": "Gagal mengekspor DSL alur", "exportDSL.errorTip": "Gagal mengekspor DSL alur",
"exportDSL.successTip": "Ekspor DSL pipeline berhasil", "exportDSL.successTip": "Ekspor DSL pipeline berhasil",
"inputField": "Bidang Masukan", "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.", "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", "documentSettings.title": "Impostazioni documento",
"editPipelineInfo": "Modificare le informazioni sulla pipeline", "editPipelineInfo": "Modificare le informazioni sulla pipeline",
"editPipelineInfoNameRequired": "Inserisci un nome per la Knowledge Base.",
"exportDSL.errorTip": "Impossibile esportare il DSL della pipeline", "exportDSL.errorTip": "Impossibile esportare il DSL della pipeline",
"exportDSL.successTip": "Esporta DSL pipeline con successo", "exportDSL.successTip": "Esporta DSL pipeline con successo",
"inputField": "Campo di input", "inputField": "Campo di input",

View File

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

View File

@ -35,6 +35,7 @@
"details.structureTooltip": "청크 구조는 문서를 분할하고 인덱싱하는 방법(일반, 부모-자식 및 Q&A 모드를 제공)을 결정하며 각 기술 자료에 고유합니다.", "details.structureTooltip": "청크 구조는 문서를 분할하고 인덱싱하는 방법(일반, 부모-자식 및 Q&A 모드를 제공)을 결정하며 각 기술 자료에 고유합니다.",
"documentSettings.title": "문서 설정", "documentSettings.title": "문서 설정",
"editPipelineInfo": "파이프라인 정보 편집", "editPipelineInfo": "파이프라인 정보 편집",
"editPipelineInfoNameRequired": "기술 자료의 이름을 입력해 주세요.",
"exportDSL.errorTip": "파이프라인 DSL을 내보내지 못했습니다.", "exportDSL.errorTip": "파이프라인 DSL을 내보내지 못했습니다.",
"exportDSL.successTip": "파이프라인 DSL 내보내기 성공", "exportDSL.successTip": "파이프라인 DSL 내보내기 성공",
"inputField": "입력 필드", "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.", "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", "documentSettings.title": "Document Settings",
"editPipelineInfo": "Edit pipeline info", "editPipelineInfo": "Edit pipeline info",
"editPipelineInfoNameRequired": "Voer een naam in voor de Kennisbank.",
"exportDSL.errorTip": "Failed to export pipeline DSL", "exportDSL.errorTip": "Failed to export pipeline DSL",
"exportDSL.successTip": "Export pipeline DSL successfully", "exportDSL.successTip": "Export pipeline DSL successfully",
"inputField": "Input Field", "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.", "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", "documentSettings.title": "Ustawienia dokumentu",
"editPipelineInfo": "Edytowanie informacji o potoku", "editPipelineInfo": "Edytowanie informacji o potoku",
"editPipelineInfoNameRequired": "Proszę podać nazwę Bazy Wiedzy.",
"exportDSL.errorTip": "Nie można wyeksportować DSL potoku", "exportDSL.errorTip": "Nie można wyeksportować DSL potoku",
"exportDSL.successTip": "Pomyślnie wyeksportowano potok DSL", "exportDSL.successTip": "Pomyślnie wyeksportowano potok DSL",
"inputField": "Pole wejściowe", "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.", "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", "documentSettings.title": "Configurações do documento",
"editPipelineInfo": "Editar informações do pipeline", "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.errorTip": "Falha ao exportar DSL de pipeline",
"exportDSL.successTip": "Exportar DSL de pipeline com êxito", "exportDSL.successTip": "Exportar DSL de pipeline com êxito",
"inputField": "Campo de entrada", "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.", "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", "documentSettings.title": "Setări document",
"editPipelineInfo": "Editați informațiile despre conductă", "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.errorTip": "Nu s-a reușit exportul DSL al conductei",
"exportDSL.successTip": "Exportați cu succes DSL", "exportDSL.successTip": "Exportați cu succes DSL",
"inputField": "Câmp de intrare", "inputField": "Câmp de intrare",

View File

@ -35,6 +35,7 @@
"details.structureTooltip": "Структура блоков определяет порядок разделения и индексирования документов (в соответствии с режимами «Общие», «Родитель-потомок» и «Вопросы и ответы») и является уникальной для каждой базы знаний.", "details.structureTooltip": "Структура блоков определяет порядок разделения и индексирования документов (в соответствии с режимами «Общие», «Родитель-потомок» и «Вопросы и ответы») и является уникальной для каждой базы знаний.",
"documentSettings.title": "Настройки документа", "documentSettings.title": "Настройки документа",
"editPipelineInfo": "Редактирование сведений о воронке продаж", "editPipelineInfo": "Редактирование сведений о воронке продаж",
"editPipelineInfoNameRequired": "Пожалуйста, введите название базы знаний.",
"exportDSL.errorTip": "Не удалось экспортировать DSL конвейера", "exportDSL.errorTip": "Не удалось экспортировать DSL конвейера",
"exportDSL.successTip": "Экспорт конвейера DSL успешно", "exportDSL.successTip": "Экспорт конвейера DSL успешно",
"inputField": "Поле ввода", "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.", "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", "documentSettings.title": "Nastavitve dokumenta",
"editPipelineInfo": "Urejanje informacij o cevovodu", "editPipelineInfo": "Urejanje informacij o cevovodu",
"editPipelineInfoNameRequired": "Prosim vnesite ime za Bazo znanja.",
"exportDSL.errorTip": "Izvoz cevovoda DSL ni uspel", "exportDSL.errorTip": "Izvoz cevovoda DSL ni uspel",
"exportDSL.successTip": "Uspešno izvozite DSL", "exportDSL.successTip": "Uspešno izvozite DSL",
"inputField": "Vnosno polje", "inputField": "Vnosno polje",

View File

@ -35,6 +35,7 @@
"details.structureTooltip": "โครงสร้างก้อนกําหนดวิธีการแยกและจัดทําดัชนีเอกสาร โดยเสนอโหมดทั่วไป ผู้ปกครอง-รอง และ Q&A และไม่ซ้ํากันสําหรับแต่ละฐานความรู้", "details.structureTooltip": "โครงสร้างก้อนกําหนดวิธีการแยกและจัดทําดัชนีเอกสาร โดยเสนอโหมดทั่วไป ผู้ปกครอง-รอง และ Q&A และไม่ซ้ํากันสําหรับแต่ละฐานความรู้",
"documentSettings.title": "การตั้งค่าเอกสาร", "documentSettings.title": "การตั้งค่าเอกสาร",
"editPipelineInfo": "แก้ไขข้อมูลไปป์ไลน์", "editPipelineInfo": "แก้ไขข้อมูลไปป์ไลน์",
"editPipelineInfoNameRequired": "โปรดป้อนชื่อสำหรับฐานความรู้",
"exportDSL.errorTip": "ไม่สามารถส่งออก DSL ไปป์ไลน์ได้", "exportDSL.errorTip": "ไม่สามารถส่งออก DSL ไปป์ไลน์ได้",
"exportDSL.successTip": "ส่งออก DSL ไปป์ไลน์สําเร็จ", "exportDSL.successTip": "ส่งออก DSL ไปป์ไลน์สําเร็จ",
"inputField": "ฟิลด์อินพุต", "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.", "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ı", "documentSettings.title": "Belge Ayarları",
"editPipelineInfo": "İşlem hattı bilgilerini düzenleme", "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.errorTip": "İşlem hattı DSL'si dışarı aktarılamadı",
"exportDSL.successTip": "İşlem hattı DSL'sini başarıyla dışarı aktarın", "exportDSL.successTip": "İşlem hattı DSL'sini başarıyla dışarı aktarın",
"inputField": "Giriş Alanı", "inputField": "Giriş Alanı",

View File

@ -35,6 +35,7 @@
"details.structureTooltip": "Структура фрагментів визначає, як документи розділяються та індексуються (пропонуючи режими «Загальні», «Батьки-дочірні елементи» та «Запитання й відповіді»), і є унікальною для кожної бази знань.", "details.structureTooltip": "Структура фрагментів визначає, як документи розділяються та індексуються (пропонуючи режими «Загальні», «Батьки-дочірні елементи» та «Запитання й відповіді»), і є унікальною для кожної бази знань.",
"documentSettings.title": "Параметри документа", "documentSettings.title": "Параметри документа",
"editPipelineInfo": "Як редагувати інформацію про воронку продажів", "editPipelineInfo": "Як редагувати інформацію про воронку продажів",
"editPipelineInfoNameRequired": "Будь ласка, введіть назву Бази знань.",
"exportDSL.errorTip": "Не вдалося експортувати DSL пайплайну", "exportDSL.errorTip": "Не вдалося експортувати DSL пайплайну",
"exportDSL.successTip": "Успішний експорт DSL воронки продажів", "exportDSL.successTip": "Успішний експорт DSL воронки продажів",
"inputField": "Поле введення", "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.", "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", "documentSettings.title": "Cài đặt tài liệu",
"editPipelineInfo": "Chỉnh sửa thông tin quy trình", "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.errorTip": "Không thể xuất DSL đường ống",
"exportDSL.successTip": "Xuất DSL quy trình thành công", "exportDSL.successTip": "Xuất DSL quy trình thành công",
"inputField": "Trường đầu vào", "inputField": "Trường đầu vào",

View File

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

View File

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