mirror of
https://github.com/langgenius/dify.git
synced 2026-04-29 04:26:30 +08:00
fix: adding a restore API for version control on workflow draft (#33582)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
parent
4d538c3727
commit
c8ed584c0e
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
@ -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])
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
||||||
|
|
||||||
|
|||||||
@ -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