mirror of
https://github.com/langgenius/dify.git
synced 2026-05-12 15:58:19 +08:00
Merge branch 'main' into jzh
This commit is contained in:
commit
bfcac64a9d
@ -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")
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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,
|
||||||
*,
|
*,
|
||||||
|
|||||||
58
api/services/workflow_restore.py
Normal file
58
api/services/workflow_restore.py
Normal 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
|
||||||
@ -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,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
@ -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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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', () => {
|
||||||
|
|||||||
@ -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',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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()
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -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',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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', () => {
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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'))
|
||||||
|
|
||||||
|
|||||||
@ -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()} />)
|
||||||
|
|||||||
@ -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 }))
|
||||||
|
|
||||||
|
|||||||
@ -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: '',
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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())
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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])
|
||||||
|
|
||||||
|
|||||||
@ -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',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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'])
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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',
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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', () => ({
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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',
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
115
web/app/components/workflow/panel/__tests__/index.spec.tsx
Normal file
115
web/app/components/workflow/panel/__tests__/index.spec.tsx
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -140,7 +140,7 @@ const Panel: FC<PanelProps> = ({
|
|||||||
components?.right
|
components?.right
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
showWorkflowVersionHistoryPanel && (
|
showWorkflowVersionHistoryPanel && versionHistoryPanelProps && (
|
||||||
<VersionHistoryPanel {...versionHistoryPanelProps} />
|
<VersionHistoryPanel {...versionHistoryPanelProps} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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()
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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": "حقل الإدخال",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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": "فیلد ورودی",
|
||||||
|
|||||||
@ -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 l’exportation du DSL du pipeline",
|
"exportDSL.errorTip": "Echec de l’exportation du DSL du pipeline",
|
||||||
"exportDSL.successTip": "Pipeline d’exportation DSL réussi",
|
"exportDSL.successTip": "Pipeline d’exportation DSL réussi",
|
||||||
"inputField": "Champ de saisie",
|
"inputField": "Champ de saisie",
|
||||||
|
|||||||
@ -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": "इनपुट फ़ील्ड",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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": "入力フィールド",
|
||||||
|
|||||||
@ -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": "입력 필드",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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": "Поле ввода",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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": "ฟิลด์อินพุต",
|
||||||
|
|||||||
@ -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ı",
|
||||||
|
|||||||
@ -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": "Поле введення",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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": "输入字段",
|
||||||
|
|||||||
@ -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": "輸入欄位",
|
||||||
|
|||||||
@ -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'],
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user