Compare commits

..

5 Commits

Author SHA1 Message Date
4a2f90e7ec fix(device): display sso_error query param on /device page 2026-05-28 02:05:15 -07:00
f5ab5e7eb3 fix: fix cannot extract elements from a scalar (#36769) 2026-05-28 07:31:36 +00:00
0c40e1c2a0 feat: add cross-environment app migration workflow (#36765)
Co-authored-by: XW <wei.xu1@wiz.ai>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-28 07:30:33 +00:00
c29d76757e docs(api): fix typo in vector migration docstrings (#36741) 2026-05-28 07:15:34 +00:00
91c1d3ad81 fix: handle null plugin badges (#36767) 2026-05-28 07:00:32 +00:00
195 changed files with 5588 additions and 11953 deletions

View File

@ -4,6 +4,12 @@ CLI command modules extracted from `commands.py`.
from .account import create_tenant, reset_email, reset_password
from .data_migrate import data_migrate, legacy_model_types
from .data_migration import (
export_migration_data,
export_migration_data_template,
import_migration_data,
migration_data_wizard,
)
from .plugin import (
extract_plugins,
extract_unique_plugins,
@ -26,7 +32,12 @@ from .retention import (
restore_workflow_runs,
)
from .storage import clear_orphaned_file_records, file_usage, migrate_oss, remove_orphaned_files_on_storage
from .system import convert_to_agent_apps, fix_app_site_missing, reset_encrypt_key_pair, upgrade_db
from .system import (
convert_to_agent_apps,
fix_app_site_missing,
reset_encrypt_key_pair,
upgrade_db,
)
from .vector import (
add_qdrant_index,
migrate_annotation_vector_database,
@ -48,10 +59,13 @@ __all__ = [
"data_migrate",
"delete_archived_workflow_runs",
"export_app_messages",
"export_migration_data",
"export_migration_data_template",
"extract_plugins",
"extract_unique_plugins",
"file_usage",
"fix_app_site_missing",
"import_migration_data",
"install_plugins",
"install_rag_pipeline_plugins",
"legacy_model_types",
@ -59,6 +73,7 @@ __all__ = [
"migrate_data_for_plugin",
"migrate_knowledge_vector_database",
"migrate_oss",
"migration_data_wizard",
"old_metadata_migration",
"remove_orphaned_files_on_storage",
"reset_email",

View File

@ -0,0 +1,754 @@
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
from typing import Any, cast
from uuid import UUID
import click
import sqlalchemy as sa
import yaml
from extensions.ext_database import db
from models import Tenant
from models.model import App
from models.tools import ApiToolProvider, MCPToolProvider, WorkflowToolProvider
from services.app_dsl_service import AppDslService
from services.data_migration.dependency_discovery_service import DependencyDiscoveryService
from services.data_migration.entities import (
DependencyKind,
ImportOptions,
MigrationDataError,
ReportContext,
ResourceReportItem,
)
from services.data_migration.export_service import ExportConfigParser, MigrationExportService
from services.data_migration.import_service import ImportRequest, MigrationImportService
from services.data_migration.package_service import MigrationPackageService
from services.data_migration.report_service import MigrationReportService
ID_STRATEGY_CHOICES = ["preserve-id", "generate-new-id"]
CONFLICT_STRATEGY_CHOICES = ["fail", "skip", "update"]
SUPPORTED_WIZARD_APP_MODES = ["workflow", "advanced-chat"]
WizardToolMap = dict[str, dict[str, str | None]]
WizardToolSelection = dict[str, list[str]]
def _scripted_export_template() -> dict[str, Any]:
return {
"source_tenant": {
"mode": "single",
"id": "",
"name": "admin's Workspace",
},
"apps": {
"modes": ["workflow", "advanced-chat"],
"ids": [],
"all": True,
},
"include_referenced_tools": True,
"additional_tools": {
"api_tools": [],
"workflow_tools": [],
"mcp_tools": [],
},
"include_secrets": False,
"import_options": {
"create_app_api_token_on_import": False,
"id_strategy": "preserve-id",
"conflict_strategy": "fail",
},
}
@click.command("app-migration-template", help="Print or write a scripted export config JSON template.")
@click.option(
"--output",
"output_file",
required=False,
type=click.Path(dir_okay=False),
help="Path to write the export config JSON template. Prints to stdout when omitted.",
)
@click.option("--overwrite", is_flag=True, default=False, help="Overwrite output if it already exists.")
def export_migration_data_template(output_file: str | None, overwrite: bool) -> None:
template_json = json.dumps(_scripted_export_template(), indent=2, ensure_ascii=False) + "\n"
if output_file is None:
click.echo(template_json, nl=False)
return
path = Path(output_file)
if path.exists() and not overwrite:
raise click.ClickException(f"Output file already exists: {output_file}")
path.write_text(template_json)
click.echo(click.style(f"Output written to {output_file}", fg="green"))
@click.command("export-app-migration", help="Export workflow migration data to a versioned JSON package.")
@click.option(
"--input",
"input_file",
required=False,
type=click.Path(exists=True, dir_okay=False),
help="Path to export config JSON.",
)
@click.option(
"--output",
"output_file",
required=False,
type=click.Path(dir_okay=False),
help="Path to migration package JSON.",
)
@click.option("--overwrite", is_flag=True, default=False, help="Overwrite output if it already exists.")
def export_migration_data(input_file: str | None, output_file: str | None, overwrite: bool) -> None:
try:
_require_options(("--input", input_file), ("--output", output_file))
assert input_file is not None
assert output_file is not None
raw_config = _load_json_object(input_file, "Export config")
selection = ExportConfigParser().parse(raw_config)
result = MigrationExportService().export(selection)
MigrationPackageService().save_package(result.package, output_file, overwrite=overwrite)
click.echo(click.style(f"Output written to {output_file}", fg="green"))
_render_report(result.report_items, context=_with_output_path(result.report_context, output_file))
except MigrationDataError as exc:
raise click.ClickException(str(exc)) from exc
@click.command("import-app-migration", help="Import a versioned migration data package.")
@click.option(
"--input",
"input_file",
required=False,
type=click.Path(exists=True, dir_okay=False),
help="Path to migration package JSON.",
)
@click.option("--target-tenant", default=None, help="Target tenant/workspace name. Overrides package metadata.")
@click.option("--operator-email", default=None, help="Operator account email in the target tenant.")
@click.option(
"--id-strategy",
default=None,
type=click.Choice(ID_STRATEGY_CHOICES),
help="Override package ID strategy.",
)
@click.option(
"--conflict-strategy",
default=None,
type=click.Choice(CONFLICT_STRATEGY_CHOICES),
help="Override package conflict strategy.",
)
@click.option(
"--create-app-api-token-on-import/--no-create-app-api-token-on-import",
default=None,
help="Override package app API token creation behavior.",
)
def import_migration_data(
input_file: str | None,
target_tenant: str | None,
operator_email: str | None,
id_strategy: str | None,
conflict_strategy: str | None,
create_app_api_token_on_import: bool | None,
) -> None:
try:
_require_options(("--input", input_file))
assert input_file is not None
package = MigrationPackageService().load_package(input_file)
result = MigrationImportService().import_package(
ImportRequest(
package=package,
cli_target_tenant=target_tenant,
operator_email=operator_email,
options_override=_build_options_override(
package.metadata.import_options,
id_strategy=id_strategy,
conflict_strategy=conflict_strategy,
create_app_api_token_on_import=create_app_api_token_on_import,
),
)
)
_render_report(result.report_items, context=result.report_context)
except MigrationDataError as exc:
raise click.ClickException(str(exc)) from exc
def parse_index_selection(raw: str, values: list[str]) -> list[str]:
normalized = raw.strip().lower()
if normalized == "all":
return values
selected: list[str] = []
for part in raw.split(","):
stripped = part.strip()
if not stripped:
continue
try:
index = int(stripped)
except ValueError as exc:
raise click.ClickException(f"Selection must be 'all' or comma-separated numbers: {raw}") from exc
if index < 1 or index > len(values):
raise click.ClickException(f"Selection index out of range: {index}")
selected.append(values[index - 1])
return list(dict.fromkeys(selected))
def _print_wizard_step(title: str) -> None:
click.echo("")
click.echo(f"==== {title} ====")
def _print_wizard_substep(title: str) -> None:
click.echo("")
click.echo(f"-- {title} --")
@click.command("app-migration-wizard", help="Interactively export workflow migration data.")
def migration_data_wizard() -> None:
try:
tenant = _prompt_source_tenant()
apps = _eligible_apps_for_tenant(tenant.id)
app_ids = _prompt_app_ids(apps)
_print_wizard_step("Referenced Tools")
include_referenced_tools = click.confirm(
"Automatically export tools referenced by selected apps? [y/n, default: y]",
default=True,
show_default=False,
)
auto_tools = _discover_auto_tools([app for app in apps if app.id in set(app_ids)], include_referenced_tools)
auto_tools = _resolve_auto_tool_names(tenant.id, auto_tools)
_print_auto_tools(auto_tools)
additional_tools = _prompt_additional_tools(tenant.id, auto_tools)
include_secrets, create_tokens, id_strategy, conflict_strategy = _prompt_import_options()
_print_wizard_step("Output")
output_file, overwrite = _prompt_output_file()
selection = ExportConfigParser().parse(
{
"source_tenant": {"mode": "single", "id": tenant.id, "name": tenant.name},
"apps": {"ids": app_ids, "all": False},
"include_referenced_tools": include_referenced_tools,
"additional_tools": additional_tools,
"include_secrets": include_secrets,
"import_options": {
"create_app_api_token_on_import": create_tokens,
"id_strategy": id_strategy,
"conflict_strategy": conflict_strategy,
},
}
)
_confirm_wizard_summary(
tenant_name=tenant.name,
app_names=[app.name for app in apps if app.id in set(app_ids)],
auto_tools=auto_tools,
additional_tools=additional_tools,
manual_labels=_selected_tool_labels_for_tenant(tenant.id, additional_tools),
include_referenced_tools=include_referenced_tools,
include_secrets=include_secrets,
create_tokens=create_tokens,
id_strategy=id_strategy,
conflict_strategy=conflict_strategy,
output_file=output_file,
)
result = MigrationExportService().export(selection)
MigrationPackageService().save_package(result.package, output_file, overwrite=overwrite)
click.echo(click.style(f"Output written to {output_file}", fg="green"))
_print_wizard_step("Report")
_render_report(result.report_items, context=_with_output_path(result.report_context, output_file))
except MigrationDataError as exc:
raise click.ClickException(str(exc)) from exc
def _load_json_object(path: str, label: str) -> dict[str, Any]:
try:
with Path(path).open(encoding="utf-8") as file:
raw = json.load(file)
except json.JSONDecodeError as exc:
raise MigrationDataError(f"{label} JSON is invalid: {exc.msg}") from exc
if not isinstance(raw, dict):
raise MigrationDataError(f"{label} JSON must be an object.")
return raw
def _require_options(*options: tuple[str, object | None]) -> None:
missing_options = [name for name, value in options if value is None]
if missing_options:
raise click.UsageError(f"Missing option(s): {', '.join(missing_options)}.")
def _build_options_override(
package_options: ImportOptions,
*,
id_strategy: str | None,
conflict_strategy: str | None,
create_app_api_token_on_import: bool | None,
) -> ImportOptions | None:
if id_strategy is None and conflict_strategy is None and create_app_api_token_on_import is None:
return None
return ImportOptions.from_mapping(
{
"id_strategy": id_strategy or package_options.id_strategy,
"conflict_strategy": conflict_strategy or package_options.conflict_strategy,
"create_app_api_token_on_import": (
create_app_api_token_on_import
if create_app_api_token_on_import is not None
else package_options.create_app_api_token_on_import
),
}
)
def _prompt_source_tenant() -> Tenant:
tenants = list(db.session.scalars(sa.select(Tenant).order_by(Tenant.name.asc())).all())
if not tenants:
raise MigrationDataError("No tenants found.")
_print_wizard_step("Source Tenant")
click.echo("Source tenants:")
for index, tenant in enumerate(tenants, 1):
click.echo(f"{index}. {tenant.name} ({tenant.id})")
tenant_index = click.prompt("Select one source tenant by number", type=int, default=1, show_default=True)
if tenant_index < 1 or tenant_index > len(tenants):
raise click.ClickException(f"Selection index out of range: {tenant_index}")
return tenants[tenant_index - 1]
def _eligible_apps_for_tenant(tenant_id: str) -> list[App]:
return list(
db.session.scalars(
sa.select(App)
.where(App.tenant_id == tenant_id, App.mode.in_(SUPPORTED_WIZARD_APP_MODES))
.order_by(App.name.asc())
).all()
)
def _prompt_app_ids(apps: list[App]) -> list[str]:
if not apps:
raise MigrationDataError("No workflow or advanced-chat apps found for the selected tenant.")
_print_wizard_step("App Selection")
click.echo("Currently supported app types: workflow and chatflow.")
click.echo("Workflow/chatflow apps:")
for index, app in enumerate(apps, 1):
mode = app.mode.value if hasattr(app.mode, "value") else app.mode
click.echo(f"{index}. {app.name} [{mode}] ({app.id})")
app_ids = parse_index_selection(
click.prompt("Select apps by number, comma-separated numbers, or all", default="all"),
[app.id for app in apps],
)
selected_apps = [app for app in apps if app.id in set(app_ids)]
click.echo("Selected apps:")
for app in selected_apps:
click.echo(f"- {app.name} ({app.id})")
return app_ids
def _prompt_import_options() -> tuple[bool, bool, str, str]:
_print_wizard_step("Import Options")
_print_wizard_substep("Secrets")
click.echo("Secrets include workflow/app DSL secret values, custom API tool credentials,")
click.echo("and full MCP provider connection data such as server URL, headers, authentication, and tool list.")
click.echo("If you choose no, credentials are omitted or masked,")
click.echo("and MCP providers are exported as dependency metadata only.")
click.echo("Treat the output JSON as sensitive if you choose yes.")
include_secrets = click.confirm(
"Include secrets in output JSON? [y/n, default: n]",
default=False,
show_default=False,
)
_print_wizard_substep("App API Tokens")
click.echo("When enabled, import will create an app API token if the imported app has none,")
click.echo("or reuse an existing app API token if one already exists.")
create_tokens = click.confirm(
"Create or reuse app API tokens during import? [y/n, default: n]",
default=False,
show_default=False,
)
_print_wizard_substep("ID Strategy")
click.echo("ID strategy controls whether imported app and tool IDs preserve source IDs")
click.echo("or use target-generated IDs.")
click.echo("preserve-id: keep source IDs where the target service supports it.")
click.echo("generate-new-id: let the target environment generate new IDs and rewrite references via mapping.")
id_strategy = click.prompt(
"Import ID strategy. Enter one of: preserve-id, generate-new-id",
type=click.Choice(ID_STRATEGY_CHOICES),
default="preserve-id",
show_default=True,
)
_print_wizard_substep("Conflict Strategy")
click.echo("Conflict strategy controls what import does when a target resource already exists.")
click.echo("fail: stop at the first conflict; previously committed resources are not rolled back.")
click.echo("skip: keep the existing target resource and skip importing that resource.")
click.echo("update: update the existing target resource in place.")
conflict_strategy = click.prompt(
"Import conflict strategy. Enter one of: fail, skip, update",
type=click.Choice(CONFLICT_STRATEGY_CHOICES),
default="update",
show_default=True,
)
return include_secrets, create_tokens, id_strategy, conflict_strategy
def _discover_auto_tools(apps: list[App], include_referenced_tools: bool) -> WizardToolMap:
auto_tools: WizardToolMap = {"api_tools": {}, "workflow_tools": {}, "mcp_tools": {}}
if not include_referenced_tools:
return auto_tools
discovery_service = DependencyDiscoveryService()
for app in apps:
dsl_content = AppDslService.export_dsl(app_model=app, include_secret=False)
raw_dsl = yaml.safe_load(dsl_content) if dsl_content else {}
dsl = raw_dsl if isinstance(raw_dsl, dict) else {}
for dependency in discovery_service.discover_from_dsl(dsl):
if dependency.kind == DependencyKind.API_TOOL:
auto_tools["api_tools"][dependency.provider_name or dependency.provider_id] = dependency.provider_id
elif dependency.kind == DependencyKind.WORKFLOW_TOOL:
auto_tools["workflow_tools"][dependency.provider_name or dependency.provider_id] = (
dependency.provider_id
)
elif dependency.kind == DependencyKind.MCP_TOOL:
auto_tools["mcp_tools"][dependency.provider_name or dependency.provider_id] = dependency.provider_id
return auto_tools
def _resolve_auto_tool_names(tenant_id: str, auto_tools: WizardToolMap) -> WizardToolMap:
return {
"api_tools": _resolve_api_tool_names(tenant_id, auto_tools["api_tools"]),
"workflow_tools": _resolve_workflow_tool_names(tenant_id, auto_tools["workflow_tools"]),
"mcp_tools": _resolve_mcp_tool_names(tenant_id, auto_tools["mcp_tools"]),
}
def _resolve_api_tool_names(tenant_id: str, tools: dict[str, str | None]) -> dict[str, str | None]:
resolved: dict[str, str | None] = {}
for name, identifier in tools.items():
predicates = [ApiToolProvider.name == name]
if _is_uuid_string(identifier):
predicates.append(ApiToolProvider.id == identifier)
provider = db.session.scalar(
sa.select(ApiToolProvider).where(
ApiToolProvider.tenant_id == tenant_id,
sa.or_(*predicates),
)
)
resolved[provider.name if provider else name] = provider.id if provider else identifier
return resolved
def _resolve_workflow_tool_names(tenant_id: str, tools: dict[str, str | None]) -> dict[str, str | None]:
resolved: dict[str, str | None] = {}
for name, identifier in tools.items():
predicates = [WorkflowToolProvider.name == name]
if _is_uuid_string(identifier):
predicates.append(WorkflowToolProvider.id == identifier)
provider = db.session.scalar(
sa.select(WorkflowToolProvider).where(
WorkflowToolProvider.tenant_id == tenant_id,
sa.or_(*predicates),
)
)
resolved[provider.name if provider else name] = provider.id if provider else identifier
return resolved
def _resolve_mcp_tool_names(tenant_id: str, tools: dict[str, str | None]) -> dict[str, str | None]:
resolved: dict[str, str | None] = {}
for name, identifier in tools.items():
predicates = [MCPToolProvider.name == name]
if identifier:
predicates.append(MCPToolProvider.server_identifier == identifier)
if _is_uuid_string(identifier):
predicates.append(MCPToolProvider.id == identifier)
provider = db.session.scalar(
sa.select(MCPToolProvider).where(
MCPToolProvider.tenant_id == tenant_id,
sa.or_(*predicates),
)
)
resolved[provider.name if provider else name] = provider.id if provider else identifier
return resolved
def _is_uuid_string(value: str | None) -> bool:
if not value:
return False
try:
UUID(value)
except ValueError:
return False
return True
def _print_auto_tools(auto_tools: WizardToolMap) -> None:
_print_wizard_step("Automatically Discovered Tools")
click.echo("Automatically discovered tools:")
_print_auto_tool_category("Custom API tools", auto_tools["api_tools"])
_print_auto_tool_category("Workflow tools", auto_tools["workflow_tools"])
_print_auto_tool_category("MCP tools", auto_tools["mcp_tools"])
def _print_auto_tool_category(label: str, values: dict[str, str | None]) -> None:
click.echo(label)
if not values:
click.echo("- none")
return
for name, identifier in sorted(values.items()):
click.echo(f"- {_format_tool_name_id(name, identifier)}")
def _prompt_additional_tools(tenant_id: str, auto_tools: WizardToolMap) -> WizardToolSelection:
selections: WizardToolSelection = {"api_tools": [], "workflow_tools": [], "mcp_tools": []}
_print_wizard_step("Additional Tools")
if not click.confirm(
"Export additional tools manually? [y/n, default: n]",
default=False,
show_default=False,
):
_print_final_tool_selection(auto_tools, selections, {})
return selections
manual_labels: dict[str, str] = {}
api_tool_options = [
(tool.name, tool.name, tool.id)
for tool in db.session.scalars(
sa.select(ApiToolProvider).where(ApiToolProvider.tenant_id == tenant_id).order_by(ApiToolProvider.name)
).all()
]
selections["api_tools"] = _prompt_tool_category(
"Custom API tools",
api_tool_options,
auto_tools=auto_tools["api_tools"],
)
manual_labels.update(_selected_tool_labels(api_tool_options, selections["api_tools"]))
workflow_tool_options = [
(tool.id, tool.name, tool.id)
for tool in db.session.scalars(
sa.select(WorkflowToolProvider)
.where(WorkflowToolProvider.tenant_id == tenant_id)
.order_by(WorkflowToolProvider.name)
).all()
]
selections["workflow_tools"] = _prompt_tool_category(
"Workflow tools",
workflow_tool_options,
auto_tools=auto_tools["workflow_tools"],
)
manual_labels.update(_selected_tool_labels(workflow_tool_options, selections["workflow_tools"]))
mcp_tool_options = [
(tool.id, tool.name, tool.server_identifier)
for tool in db.session.scalars(
sa.select(MCPToolProvider).where(MCPToolProvider.tenant_id == tenant_id).order_by(MCPToolProvider.name)
).all()
]
selections["mcp_tools"] = _prompt_tool_category(
"MCP tools",
mcp_tool_options,
auto_tools=auto_tools["mcp_tools"],
)
manual_labels.update(_selected_tool_labels(mcp_tool_options, selections["mcp_tools"]))
_print_final_tool_selection(auto_tools, selections, manual_labels)
return selections
def _selected_tool_labels_for_tenant(tenant_id: str, selected_tools: WizardToolSelection) -> dict[str, str]:
labels: dict[str, str] = {}
if selected_tools["api_tools"]:
labels.update(
_selected_tool_labels(
[
(tool.name, tool.name, tool.id)
for tool in db.session.scalars(
sa.select(ApiToolProvider)
.where(ApiToolProvider.tenant_id == tenant_id)
.order_by(ApiToolProvider.name)
).all()
],
selected_tools["api_tools"],
)
)
if selected_tools["workflow_tools"]:
labels.update(
_selected_tool_labels(
[
(tool.id, tool.name, tool.id)
for tool in db.session.scalars(
sa.select(WorkflowToolProvider)
.where(WorkflowToolProvider.tenant_id == tenant_id)
.order_by(WorkflowToolProvider.name)
).all()
],
selected_tools["workflow_tools"],
)
)
if selected_tools["mcp_tools"]:
labels.update(
_selected_tool_labels(
[
(tool.id, tool.name, tool.server_identifier)
for tool in db.session.scalars(
sa.select(MCPToolProvider)
.where(MCPToolProvider.tenant_id == tenant_id)
.order_by(MCPToolProvider.name)
).all()
],
selected_tools["mcp_tools"],
)
)
return labels
def _selected_tool_labels(options: list[tuple[str, str, str]], selected_values: list[str]) -> dict[str, str]:
selected = set(selected_values)
return {value: _format_tool_name_id(name, detail) for value, name, detail in options if value in selected}
def _prompt_tool_category(
label: str,
options: list[tuple[str, str, str]],
*,
auto_tools: dict[str, str | None],
) -> list[str]:
if not options:
click.echo(f"{label}: none")
return []
_print_wizard_step(label)
for index, (value, name, detail) in enumerate(options, 1):
marker = "[auto]" if _is_auto_tool(value, name, detail, auto_tools) else "[ ]"
click.echo(f"{index}. {marker} {name} ({detail})")
raw = click.prompt(
f"Select {label.lower()} by number, comma-separated numbers, all, or empty",
default="",
show_default=cast(Any, "empty"),
)
if not raw.strip():
return []
return parse_index_selection(raw, [value for value, _, _ in options])
def _is_auto_tool(value: str, name: str, detail: str, auto_tools: dict[str, str | None]) -> bool:
return name in auto_tools or value in auto_tools or value in auto_tools.values() or detail in auto_tools.values()
def _print_final_tool_selection(
auto_tools: WizardToolMap,
additional_tools: WizardToolSelection,
manual_labels: dict[str, str],
) -> None:
_print_wizard_step("Final Tool Selection")
_print_tool_selection_body(auto_tools, additional_tools, manual_labels)
def _print_tool_selection_body(
auto_tools: WizardToolMap,
additional_tools: WizardToolSelection,
manual_labels: dict[str, str],
) -> None:
click.echo("Final tools to export:")
_print_final_tool_category(
"Custom API tools",
auto_tools["api_tools"],
additional_tools["api_tools"],
manual_labels,
)
_print_final_tool_category(
"Workflow tools",
auto_tools["workflow_tools"],
additional_tools["workflow_tools"],
manual_labels,
)
_print_final_tool_category("MCP tools", auto_tools["mcp_tools"], additional_tools["mcp_tools"], manual_labels)
def _print_final_tool_category(
label: str,
auto_tools: dict[str, str | None],
manual_values: list[str],
manual_labels: dict[str, str],
) -> None:
click.echo(label)
lines = [f"- [auto] {_format_tool_name_id(name, identifier)}" for name, identifier in sorted(auto_tools.items())]
auto_identifiers = {identifier for identifier in auto_tools.values() if identifier}
lines.extend(
f"- [manual] {manual_labels.get(value, value)}"
for value in manual_values
if value not in auto_tools and value not in auto_identifiers
)
if not lines:
click.echo("- none")
return
for line in lines:
click.echo(line)
def _format_tool_name_id(name: str, identifier: str | None) -> str:
if identifier and identifier != name:
return f"{name}: {identifier}"
return name
def _confirm_wizard_summary(
*,
tenant_name: str,
app_names: list[str],
auto_tools: WizardToolMap,
additional_tools: WizardToolSelection,
manual_labels: dict[str, str],
include_referenced_tools: bool,
include_secrets: bool,
create_tokens: bool,
id_strategy: str,
conflict_strategy: str,
output_file: str,
) -> None:
_print_wizard_step("Summary")
click.echo("Migration export summary:")
click.echo(f"source tenant: {tenant_name}")
click.echo(f"selected apps: {len(app_names)}")
for app_name in app_names:
click.echo(f"- {app_name}")
click.echo(f"auto referenced tools: {str(include_referenced_tools).lower()}")
_print_tool_selection_body(auto_tools, additional_tools, manual_labels)
click.echo(f"include secrets: {str(include_secrets).lower()}")
click.echo(f"create app api token on import: {str(create_tokens).lower()}")
click.echo(f"id strategy: {id_strategy}")
click.echo(f"conflict strategy: {conflict_strategy}")
click.echo(f"output path: {output_file}")
if not click.confirm("Write migration package? [y/n, default: y]", default=True, show_default=False):
raise click.Abort()
def _prompt_output_file() -> tuple[str, bool]:
default_output = f"migration-data-{datetime.now().strftime('%Y%m%d-%H%M%S')}.json"
output_file = click.prompt("Output path", default=default_output, show_default=True)
if output_file.lower() in {"y", "yes", "n", "no"}:
raise click.ClickException("Output path must be a file path. Press Enter to use the default path.")
overwrite = False
if Path(output_file).exists():
overwrite = click.confirm(
"Output file exists. Overwrite? [y/n, default: n]",
default=False,
show_default=False,
)
if not overwrite:
raise click.ClickException(f"Output file already exists: {output_file}")
return output_file, overwrite
def _with_output_path(context: ReportContext | None, output_path: str) -> ReportContext:
if context is None:
return ReportContext(output_path=output_path)
return ReportContext(
output_path=output_path,
source_scope=context.source_scope,
selected_app_count=context.selected_app_count,
include_secrets=context.include_secrets,
target_tenant=context.target_tenant,
operator_email=context.operator_email,
app_api_tokens_created=context.app_api_tokens_created,
app_api_tokens_reused=context.app_api_tokens_reused,
id_mapping_count=context.id_mapping_count,
id_mappings=context.id_mappings,
)
def _render_report(report_items: list[ResourceReportItem], *, context: ReportContext | None = None) -> None:
for line in MigrationReportService().render(report_items, context=context):
click.echo(line)

View File

@ -30,7 +30,7 @@ def vdb_migrate(scope: str):
def migrate_annotation_vector_database():
"""
Migrate annotation datas to target vector database .
Migrate annotation data to target vector database.
"""
click.echo(click.style("Starting annotation data migration.", fg="green"))
create_count = 0
@ -140,7 +140,7 @@ def migrate_annotation_vector_database():
def migrate_knowledge_vector_database():
"""
Migrate vector database datas to target vector database .
Migrate vector database data to target vector database.
"""
click.echo(click.style("Starting vector database migration.", fg="green"))
create_count = 0

View File

@ -5,7 +5,7 @@ from uuid import UUID
from flask import request
from flask_restx import Resource, marshal
from pydantic import BaseModel, Field
from sqlalchemy import String, cast, func, or_, select
from sqlalchemy import String, case, cast, func, literal, or_, select
from sqlalchemy.dialects.postgresql import JSONB
from werkzeug.exceptions import Forbidden, NotFound
@ -169,12 +169,17 @@ class DatasetDocumentSegmentListApi(Resource):
# Use database-specific methods for JSON array search
if dify_config.SQLALCHEMY_DATABASE_URI_SCHEME == "postgresql":
# PostgreSQL: Use jsonb_array_elements_text to properly handle Unicode/Chinese text
# Guard with jsonb_typeof to avoid "cannot extract elements from a scalar" error
# when keywords is null or a non-array JSON value.
# Feed the set-returning function a JSON array in every row. Filtering in
# the subquery is not enough because PostgreSQL can still evaluate the
# SRF on scalar JSON before applying the predicate.
keywords_jsonb = cast(DocumentSegment.keywords, JSONB)
keywords_array = case(
(func.jsonb_typeof(keywords_jsonb) == "array", keywords_jsonb),
else_=cast(literal("[]"), JSONB),
)
keywords_condition = func.array_to_string(
func.array(
select(func.jsonb_array_elements_text(cast(DocumentSegment.keywords, JSONB)))
.where(func.jsonb_typeof(cast(DocumentSegment.keywords, JSONB)) == "array")
select(func.jsonb_array_elements_text(keywords_array))
.correlate(DocumentSegment)
.scalar_subquery()
),

View File

@ -863,7 +863,7 @@ class ToolManager:
return controller
@classmethod
def user_get_api_provider(cls, provider: str, tenant_id: str):
def user_get_api_provider(cls, provider: str, tenant_id: str, mask: bool = True):
"""
get api provider
"""
@ -902,8 +902,10 @@ class ToolManager:
tenant_id=tenant_id,
controller=controller,
)
masked_credentials = encrypter.mask_plugin_credentials(encrypter.decrypt(credentials))
if mask:
masked_credentials = encrypter.mask_plugin_credentials(encrypter.decrypt(credentials))
else:
masked_credentials = encrypter.decrypt(credentials)
try:
icon = emoji_icon_adapter.validate_json(provider_obj.icon)

View File

@ -6,7 +6,7 @@ from json.decoder import JSONDecodeError
from typing import Any, TypedDict
import httpx
from flask import request
from flask import has_request_context, request
from yaml import YAMLError, safe_load
from core.tools.entities.common_entities import I18nObject
@ -44,7 +44,7 @@ class ApiBasedToolSchemaParser:
raise ToolProviderNotFoundError("No server found in the openapi yaml.")
server_url = openapi["servers"][0]["url"]
request_env = request.headers.get("X-Request-Env")
request_env = request.headers.get("X-Request-Env") if has_request_context() else None
if request_env:
matched_servers = [server["url"] for server in openapi["servers"] if server["env"] == request_env]
server_url = matched_servers[0] if matched_servers else server_url

View File

@ -1,6 +1,7 @@
import logging
from core.tools.entities.tool_entities import ToolProviderType
from core.tools.errors import ToolProviderNotFoundError
from core.tools.tool_manager import ToolManager
from core.tools.utils.configuration import ToolParameterConfigurationManager
from core.workflow.human_input_adapter import adapt_node_config_for_graph
@ -38,6 +39,14 @@ def handle(sender, **kwargs):
identity_id=f"WORKFLOW.{app.id}.{node_data.get('id')}",
)
manager.delete_tool_parameters_cache()
except ToolProviderNotFoundError as exc:
logger.info(
"Skipped deleting tool parameters cache for workflow %s node %s "
"because tool provider is missing: %s",
app.id,
node_data.get("id"),
exc,
)
except Exception:
# tool dose not exist
logger.exception(

View File

@ -15,14 +15,18 @@ def init_app(app: DifyApp):
data_migrate,
delete_archived_workflow_runs,
export_app_messages,
export_migration_data,
export_migration_data_template,
extract_plugins,
extract_unique_plugins,
file_usage,
fix_app_site_missing,
import_migration_data,
install_plugins,
install_rag_pipeline_plugins,
migrate_data_for_plugin,
migrate_oss,
migration_data_wizard,
old_metadata_migration,
remove_orphaned_files_on_storage,
reset_email,
@ -70,6 +74,10 @@ def init_app(app: DifyApp):
clean_workflow_runs,
clean_expired_messages,
export_app_messages,
export_migration_data,
export_migration_data_template,
import_migration_data,
migration_data_wizard,
]
for cmd in cmds_to_register:
app.cli.add_command(cmd)

View File

@ -97,6 +97,7 @@ class AppDslService:
icon: str | None = None,
icon_background: str | None = None,
app_id: str | None = None,
import_app_id: str | None = None,
) -> Import:
"""Import an app from YAML content or URL."""
import_id = str(uuid.uuid4())
@ -262,6 +263,7 @@ class AppDslService:
icon=icon,
icon_background=icon_background,
dependencies=check_dependencies_pending_data,
import_app_id=import_app_id,
)
draft_var_srv = WorkflowDraftVariableService(session=self._session)
@ -385,6 +387,7 @@ class AppDslService:
icon: str | None = None,
icon_background: str | None = None,
dependencies: list[PluginDependency] | None = None,
import_app_id: str | None = None,
) -> App:
"""Create a new app or update an existing one."""
app_data = data.get("app", {})
@ -417,7 +420,7 @@ class AppDslService:
# Create new app
app = App()
app.id = str(uuid4())
app.id = import_app_id or str(uuid4())
app.tenant_id = account.current_tenant_id
app.mode = app_mode
app.name = name or app_data.get("name", "")

View File

@ -0,0 +1,17 @@
from services.data_migration.entities import (
ConflictStrategy,
ExportSelection,
IdStrategy,
ImportOptions,
MigrationDataError,
MigrationPackage,
)
__all__ = [
"ConflictStrategy",
"ExportSelection",
"IdStrategy",
"ImportOptions",
"MigrationDataError",
"MigrationPackage",
]

View File

@ -0,0 +1,92 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from services.data_migration.entities import DependencyKind
@dataclass(frozen=True)
class DiscoveredDependency:
kind: DependencyKind
provider_id: str
provider_name: str | None = None
source: str | None = None
class DependencyDiscoveryService:
def discover_from_dsl(self, dsl: dict[str, Any]) -> list[DiscoveredDependency]:
seen: set[tuple[DependencyKind, str]] = set()
result: list[DiscoveredDependency] = []
for node in self._nodes_from_dsl(dsl):
data = node.get("data", {}) if isinstance(node, dict) else {}
for dependency in self._dependencies_from_node(data):
key = (dependency.kind, dependency.provider_id)
if dependency.provider_id and key not in seen:
seen.add(key)
result.append(dependency)
return result
def _nodes_from_dsl(self, dsl: dict[str, Any]) -> list[dict[str, Any]]:
nodes: list[dict[str, Any]] = []
graph = dsl.get("graph") if isinstance(dsl, dict) else None
if isinstance(graph, dict) and isinstance(graph.get("nodes"), list):
nodes.extend(node for node in graph["nodes"] if isinstance(node, dict))
workflow = dsl.get("workflow") if isinstance(dsl, dict) else None
workflow_graph = workflow.get("graph") if isinstance(workflow, dict) else None
if isinstance(workflow_graph, dict) and isinstance(workflow_graph.get("nodes"), list):
nodes.extend(node for node in workflow_graph["nodes"] if isinstance(node, dict))
return nodes
def _dependencies_from_node(self, data: dict[str, Any]) -> list[DiscoveredDependency]:
dependencies: list[DiscoveredDependency] = []
node_type = data.get("type")
if node_type == "tool":
dependency = self._from_tool_config(data, source="tool_node")
if dependency:
dependencies.append(dependency)
if node_type == "agent":
for tool_config in self._agent_tool_configs(data):
if isinstance(tool_config, dict):
dependency = self._from_tool_config(tool_config, source="agent_node")
if dependency:
dependencies.append(dependency)
return dependencies
def _agent_tool_configs(self, data: dict[str, Any]) -> list[dict[str, Any]]:
configs = data.get("tools")
if isinstance(configs, list):
return [config for config in configs if isinstance(config, dict)]
agent_parameters = data.get("agent_parameters")
if not isinstance(agent_parameters, dict):
return []
tools_parameter = agent_parameters.get("tools")
if not isinstance(tools_parameter, dict):
return []
value = tools_parameter.get("value", [])
if not isinstance(value, list):
return []
return [config for config in value if isinstance(config, dict)]
def _from_tool_config(self, config: dict[str, Any], *, source: str) -> DiscoveredDependency | None:
provider_id = config.get("provider_id") or config.get("provider_name") or config.get("provider")
if not provider_id:
return None
provider_type = str(config.get("provider_type") or config.get("type") or "")
kind = self._kind_from_provider_type(provider_type)
return DiscoveredDependency(
kind=kind,
provider_id=str(provider_id),
provider_name=config.get("provider_name"),
source=source,
)
def _kind_from_provider_type(self, provider_type: str) -> DependencyKind:
normalized = provider_type.lower()
if normalized in {"api", "custom", "api_tool"}:
return DependencyKind.API_TOOL
if normalized in {"workflow", "workflow_tool"}:
return DependencyKind.WORKFLOW_TOOL
if normalized == "mcp":
return DependencyKind.MCP_TOOL
return DependencyKind.BUILTIN_OR_PLUGIN_TOOL

View File

@ -0,0 +1,241 @@
"""Typed entities for versioned cross-environment migration packages.
This module is intentionally side-effect free. It owns only value objects and
validation for migration package/config shapes; command output and database I/O
belong in adapter and service modules built on top of these entities.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import StrEnum
from typing import Any, Literal, TypedDict
class MigrationDataError(ValueError):
"""Raised when migration config or package data is invalid."""
class IdStrategy(StrEnum):
PRESERVE_ID = "preserve-id"
GENERATE_NEW_ID = "generate-new-id"
class ConflictStrategy(StrEnum):
FAIL = "fail"
SKIP = "skip"
UPDATE = "update"
class ResourceType(StrEnum):
WORKFLOW = "workflow"
API_TOOL = "api_tool"
WORKFLOW_TOOL = "workflow_tool"
MCP_TOOL = "mcp_tool"
DEPENDENCY = "dependency"
class DependencyKind(StrEnum):
API_TOOL = "api_tool"
WORKFLOW_TOOL = "workflow_tool"
MCP_TOOL = "mcp_tool"
BUILTIN_OR_PLUGIN_TOOL = "builtin_or_plugin_tool"
UNRESOLVED = "unresolved"
class TargetTenantSelector(TypedDict, total=False):
id: str
name: str
def _parse_target_tenant(value: Any) -> TargetTenantSelector | None:
if value is None:
return None
if not isinstance(value, dict):
raise MigrationDataError("metadata.target_tenant must be an object when provided.")
target: TargetTenantSelector = {}
target_id = value.get("id")
if target_id is not None:
if not isinstance(target_id, str):
raise MigrationDataError("metadata.target_tenant.id must be a string.")
target["id"] = target_id
target_name = value.get("name")
if target_name is not None:
if not isinstance(target_name, str):
raise MigrationDataError("metadata.target_tenant.name must be a string.")
target["name"] = target_name
unsupported_keys = sorted(set(value.keys()) - {"id", "name"})
if unsupported_keys:
raise MigrationDataError(f"metadata.target_tenant contains unsupported fields: {unsupported_keys}")
return target
def _parse_package_section(value: Any, section: str) -> list[dict[str, Any]]:
if value is None:
return []
if not isinstance(value, list):
raise MigrationDataError(f"Migration package field '{section}' must be a list.")
for item in value:
if not isinstance(item, dict):
raise MigrationDataError(f"Migration package field '{section}' must contain only objects.")
return value
@dataclass(frozen=True)
class SourceTenant:
id: str
name: str
@classmethod
def from_mapping(cls, value: dict[str, Any]) -> SourceTenant:
return cls(id=str(value.get("id", "")), name=str(value.get("name", "")))
@dataclass(frozen=True)
class ImportOptions:
create_app_api_token_on_import: bool = False
id_strategy: IdStrategy = IdStrategy.PRESERVE_ID
conflict_strategy: ConflictStrategy = ConflictStrategy.FAIL
@classmethod
def from_mapping(cls, value: dict[str, Any] | None) -> ImportOptions:
value = value or {}
try:
id_strategy = IdStrategy(value.get("id_strategy", IdStrategy.PRESERVE_ID))
except ValueError as exc:
raise MigrationDataError(f"Unsupported import_options.id_strategy: {value.get('id_strategy')}") from exc
try:
conflict_strategy = ConflictStrategy(value.get("conflict_strategy", ConflictStrategy.FAIL))
except ValueError as exc:
raise MigrationDataError(
f"Unsupported import_options.conflict_strategy: {value.get('conflict_strategy')}"
) from exc
return cls(
create_app_api_token_on_import=bool(value.get("create_app_api_token_on_import", False)),
id_strategy=id_strategy,
conflict_strategy=conflict_strategy,
)
@dataclass(frozen=True)
class MigrationMetadata:
version: str
source_scope: Literal["single"]
source_tenants: list[SourceTenant]
target_tenant: TargetTenantSelector | None = None
created_at: str | None = None
include_secrets: bool = False
import_options: ImportOptions = field(default_factory=ImportOptions)
@classmethod
def from_mapping(cls, value: dict[str, Any]) -> MigrationMetadata:
version = value.get("version")
if not version:
raise MigrationDataError("Migration package must include metadata.version.")
source_scope = value.get("source_scope", "single")
if source_scope != "single":
raise MigrationDataError(f"Unsupported source_scope: {source_scope}")
source_tenants = [
SourceTenant.from_mapping(item) for item in value.get("source_tenants", []) if isinstance(item, dict)
]
return cls(
version=str(version),
source_scope="single",
source_tenants=source_tenants,
target_tenant=_parse_target_tenant(value.get("target_tenant")),
created_at=value.get("created_at"),
include_secrets=bool(value.get("include_secrets", False)),
import_options=ImportOptions.from_mapping(value.get("import_options")),
)
@dataclass(frozen=True)
class MigrationPackage:
metadata: MigrationMetadata
workflows: list[dict[str, Any]] = field(default_factory=list)
tools: list[dict[str, Any]] = field(default_factory=list)
workflow_tools: list[dict[str, Any]] = field(default_factory=list)
mcp_tools: list[dict[str, Any]] = field(default_factory=list)
dependencies: list[dict[str, Any]] = field(default_factory=list)
@classmethod
def from_mapping(cls, value: dict[str, Any]) -> MigrationPackage:
metadata_value = value.get("metadata")
if not isinstance(metadata_value, dict):
raise MigrationDataError("Migration package must include metadata.version.")
return cls(
metadata=MigrationMetadata.from_mapping(metadata_value),
workflows=_parse_package_section(value.get("workflows"), "workflows"),
tools=_parse_package_section(value.get("tools"), "tools"),
workflow_tools=_parse_package_section(value.get("workflow_tools"), "workflow_tools"),
mcp_tools=_parse_package_section(value.get("mcp_tools"), "mcp_tools"),
dependencies=_parse_package_section(value.get("dependencies"), "dependencies"),
)
@dataclass(frozen=True)
class ExportSelection:
source_tenant_name: str
app_ids: list[str]
source_tenant_id: str | None = None
export_all_apps: bool = False
include_referenced_tools: bool = True
additional_api_tools: list[str] = field(default_factory=list)
additional_workflow_tools: list[str] = field(default_factory=list)
additional_mcp_tools: list[str] = field(default_factory=list)
include_secrets: bool = False
import_options: ImportOptions = field(default_factory=ImportOptions)
@dataclass(frozen=True)
class ResourceReportItem:
resource_type: ResourceType
identifier: str
name: str | None
status: str
message: str | None = None
@dataclass(frozen=True)
class ResourceIdMapping:
resource_type: ResourceType
name: str | None
source_id: str
target_id: str
@dataclass(frozen=True)
class ExportResult:
package: MigrationPackage
report_items: list[ResourceReportItem]
report_context: ReportContext | None = None
@dataclass(frozen=True)
class ImportTarget:
tenant_id: str
tenant_name: str
operator_id: str
operator_email: str | None
@dataclass(frozen=True)
class ImportResult:
report_items: list[ResourceReportItem]
id_mapping: dict[str, str] = field(default_factory=dict)
report_context: ReportContext | None = None
@dataclass(frozen=True)
class ReportContext:
output_path: str | None = None
source_scope: str | None = None
selected_app_count: int | None = None
include_secrets: bool | None = None
target_tenant: str | None = None
operator_email: str | None = None
app_api_tokens_created: int = 0
app_api_tokens_reused: int = 0
id_mapping_count: int = 0
id_mappings: dict[str, str] = field(default_factory=dict)
id_mapping_details: list[ResourceIdMapping] = field(default_factory=list)

View File

@ -0,0 +1,492 @@
from __future__ import annotations
from collections.abc import Iterable
from typing import Any
from uuid import UUID
import sqlalchemy as sa
import yaml
from core.tools.tool_manager import ToolManager
from extensions.ext_database import db
from graphon.model_runtime.utils.encoders import jsonable_encoder
from models import Account, Tenant
from models.account import TenantAccountJoin
from models.model import App
from models.tools import MCPToolProvider
from services.app_dsl_service import AppDslService
from services.data_migration.dependency_discovery_service import DependencyDiscoveryService, DiscoveredDependency
from services.data_migration.entities import (
DependencyKind,
ExportResult,
ExportSelection,
ImportOptions,
MigrationDataError,
ReportContext,
ResourceReportItem,
ResourceType,
)
from services.data_migration.package_service import MigrationPackageService
from services.tools.workflow_tools_manage_service import WorkflowToolManageService
SUPPORTED_APP_MODES = {"workflow", "advanced-chat"}
class ExportConfigParser:
def parse(self, data: dict[str, Any]) -> ExportSelection:
if not isinstance(data, dict):
raise MigrationDataError("Export config JSON must be an object.")
source_tenant = self._source_tenant(data)
source_tenant_name = self._source_tenant_name(source_tenant, data)
apps = self._mapping(data.get("apps"), field_name="apps")
self._validate_source_scope(data)
self._validate_app_modes(apps.get("modes", []))
additional_tools = self._mapping(data.get("additional_tools"), field_name="additional_tools")
return ExportSelection(
source_tenant_name=source_tenant_name,
app_ids=self._string_list(apps.get("ids", data.get("workflows", [])), field_name="apps.ids"),
source_tenant_id=source_tenant.get("id"),
export_all_apps=bool(apps.get("all", data.get("export_all_workflows", False))),
include_referenced_tools=bool(data.get("include_referenced_tools", True)),
additional_api_tools=self._string_list(
additional_tools.get("api_tools", data.get("tools", [])), field_name="additional_tools.api_tools"
),
additional_workflow_tools=self._string_list(
additional_tools.get("workflow_tools", data.get("workflow_tools", [])),
field_name="additional_tools.workflow_tools",
),
additional_mcp_tools=self._string_list(
additional_tools.get("mcp_tools", data.get("mcp_tools", [])),
field_name="additional_tools.mcp_tools",
),
include_secrets=bool(data.get("include_secrets", False)),
import_options=ImportOptions.from_mapping(data.get("import_options")),
)
def _source_tenant(self, data: dict[str, Any]) -> dict[str, Any]:
if "source_tenant" in data:
return self._mapping(data.get("source_tenant"), field_name="source_tenant")
return {}
def _source_tenant_name(self, source_tenant: dict[str, Any], data: dict[str, Any]) -> str:
if source_tenant:
source_tenant_name = source_tenant.get("name")
if not source_tenant_name:
raise MigrationDataError("Export config must include source_tenant.name.")
return str(source_tenant_name)
source_tenant_name = data.get("tenant_name")
if not source_tenant_name:
raise MigrationDataError("Export config must include source_tenant.name.")
return str(source_tenant_name)
def _validate_source_scope(self, data: dict[str, Any]) -> None:
source_tenant = data.get("source_tenant")
if not isinstance(source_tenant, dict):
return
mode = source_tenant.get("mode", "single")
if mode != "single":
raise MigrationDataError(f"Unsupported source_tenant.mode: {mode}")
def _validate_app_modes(self, modes: Any) -> None:
app_modes = self._string_list(modes, field_name="apps.modes") if modes else []
unsupported_modes = sorted(set(app_modes) - SUPPORTED_APP_MODES)
if unsupported_modes:
raise MigrationDataError(f"Unsupported app modes for export: {unsupported_modes}")
def _mapping(self, value: Any, *, field_name: str) -> dict[str, Any]:
if value is None:
return {}
if not isinstance(value, dict):
raise MigrationDataError(f"Export config field '{field_name}' must be an object.")
return value
def _string_list(self, value: Any, *, field_name: str) -> list[str]:
if value is None:
return []
if not isinstance(value, list):
raise MigrationDataError(f"Export config field '{field_name}' must be a list.")
return [str(item) for item in value]
class MigrationExportService:
def __init__(
self,
*,
package_service: MigrationPackageService | None = None,
dependency_discovery_service: DependencyDiscoveryService | None = None,
) -> None:
self.package_service = package_service or MigrationPackageService()
self.dependency_discovery_service = dependency_discovery_service or DependencyDiscoveryService()
def export(self, selection: ExportSelection) -> ExportResult:
tenant = self._get_tenant(selection)
package = self.package_service.build_empty_package(
source_tenant_id=tenant.id,
source_tenant_name=tenant.name,
include_secrets=selection.include_secrets,
import_options=selection.import_options,
)
report_items: list[ResourceReportItem] = []
discovered_dependencies: list[DiscoveredDependency] = []
apps = self._selected_apps(tenant.id, selection)
exported_app_ids = {app.id for app in apps}
for app in apps:
dsl_content = AppDslService.export_dsl(app_model=app, include_secret=selection.include_secrets)
package.workflows.append(
{
"id": app.id,
"name": app.name,
"mode": app.mode.value if hasattr(app.mode, "value") else app.mode,
"dsl": dsl_content,
"source_tenant_id": tenant.id,
"create_app_api_token_on_import": selection.import_options.create_app_api_token_on_import,
}
)
report_items.append(ResourceReportItem(ResourceType.WORKFLOW, app.id, app.name, "exported"))
if selection.include_referenced_tools:
discovered_dependencies.extend(self._discover_dependencies(dsl_content))
self._export_api_tools(
tenant.id,
self._provider_ids(selection.additional_api_tools, discovered_dependencies, DependencyKind.API_TOOL),
include_secrets=selection.include_secrets,
exported_tools=package.tools,
report_items=report_items,
)
self._export_workflow_tools(
tenant,
self._provider_ids(
selection.additional_workflow_tools, discovered_dependencies, DependencyKind.WORKFLOW_TOOL
),
exported_app_ids=exported_app_ids,
exported_workflow_tools=package.workflow_tools,
dependencies=package.dependencies,
report_items=report_items,
)
self._export_mcp_tools(
tenant_id=tenant.id,
provider_ids=self._provider_ids(
selection.additional_mcp_tools,
discovered_dependencies,
DependencyKind.MCP_TOOL,
),
include_secrets=selection.include_secrets,
exported_mcp_tools=package.mcp_tools,
dependencies=package.dependencies,
report_items=report_items,
)
self._record_dependency_metadata(
self._dependencies_by_kind(discovered_dependencies, DependencyKind.BUILTIN_OR_PLUGIN_TOOL),
package.dependencies,
report_items,
)
return ExportResult(
package=package,
report_items=report_items,
report_context=ReportContext(
source_scope=package.metadata.source_scope,
selected_app_count=len(apps),
include_secrets=selection.include_secrets,
),
)
def _get_tenant(self, selection: ExportSelection) -> Tenant:
if selection.source_tenant_id:
tenant = db.session.get(Tenant, selection.source_tenant_id)
if tenant is None:
raise MigrationDataError(f"Source tenant not found: {selection.source_tenant_id}")
if tenant.name != selection.source_tenant_name:
raise MigrationDataError(
f"Source tenant id/name mismatch: {selection.source_tenant_id} / {selection.source_tenant_name}"
)
return tenant
tenants = list(db.session.scalars(sa.select(Tenant).where(Tenant.name == selection.source_tenant_name)).all())
if not tenants:
raise MigrationDataError(f"Source tenant not found: {selection.source_tenant_name}")
if len(tenants) > 1:
raise MigrationDataError(
f"Source tenant name is ambiguous; use source_tenant.id: {selection.source_tenant_name}"
)
return tenants[0]
def _selected_apps(self, tenant_id: str, selection: ExportSelection) -> list[App]:
query = sa.select(App).where(App.tenant_id == tenant_id, App.mode.in_(SUPPORTED_APP_MODES))
if not selection.export_all_apps:
if not selection.app_ids:
return []
query = query.where(App.id.in_(selection.app_ids))
apps = list(db.session.scalars(query).all())
if not selection.export_all_apps and len(apps) != len(set(selection.app_ids)):
found_ids = {app.id for app in apps}
missing_ids = [app_id for app_id in selection.app_ids if app_id not in found_ids]
raise MigrationDataError(
f"Selected app IDs not found in source tenant or unsupported app mode: {missing_ids}"
)
return apps
def _discover_dependencies(self, dsl_content: str | dict[str, Any]) -> list[DiscoveredDependency]:
if isinstance(dsl_content, dict):
dsl = dsl_content
else:
raw_dsl = yaml.safe_load(dsl_content) if dsl_content else {}
dsl = raw_dsl if isinstance(raw_dsl, dict) else {}
return self.dependency_discovery_service.discover_from_dsl(dsl)
def _export_api_tools(
self,
tenant_id: str,
provider_ids: Iterable[str],
*,
include_secrets: bool,
exported_tools: list[dict[str, Any]],
report_items: list[ResourceReportItem],
) -> None:
for provider_id in self._dedupe(provider_ids):
try:
tool_data = ToolManager.user_get_api_provider(
provider=provider_id,
tenant_id=tenant_id,
mask=not include_secrets,
)
if not include_secrets:
tool_data.pop("credentials", None)
tool_data.pop("tools", None)
tool_data["provider_name"] = provider_id
tool_data["source_tenant_id"] = tenant_id
exported_tools.append(tool_data)
report_items.append(ResourceReportItem(ResourceType.API_TOOL, provider_id, provider_id, "exported"))
except Exception as exc:
report_items.append(
ResourceReportItem(ResourceType.API_TOOL, provider_id, provider_id, "unresolved", str(exc))
)
def _export_workflow_tools(
self,
tenant: Tenant,
provider_ids: Iterable[str],
*,
exported_app_ids: set[str],
exported_workflow_tools: list[dict[str, Any]],
dependencies: list[dict[str, Any]],
report_items: list[ResourceReportItem],
) -> None:
provider_ids = self._dedupe(provider_ids)
if not provider_ids:
return
owner = self._get_tenant_owner(tenant.id)
if owner is None:
for provider_id in provider_ids:
report_items.append(
ResourceReportItem(
ResourceType.WORKFLOW_TOOL,
provider_id,
provider_id,
"unresolved",
f"No owner account found for source tenant: {tenant.name}",
)
)
return
for provider_id in provider_ids:
try:
tool_data = WorkflowToolManageService.get_workflow_tool_by_tool_id(
user_id=owner.id,
tenant_id=tenant.id,
workflow_tool_id=provider_id,
)
tool_info = jsonable_encoder(tool_data)
tool_info["id"] = provider_id
tool_info["app_id"] = tool_info.get("workflow_app_id")
tool_info["source_tenant_id"] = tenant.id
for field_name in ("workflow_tool_id", "workflow_app_id", "tool"):
tool_info.pop(field_name, None)
exported_workflow_tools.append(tool_info)
if tool_info.get("app_id") not in exported_app_ids:
workflow_app_id = str(tool_info.get("app_id") or "")
workflow_app = db.session.get(App, workflow_app_id) if workflow_app_id else None
self._record_dependency_metadata(
[
DiscoveredDependency(
DependencyKind.WORKFLOW_TOOL,
workflow_app_id,
provider_name=workflow_app.name if workflow_app else tool_info.get("name"),
source="workflow_tool_app",
)
],
dependencies,
report_items,
)
report_items.append(
ResourceReportItem(ResourceType.WORKFLOW_TOOL, provider_id, tool_info.get("name"), "exported")
)
except Exception as exc:
report_items.append(
ResourceReportItem(ResourceType.WORKFLOW_TOOL, provider_id, provider_id, "unresolved", str(exc))
)
def _get_tenant_owner(self, tenant_id: str) -> Account | None:
return db.session.scalar(
sa.select(Account)
.join(TenantAccountJoin, Account.id == TenantAccountJoin.account_id)
.where(TenantAccountJoin.tenant_id == tenant_id, TenantAccountJoin.role == "owner")
.order_by(TenantAccountJoin.created_at.asc())
.limit(1)
)
def _export_mcp_tools(
self,
*,
tenant_id: str,
provider_ids: Iterable[str],
include_secrets: bool,
exported_mcp_tools: list[dict[str, Any]],
dependencies: list[dict[str, Any]],
report_items: list[ResourceReportItem],
) -> None:
for provider_id in self._dedupe(provider_ids):
if not include_secrets:
self._record_dependency_metadata(
[DiscoveredDependency(DependencyKind.MCP_TOOL, provider_id, source="mcp_provider")],
dependencies,
report_items,
)
continue
try:
provider = self._get_mcp_provider(tenant_id, provider_id)
exported_mcp_tools.append(self._serialize_mcp_provider(provider))
report_items.append(ResourceReportItem(ResourceType.MCP_TOOL, provider_id, provider.name, "exported"))
except Exception as exc:
report_items.append(
ResourceReportItem(ResourceType.MCP_TOOL, provider_id, provider_id, "unresolved", str(exc))
)
def _get_mcp_provider(self, tenant_id: str, provider_id: str) -> MCPToolProvider:
predicates = [MCPToolProvider.server_identifier == provider_id]
if self._is_uuid_string(provider_id):
predicates.append(MCPToolProvider.id == provider_id)
provider = db.session.scalar(
sa.select(MCPToolProvider).where(MCPToolProvider.tenant_id == tenant_id, sa.or_(*predicates))
)
if provider is None:
raise MigrationDataError(f"MCP provider not found: {provider_id}")
return provider
def _is_uuid_string(self, value: str) -> bool:
try:
UUID(value)
except ValueError:
return False
return True
def _serialize_mcp_provider(self, provider: MCPToolProvider) -> dict[str, Any]:
provider_entity = provider.to_entity()
provider_icon = provider_entity.provider_icon
if isinstance(provider_icon, dict):
icon = provider_icon.get("content")
icon_background = provider_icon.get("background")
icon_type = "emoji"
else:
icon = provider_icon
icon_background = None
icon_type = "url"
return {
"id": provider.id,
"name": provider.name,
"server_url": provider_entity.decrypt_server_url(),
"server_identifier": provider.server_identifier,
"icon": icon,
"icon_background": icon_background,
"icon_type": icon_type,
"configuration": {"timeout": provider.timeout, "sse_read_timeout": provider.sse_read_timeout},
"headers": provider_entity.decrypt_headers(),
"authentication": self._serialize_mcp_authentication(provider_entity.decrypt_authentication()),
"tools": provider.tool_dict,
"source_tenant_id": provider.tenant_id,
}
def _serialize_mcp_authentication(self, authentication: dict[str, Any] | None) -> dict[str, Any] | None:
if not authentication or not authentication.get("client_id"):
return None
return {
"client_id": authentication["client_id"],
"client_secret": authentication.get("client_secret"),
}
def _record_dependency_metadata(
self,
dependencies_to_record: Iterable[DiscoveredDependency],
dependencies: list[dict[str, Any]],
report_items: list[ResourceReportItem],
) -> None:
existing = {(item.get("kind"), item.get("provider_id")) for item in dependencies}
for dependency in dependencies_to_record:
key = (dependency.kind.value, dependency.provider_id)
if key in existing:
continue
existing.add(key)
dependencies.append(
{
"kind": dependency.kind.value,
"provider_id": dependency.provider_id,
"provider_name": dependency.provider_name,
"source": dependency.source,
}
)
report_items.append(
ResourceReportItem(
ResourceType.DEPENDENCY,
dependency.provider_id,
self._dependency_report_name(dependency),
"dependency-only",
self._dependency_message(dependency.kind),
)
)
def _provider_ids(
self,
manual_provider_ids: Iterable[str],
discovered_dependencies: Iterable[DiscoveredDependency],
kind: DependencyKind,
) -> list[str]:
provider_ids = list(manual_provider_ids)
provider_ids.extend(
self._provider_export_identifier(dependency)
for dependency in discovered_dependencies
if dependency.kind == kind
)
return self._dedupe(provider_ids)
def _provider_export_identifier(self, dependency: DiscoveredDependency) -> str:
if dependency.kind == DependencyKind.API_TOOL and dependency.provider_name:
return dependency.provider_name
return dependency.provider_id
def _dependencies_by_kind(
self, discovered_dependencies: Iterable[DiscoveredDependency], kind: DependencyKind
) -> list[DiscoveredDependency]:
return [dependency for dependency in discovered_dependencies if dependency.kind == kind]
def _dedupe(self, values: Iterable[str]) -> list[str]:
seen: set[str] = set()
result: list[str] = []
for value in values:
if value and value not in seen:
seen.add(value)
result.append(value)
return result
def _dependency_message(self, kind: DependencyKind) -> str:
if kind == DependencyKind.MCP_TOOL:
return "Configure MCP provider manually in the target tenant unless exporting with secrets enabled."
if kind == DependencyKind.BUILTIN_OR_PLUGIN_TOOL:
return "Ensure the built-in or plugin tool exists in the target environment."
return "Dependency metadata only; ensure the resource exists in the target environment."
def _dependency_report_name(self, dependency: DiscoveredDependency) -> str:
name = dependency.provider_name or dependency.provider_id
if dependency.kind == DependencyKind.WORKFLOW_TOOL:
return f"workflow {name}"
return f"{dependency.kind.value} {name}"

View File

@ -0,0 +1,938 @@
"""Apply versioned migration packages to an explicitly resolved target tenant.
Import target resolution is deliberately performed before any resource import
work. The service does not write Click output; callers receive structured
report items and can decide how to render them.
"""
from __future__ import annotations
import json
from collections.abc import Iterable
from dataclasses import dataclass
from typing import Any, cast
from uuid import UUID
import sqlalchemy as sa
import yaml
from sqlalchemy import or_
from sqlalchemy.orm import Session, sessionmaker
from core.entities.mcp_provider import MCPAuthentication, MCPConfiguration
from core.tools.entities.tool_entities import ApiProviderSchemaType, WorkflowToolParameterConfiguration
from extensions.ext_database import db
from libs.datetime_utils import naive_utc_now
from models import Account, ApiToken, Tenant, TenantAccountJoin, TenantAccountRole
from models.enums import ApiTokenType
from models.model import App
from models.tools import ApiToolProvider, MCPToolProvider, WorkflowToolProvider
from services.app_dsl_service import AppDslService
from services.data_migration.dependency_discovery_service import DependencyDiscoveryService
from services.data_migration.entities import (
ConflictStrategy,
DependencyKind,
IdStrategy,
ImportOptions,
ImportResult,
ImportTarget,
MigrationDataError,
MigrationPackage,
ReportContext,
ResourceIdMapping,
ResourceReportItem,
ResourceType,
)
from services.entities.dsl_entities import ImportStatus
from services.tools.api_tools_manage_service import ApiToolManageService
from services.tools.mcp_tools_manage_service import MCPToolManageService
from services.tools.workflow_tools_manage_service import WorkflowToolManageService
from services.workflow_service import WorkflowService
@dataclass(frozen=True)
class ImportRequest:
"""Structured input for package import.
`cli_target_tenant` and `config_target_tenant` are target tenant names from
outer adapters. They intentionally override package metadata, because a
migration package may be reused across environments.
"""
package: MigrationPackage
cli_target_tenant: str | None = None
config_target_tenant: str | None = None
operator_email: str | None = None
options_override: ImportOptions | None = None
class ImportTargetResolver:
"""Resolve the target tenant and operator before import side effects begin."""
def select_target_tenant_name(self, request: ImportRequest) -> str:
if request.cli_target_tenant:
return request.cli_target_tenant
if request.config_target_tenant:
return request.config_target_tenant
package_target = request.package.metadata.target_tenant or {}
if package_target.get("name"):
return package_target["name"]
if package_target.get("id"):
return package_target["id"]
raise MigrationDataError(
"Target tenant must be provided by --target-tenant, import config, or package metadata."
)
def resolve(self, request: ImportRequest) -> ImportTarget:
target_tenant_name = self.select_target_tenant_name(request)
package_target = request.package.metadata.target_tenant or {}
if request.cli_target_tenant or request.config_target_tenant:
tenant = self._resolve_tenant_by_id_or_name(target_tenant_name)
elif package_target.get("id") and self._is_uuid(package_target["id"]):
tenant = db.session.get(Tenant, package_target["id"])
if tenant is not None and package_target.get("name") and tenant.name != package_target.get("name"):
raise MigrationDataError(
f"Target tenant id/name mismatch: {package_target['id']} / {package_target['name']}"
)
else:
tenant = self._resolve_tenant_by_id_or_name(target_tenant_name)
if tenant is None:
raise MigrationDataError(f"Target tenant not found: {target_tenant_name}")
account_query = (
db.session.query(Account)
.join(TenantAccountJoin, Account.id == TenantAccountJoin.account_id)
.filter(TenantAccountJoin.tenant_id == tenant.id)
)
if request.operator_email:
account_query = account_query.filter(Account.email == request.operator_email)
identity = request.operator_email
else:
account_query = account_query.filter(TenantAccountJoin.role == TenantAccountRole.OWNER).order_by(
TenantAccountJoin.created_at.asc()
)
identity = "earliest owner"
account = account_query.first()
if account is None:
raise MigrationDataError(f"No operator account found for target tenant {target_tenant_name}: {identity}")
return ImportTarget(
tenant_id=tenant.id,
tenant_name=tenant.name,
operator_id=account.id,
operator_email=account.email,
)
def _resolve_tenant_by_id_or_name(self, value: str) -> Tenant | None:
if self._is_uuid(value):
tenant = db.session.get(Tenant, value)
if tenant is not None:
return tenant
tenants = list(db.session.scalars(sa.select(Tenant).where(Tenant.name == value)).all())
if len(tenants) > 1:
raise MigrationDataError(f"Target tenant name is ambiguous; use target_tenant.id: {value}")
return tenants[0] if tenants else None
def _is_uuid(self, value: str) -> bool:
try:
UUID(value)
except ValueError:
return False
return True
class MigrationImportService:
"""Apply package resources using Dify service APIs and structured reporting."""
target_resolver: ImportTargetResolver
def __init__(self, *, target_resolver: ImportTargetResolver | None = None) -> None:
self.target_resolver = target_resolver or ImportTargetResolver()
def import_package(self, request: ImportRequest) -> ImportResult:
target = self.target_resolver.resolve(request)
options = request.options_override or request.package.metadata.import_options
report_items = [
ResourceReportItem(
resource_type=ResourceType.DEPENDENCY,
identifier=target.tenant_id,
name=target.tenant_name,
status="resolved",
message=f"operator: {target.operator_email or target.operator_id}",
)
]
id_mapping: dict[str, str] = {}
id_mapping_details: list[ResourceIdMapping] = []
self._import_api_tools(
request.package,
target,
options,
report_items,
id_mapping,
id_mapping_details,
self._source_api_provider_ids_by_name(request.package),
)
self._import_mcp_tools(request.package, target, options, report_items, id_mapping, id_mapping_details)
self._preflight_dependency_only_mcp(request.package, target, report_items)
workflow_tool_app_ids = self._workflow_tool_source_app_ids(request.package)
imported_workflow_ids: set[str] = set()
if workflow_tool_app_ids:
self._import_workflows(
request.package,
target,
options,
report_items,
id_mapping,
id_mapping_details=id_mapping_details,
imported_workflow_ids=imported_workflow_ids,
only_app_ids=workflow_tool_app_ids,
)
self._import_workflow_tools(request.package, target, options, id_mapping, id_mapping_details, report_items)
self._import_workflows(
request.package,
target,
options,
report_items,
id_mapping,
id_mapping_details=id_mapping_details,
imported_workflow_ids=imported_workflow_ids,
skip_app_ids=imported_workflow_ids,
)
return ImportResult(
report_items=report_items,
id_mapping=id_mapping,
report_context=ReportContext(
target_tenant=target.tenant_name,
operator_email=target.operator_email,
id_mapping_count=len(id_mapping),
id_mappings=dict(id_mapping),
id_mapping_details=id_mapping_details,
),
)
def _import_workflows(
self,
package: MigrationPackage,
target: ImportTarget,
options: ImportOptions,
report_items: list[ResourceReportItem],
id_mapping: dict[str, str],
id_mapping_details: list[ResourceIdMapping],
imported_workflow_ids: set[str] | None = None,
only_app_ids: set[str] | None = None,
skip_app_ids: set[str] | None = None,
) -> None:
account = db.session.get(Account, target.operator_id)
tenant = db.session.get(Tenant, target.tenant_id)
if account is None:
raise MigrationDataError(f"Operator account not found: {target.operator_id}")
if tenant is None:
raise MigrationDataError(f"Target tenant not found: {target.tenant_id}")
account.current_tenant = tenant
for workflow_data in package.workflows:
app_id = self._optional_string(workflow_data.get("id"))
if only_app_ids and app_id not in only_app_ids:
continue
if skip_app_ids and app_id in skip_app_ids:
continue
dsl_content = self._rewrite_workflow_dsl_provider_ids(
self._required_string(workflow_data, "dsl", "workflow"),
id_mapping,
)
existing_app = (
self._find_existing_app(app_id, target.tenant_id)
if options.id_strategy == IdStrategy.PRESERVE_ID
else None
)
if existing_app is not None and options.conflict_strategy == ConflictStrategy.FAIL:
raise MigrationDataError(f"App already exists and conflict_strategy=fail: {app_id}")
if existing_app is not None and options.conflict_strategy == ConflictStrategy.SKIP:
if app_id:
self._record_id_mappings(
id_mapping,
id_mapping_details,
ResourceType.WORKFLOW,
workflow_data.get("name") if isinstance(workflow_data.get("name"), str) else None,
{app_id},
existing_app.id,
)
report_items.append(
ResourceReportItem(ResourceType.WORKFLOW, str(app_id), workflow_data.get("name"), "skipped")
)
continue
imported_app_id = self._import_workflow_app(
account=account,
workflow_data=workflow_data,
dsl_content=dsl_content,
app_id=app_id,
existing_app=existing_app,
options=options,
)
if app_id:
self._record_id_mappings(
id_mapping,
id_mapping_details,
ResourceType.WORKFLOW,
workflow_data.get("name") if isinstance(workflow_data.get("name"), str) else None,
{app_id},
imported_app_id,
)
if imported_workflow_ids is not None:
imported_workflow_ids.add(app_id)
if options.create_app_api_token_on_import:
self._create_or_reuse_app_api_token(imported_app_id, target.tenant_id)
report_items.append(
ResourceReportItem(
ResourceType.WORKFLOW,
imported_app_id,
workflow_data.get("name"),
"updated" if existing_app is not None else "created",
)
)
def _workflow_tool_source_app_ids(self, package: MigrationPackage) -> set[str]:
app_ids: set[str] = set()
for workflow_tool_data in package.workflow_tools:
app_id = self._optional_string(workflow_tool_data.get("app_id"))
if app_id:
app_ids.add(app_id)
return app_ids
def _import_workflow_app(
self,
*,
account: Account,
workflow_data: dict[str, object],
dsl_content: str,
app_id: str | None,
existing_app: App | None,
options: ImportOptions,
) -> str:
import_service = AppDslService(cast(Session, db.session))
if existing_app is not None:
import_result = import_service.import_app(
account=account,
import_mode="yaml-content",
yaml_content=dsl_content,
app_id=existing_app.id,
)
else:
import_app_id = app_id if self._should_preserve_source_app_id(options) else None
import_result = import_service.import_app(
account=account,
import_mode="yaml-content",
yaml_content=dsl_content,
import_app_id=import_app_id,
)
if import_result.status not in {ImportStatus.COMPLETED, ImportStatus.COMPLETED_WITH_WARNINGS}:
error = import_result.error or f"unexpected import status {import_result.status}"
raise MigrationDataError(f"Workflow import failed: {error}")
if import_result.app_id is None:
raise MigrationDataError(f"Workflow import did not return an app id: {workflow_data.get('name')}")
db.session.commit()
return import_result.app_id
def _rewrite_workflow_dsl_provider_ids(self, dsl_content: str, id_mapping: dict[str, str]) -> str:
if not id_mapping:
return dsl_content
parsed = yaml.safe_load(dsl_content) if dsl_content else {}
if not isinstance(parsed, dict):
return dsl_content
for node in self._workflow_nodes(parsed):
data = node.get("data") if isinstance(node, dict) else None
if not isinstance(data, dict):
continue
self._rewrite_tool_config_provider_id(data, id_mapping)
for tool_config in self._agent_tool_configs(data):
self._rewrite_tool_config_provider_id(tool_config, id_mapping)
return yaml.safe_dump(parsed, sort_keys=False, allow_unicode=True)
def _rewrite_tool_config_provider_id(self, tool_config: dict[str, Any], id_mapping: dict[str, str]) -> None:
provider_id = self._optional_string(tool_config.get("provider_id"))
if provider_id and provider_id in id_mapping:
tool_config["provider_id"] = id_mapping[provider_id]
def _source_api_provider_ids_by_name(self, package: MigrationPackage) -> dict[str, set[str]]:
provider_ids_by_name: dict[str, set[str]] = {}
discovery_service = DependencyDiscoveryService()
for workflow_data in package.workflows:
dsl_content = self._optional_string(workflow_data.get("dsl"))
if not dsl_content:
continue
parsed = yaml.safe_load(dsl_content) if dsl_content else {}
if not isinstance(parsed, dict):
continue
for dependency in discovery_service.discover_from_dsl(parsed):
if dependency.kind != DependencyKind.API_TOOL or not dependency.provider_name:
continue
provider_ids_by_name.setdefault(dependency.provider_name, set()).add(dependency.provider_id)
return provider_ids_by_name
def _workflow_nodes(self, dsl: dict[str, Any]) -> list[dict[str, Any]]:
nodes: list[dict[str, Any]] = []
graph = dsl.get("graph")
if isinstance(graph, dict) and isinstance(graph.get("nodes"), list):
nodes.extend(node for node in graph["nodes"] if isinstance(node, dict))
workflow = dsl.get("workflow")
workflow_graph = workflow.get("graph") if isinstance(workflow, dict) else None
if isinstance(workflow_graph, dict) and isinstance(workflow_graph.get("nodes"), list):
nodes.extend(node for node in workflow_graph["nodes"] if isinstance(node, dict))
return nodes
def _agent_tool_configs(self, data: dict[str, Any]) -> list[dict[str, Any]]:
configs = data.get("tools")
if isinstance(configs, list):
return [config for config in configs if isinstance(config, dict)]
agent_parameters = data.get("agent_parameters")
if not isinstance(agent_parameters, dict):
return []
tools_parameter = agent_parameters.get("tools")
if not isinstance(tools_parameter, dict):
return []
value = tools_parameter.get("value", [])
if not isinstance(value, list):
return []
return [config for config in value if isinstance(config, dict)]
def _should_preserve_source_app_id(self, options: ImportOptions) -> bool:
return options.id_strategy == IdStrategy.PRESERVE_ID
def _find_existing_app(self, app_id: str | None, tenant_id: str) -> App | None:
if not self._is_uuid_string(app_id):
return None
return db.session.scalar(sa.select(App).where(App.id == app_id, App.tenant_id == tenant_id))
def _create_or_reuse_app_api_token(self, app_id: str, tenant_id: str) -> None:
existing = db.session.scalar(
sa.select(ApiToken).where(
ApiToken.type == ApiTokenType.APP,
ApiToken.app_id == app_id,
ApiToken.tenant_id == tenant_id,
)
)
if existing is not None:
return
api_token = ApiToken()
api_token.app_id = app_id
api_token.tenant_id = tenant_id
api_token.token = ApiToken.generate_api_key("app", 24)
api_token.type = ApiTokenType.APP
db.session.add(api_token)
db.session.commit()
def _import_api_tools(
self,
package: MigrationPackage,
target: ImportTarget,
options: ImportOptions,
report_items: list[ResourceReportItem],
id_mapping: dict[str, str],
id_mapping_details: list[ResourceIdMapping],
source_provider_ids_by_name: dict[str, set[str]],
) -> None:
for tool_data in package.tools:
provider_name = self._required_string(tool_data, "provider_name", "api_tool")
schema = self._required_string(tool_data, "schema", "api_tool")
existing = db.session.scalar(
sa.select(ApiToolProvider).where(
ApiToolProvider.tenant_id == target.tenant_id,
ApiToolProvider.name == provider_name,
)
)
if existing is not None and options.conflict_strategy == ConflictStrategy.FAIL:
raise MigrationDataError(f"API tool already exists and conflict_strategy=fail: {provider_name}")
if existing is not None and options.conflict_strategy == ConflictStrategy.SKIP:
self._record_id_mappings(
id_mapping,
id_mapping_details,
ResourceType.API_TOOL,
provider_name,
self._api_tool_source_ids(provider_name, tool_data, source_provider_ids_by_name),
existing.id,
)
report_items.append(ResourceReportItem(ResourceType.API_TOOL, provider_name, provider_name, "skipped"))
continue
schema_info = ApiToolManageService.parser_api_schema(schema=schema)
schema_type = cast(ApiProviderSchemaType, schema_info["schema_type"])
credentials = (
cast(dict[str, Any], tool_data.get("credentials"))
if isinstance(tool_data.get("credentials"), dict)
else {}
)
credentials = credentials or {"auth_type": "none"}
raw_icon = tool_data.get("icon")
icon = (
cast(dict[str, Any], raw_icon)
if isinstance(raw_icon, dict)
else {"content": "tool", "background": "#FEF7C3"}
)
raw_labels = tool_data.get("labels")
labels = [str(label) for label in raw_labels] if isinstance(raw_labels, list) else []
if existing is not None:
ApiToolManageService.update_api_tool_provider(
user_id=target.operator_id,
tenant_id=target.tenant_id,
provider_name=provider_name,
original_provider=existing.name,
_schema_type=schema_type,
schema=schema,
privacy_policy=self._optional_string(tool_data.get("privacy_policy")) or "",
credentials=credentials,
custom_disclaimer=self._optional_string(tool_data.get("custom_disclaimer")) or "",
labels=labels,
icon=icon,
)
status = "updated"
else:
ApiToolManageService.create_api_tool_provider(
user_id=target.operator_id,
tenant_id=target.tenant_id,
provider_name=provider_name,
schema_type=schema_type,
schema=schema,
privacy_policy=self._optional_string(tool_data.get("privacy_policy")) or "",
credentials=credentials,
custom_disclaimer=self._optional_string(tool_data.get("custom_disclaimer")) or "",
labels=labels,
icon=icon,
)
status = "created"
target_provider = self._find_api_tool_provider(target.tenant_id, provider_name)
if target_provider is not None:
self._record_id_mappings(
id_mapping,
id_mapping_details,
ResourceType.API_TOOL,
provider_name,
self._api_tool_source_ids(provider_name, tool_data, source_provider_ids_by_name),
target_provider.id,
)
report_items.append(ResourceReportItem(ResourceType.API_TOOL, provider_name, provider_name, status))
def _find_api_tool_provider(self, tenant_id: str, provider_name: str) -> ApiToolProvider | None:
return db.session.scalar(
sa.select(ApiToolProvider).where(
ApiToolProvider.tenant_id == tenant_id,
ApiToolProvider.name == provider_name,
)
)
def _api_tool_source_ids(
self,
provider_name: str,
tool_data: dict[str, Any],
source_provider_ids_by_name: dict[str, set[str]],
) -> set[str]:
source_ids = set(source_provider_ids_by_name.get(provider_name, set()))
source_id = self._optional_string(tool_data.get("id"))
if source_id:
source_ids.add(source_id)
return source_ids
def _record_id_mappings(
self,
id_mapping: dict[str, str],
id_mapping_details: list[ResourceIdMapping],
resource_type: ResourceType,
name: str | None,
source_ids: Iterable[str],
target_id: str,
) -> None:
for source_id in source_ids:
id_mapping[source_id] = target_id
id_mapping_details[:] = [item for item in id_mapping_details if item.source_id != source_id]
id_mapping_details.append(ResourceIdMapping(resource_type, name, source_id, target_id))
def _import_workflow_tools(
self,
package: MigrationPackage,
target: ImportTarget,
options: ImportOptions,
id_mapping: dict[str, str],
id_mapping_details: list[ResourceIdMapping],
report_items: list[ResourceReportItem],
) -> None:
if not package.workflow_tools:
return
account = db.session.get(Account, target.operator_id)
if account is None:
raise MigrationDataError(f"Operator account not found: {target.operator_id}")
for workflow_tool_data in package.workflow_tools:
app_id = self._optional_string(workflow_tool_data.get("app_id"))
resolved_app_id = id_mapping.get(app_id or "", app_id)
if not resolved_app_id or self._find_existing_app(resolved_app_id, target.tenant_id) is None:
report_items.append(
ResourceReportItem(
ResourceType.WORKFLOW_TOOL,
str(workflow_tool_data.get("id", workflow_tool_data.get("name", ""))),
self._optional_string(workflow_tool_data.get("name")),
"unresolved",
"Referenced workflow app was not found in the target tenant; workflow tool was skipped.",
)
)
continue
try:
self._ensure_workflow_app_is_published(target, account, resolved_app_id)
except Exception as exc:
report_items.append(
ResourceReportItem(
ResourceType.WORKFLOW_TOOL,
str(workflow_tool_data.get("id", workflow_tool_data.get("name", ""))),
self._optional_string(workflow_tool_data.get("name")),
"unresolved",
f"Referenced workflow app could not be published: {exc}",
)
)
continue
workflow_tool_id = self._optional_string(workflow_tool_data.get("id"))
tool_name = self._required_string(workflow_tool_data, "name", "workflow_tool")
lookup_workflow_tool_id = workflow_tool_id if options.id_strategy == IdStrategy.PRESERVE_ID else None
existing = self._find_existing_workflow_tool(
target.tenant_id, lookup_workflow_tool_id, tool_name, resolved_app_id
)
if existing is not None and options.conflict_strategy == ConflictStrategy.FAIL:
raise MigrationDataError(f"Workflow tool already exists and conflict_strategy=fail: {tool_name}")
if existing is not None and options.conflict_strategy == ConflictStrategy.SKIP:
if workflow_tool_id:
self._record_id_mappings(
id_mapping,
id_mapping_details,
ResourceType.WORKFLOW_TOOL,
tool_name,
{workflow_tool_id},
existing.id,
)
report_items.append(ResourceReportItem(ResourceType.WORKFLOW_TOOL, existing.id, tool_name, "skipped"))
continue
raw_icon = workflow_tool_data.get("icon")
icon = (
cast(dict[str, Any], raw_icon)
if isinstance(raw_icon, dict)
else {"content": "🤖", "background": "#FFEAD5"}
)
raw_parameters = workflow_tool_data.get("parameters")
parameters = [
parameter
if isinstance(parameter, WorkflowToolParameterConfiguration)
else WorkflowToolParameterConfiguration(**parameter)
for parameter in (raw_parameters if isinstance(raw_parameters, list) else [])
if isinstance(parameter, dict | WorkflowToolParameterConfiguration)
]
raw_labels = workflow_tool_data.get("labels")
labels = [str(label) for label in raw_labels] if isinstance(raw_labels, list) else []
label = self._optional_string(workflow_tool_data.get("label")) or tool_name
description = self._optional_string(workflow_tool_data.get("description")) or ""
privacy_policy = self._optional_string(workflow_tool_data.get("privacy_policy")) or ""
if existing is not None:
WorkflowToolManageService.update_workflow_tool(
user_id=account.id,
tenant_id=target.tenant_id,
workflow_tool_id=existing.id,
name=tool_name,
label=label,
icon=icon,
description=description,
parameters=parameters,
privacy_policy=privacy_policy,
labels=labels,
)
status = "updated"
identifier = existing.id
else:
import_id = workflow_tool_id if options.id_strategy == IdStrategy.PRESERVE_ID else ""
WorkflowToolManageService.create_workflow_tool(
user_id=account.id,
tenant_id=target.tenant_id,
workflow_app_id=resolved_app_id,
name=tool_name,
label=label,
icon=icon,
description=description,
parameters=parameters,
privacy_policy=privacy_policy,
labels=labels,
import_id=import_id or "",
)
status = "created"
target_provider = self._find_existing_workflow_tool(
target.tenant_id, import_id or None, tool_name, resolved_app_id
)
if target_provider is None:
raise MigrationDataError(f"Workflow tool was not created: {tool_name}")
identifier = target_provider.id
if workflow_tool_id:
self._record_id_mappings(
id_mapping,
id_mapping_details,
ResourceType.WORKFLOW_TOOL,
tool_name,
{workflow_tool_id},
identifier,
)
report_items.append(ResourceReportItem(ResourceType.WORKFLOW_TOOL, identifier, tool_name, status))
def _ensure_workflow_app_is_published(self, target: ImportTarget, account: Account, app_id: str) -> None:
app = self._find_existing_app(app_id, target.tenant_id)
if app is None:
raise MigrationDataError(f"Referenced workflow app was not found in target tenant: {app_id}")
if app.workflow_id:
return
workflow_service = WorkflowService()
with sessionmaker(db.engine).begin() as session:
app_in_session = session.get(App, app_id)
account_in_session = session.get(Account, account.id)
if app_in_session is None:
raise MigrationDataError(f"Referenced workflow app was not found in target tenant: {app_id}")
if account_in_session is None:
raise MigrationDataError(f"Operator account not found: {account.id}")
workflow = workflow_service.publish_workflow(
session=session,
app_model=app_in_session,
account=account_in_session,
marked_name="Migration import",
marked_comment="Published automatically for workflow tool import.",
)
app_in_session.workflow_id = workflow.id
app_in_session.updated_by = account.id
app_in_session.updated_at = naive_utc_now()
def _import_mcp_tools(
self,
package: MigrationPackage,
target: ImportTarget,
options: ImportOptions,
report_items: list[ResourceReportItem],
id_mapping: dict[str, str],
id_mapping_details: list[ResourceIdMapping],
) -> None:
for mcp_data in package.mcp_tools:
name = self._required_string(mcp_data, "name", "mcp_tool")
server_identifier = self._required_string(mcp_data, "server_identifier", "mcp_tool")
provider_id = self._optional_string(mcp_data.get("id"))
lookup_provider_id = provider_id if options.id_strategy == IdStrategy.PRESERVE_ID else None
existing = self._find_existing_mcp_tool(target.tenant_id, lookup_provider_id, server_identifier)
if existing is not None and options.conflict_strategy == ConflictStrategy.FAIL:
raise MigrationDataError(f"MCP tool already exists and conflict_strategy=fail: {name}")
if existing is not None and options.conflict_strategy == ConflictStrategy.SKIP:
if provider_id:
self._record_id_mappings(
id_mapping,
id_mapping_details,
ResourceType.MCP_TOOL,
name,
{provider_id},
existing.id,
)
report_items.append(ResourceReportItem(ResourceType.MCP_TOOL, existing.id, name, "skipped"))
continue
service = MCPToolManageService(session=cast(Session, db.session))
configuration = MCPConfiguration.model_validate(mcp_data.get("configuration") or {})
authentication = (
MCPAuthentication.model_validate(mcp_data["authentication"]) if mcp_data.get("authentication") else None
)
if existing is not None:
service.update_provider(
tenant_id=target.tenant_id,
provider_id=existing.id,
server_url=self._required_string(mcp_data, "server_url", "mcp_tool"),
name=name,
icon=self._optional_string(mcp_data.get("icon")) or "",
icon_type=self._optional_string(mcp_data.get("icon_type")) or "emoji",
icon_background=self._optional_string(mcp_data.get("icon_background")) or "",
server_identifier=server_identifier,
headers=mcp_data.get("headers") if isinstance(mcp_data.get("headers"), dict) else {},
configuration=configuration,
authentication=authentication,
)
db.session.commit()
status = "updated"
identifier = existing.id
provider = existing
else:
service.create_provider(
tenant_id=target.tenant_id,
user_id=target.operator_id,
server_url=self._required_string(mcp_data, "server_url", "mcp_tool"),
name=name,
icon=self._optional_string(mcp_data.get("icon")) or "",
icon_type=self._optional_string(mcp_data.get("icon_type")) or "emoji",
icon_background=self._optional_string(mcp_data.get("icon_background")) or "",
server_identifier=server_identifier,
headers=mcp_data.get("headers") if isinstance(mcp_data.get("headers"), dict) else {},
configuration=configuration,
authentication=authentication,
)
created_provider = self._find_existing_mcp_tool(target.tenant_id, lookup_provider_id, server_identifier)
if created_provider is None:
raise MigrationDataError(f"MCP provider was not created: {name}")
status = "created"
provider = created_provider
identifier = provider.id
self._restore_mcp_provider_tools(provider, mcp_data)
db.session.commit()
if provider_id:
self._record_id_mappings(
id_mapping,
id_mapping_details,
ResourceType.MCP_TOOL,
name,
{provider_id},
identifier,
)
report_items.append(ResourceReportItem(ResourceType.MCP_TOOL, identifier, name, status))
def _restore_mcp_provider_tools(self, provider: MCPToolProvider, mcp_data: dict[str, object]) -> None:
tools = mcp_data.get("tools")
if not isinstance(tools, list):
return
provider.tools = json.dumps(tools)
provider.authed = True
def _find_existing_mcp_tool(
self, tenant_id: str, provider_id: str | None, server_identifier: str
) -> MCPToolProvider | None:
predicates = [MCPToolProvider.server_identifier == server_identifier]
if self._is_uuid_string(provider_id):
predicates.append(MCPToolProvider.id == provider_id)
return db.session.scalar(
sa.select(MCPToolProvider).where(MCPToolProvider.tenant_id == tenant_id, or_(*predicates)).limit(1)
)
def _is_uuid_string(self, value: str | None) -> bool:
if not value:
return False
try:
UUID(value)
except ValueError:
return False
return True
def _find_existing_workflow_tool(
self, tenant_id: str, workflow_tool_id: str | None, tool_name: str, app_id: str
) -> WorkflowToolProvider | None:
predicates = [WorkflowToolProvider.name == tool_name, WorkflowToolProvider.app_id == app_id]
if self._is_uuid_string(workflow_tool_id):
predicates.append(WorkflowToolProvider.id == workflow_tool_id)
return db.session.scalar(
sa.select(WorkflowToolProvider)
.where(WorkflowToolProvider.tenant_id == tenant_id, or_(*predicates))
.limit(1)
)
def _preflight_dependency_only_mcp(
self, package: MigrationPackage, target: ImportTarget, report_items: list[ResourceReportItem]
) -> None:
for dependency in package.dependencies:
if dependency.get("kind") != DependencyKind.MCP_TOOL.value:
continue
provider_id = str(dependency.get("provider_id", dependency.get("id", "")))
provider_name = self._optional_string(dependency.get("provider_name") or dependency.get("name"))
existing = self._find_dependency_only_mcp_provider(target.tenant_id, provider_id, provider_name)
report_name = f"mcp_tool {provider_name or getattr(existing, 'name', None) or provider_id}"
if existing is not None:
report_items.append(
ResourceReportItem(
ResourceType.DEPENDENCY,
provider_id,
report_name,
"available",
"MCP provider exists in target tenant.",
)
)
continue
reference_summary = self._dependency_only_mcp_reference_summary(package, provider_id, provider_name)
message = "missing in target tenant"
if reference_summary:
message = f"{message}; referenced by {reference_summary}"
message = f"{message}; configure it manually before running the workflow."
report_items.append(
ResourceReportItem(
ResourceType.DEPENDENCY,
provider_id,
report_name,
"skipped",
message,
)
)
def _find_dependency_only_mcp_provider(
self, tenant_id: str, provider_id: str, provider_name: str | None
) -> MCPToolProvider | None:
predicates = [MCPToolProvider.server_identifier == provider_id]
if self._is_uuid_string(provider_id):
predicates.append(MCPToolProvider.id == provider_id)
return db.session.scalar(
sa.select(MCPToolProvider).where(MCPToolProvider.tenant_id == tenant_id, or_(*predicates)).limit(1)
)
def _dependency_only_mcp_reference_summary(
self, package: MigrationPackage, provider_id: str, provider_name: str | None
) -> str:
references = self._dependency_only_mcp_references(package, provider_id, provider_name)
return "; ".join(references)
def _dependency_only_mcp_references(
self, package: MigrationPackage, provider_id: str, provider_name: str | None
) -> list[str]:
references: list[str] = []
seen: set[str] = set()
for workflow_data in package.workflows:
workflow_name = self._optional_string(workflow_data.get("name"))
workflow_id = self._optional_string(workflow_data.get("id"))
workflow_label = workflow_name or workflow_id or "unknown workflow"
dsl_content = self._optional_string(workflow_data.get("dsl"))
if not dsl_content:
continue
parsed = yaml.safe_load(dsl_content) if dsl_content else {}
if not isinstance(parsed, dict):
continue
for node in self._workflow_nodes(parsed):
data = node.get("data") if isinstance(node, dict) else None
if not isinstance(data, dict):
continue
for tool_config in [data, *self._agent_tool_configs(data)]:
if not self._is_mcp_dependency_reference(tool_config, provider_id, provider_name):
continue
tool_label = self._mcp_tool_reference_label(node, tool_config)
reference = f"{workflow_label} / {tool_label}"
if reference not in seen:
seen.add(reference)
references.append(reference)
return references
def _is_mcp_dependency_reference(
self, tool_config: dict[str, Any], provider_id: str, provider_name: str | None
) -> bool:
provider_type = str(tool_config.get("provider_type") or tool_config.get("type") or "").lower()
if provider_type != "mcp":
return False
config_provider_id = self._optional_string(
tool_config.get("provider_id") or tool_config.get("provider_name") or tool_config.get("provider")
)
if config_provider_id == provider_id:
return True
return bool(provider_name and config_provider_id == provider_name)
def _mcp_tool_reference_label(self, node: dict[str, Any], tool_config: dict[str, Any]) -> str:
for key in ("tool_name", "tool", "name"):
value = self._optional_string(tool_config.get(key))
if value:
return value
node_id = self._optional_string(node.get("id"))
return node_id or "unknown tool"
def _required_string(self, value: dict[str, object], field_name: str, resource_name: str) -> str:
field_value = value.get(field_name)
if not isinstance(field_value, str) or not field_value:
raise MigrationDataError(f"Missing required {resource_name} field: {field_name}")
return field_value
def _optional_string(self, value: object) -> str | None:
if isinstance(value, str) and value:
return value
return None

View File

@ -0,0 +1,71 @@
"""JSON persistence for versioned cross-environment migration packages.
The package service validates file shape and serializes only structured package
entities. It does not perform CLI rendering or database access, keeping it safe
to reuse from Click adapters, tests, and future import/export services.
"""
from __future__ import annotations
import json
from dataclasses import asdict
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from services.data_migration.entities import (
ImportOptions,
MigrationDataError,
MigrationMetadata,
MigrationPackage,
SourceTenant,
TargetTenantSelector,
)
PACKAGE_VERSION = "1"
class MigrationPackageService:
def load_package(self, path: str | Path) -> MigrationPackage:
package_path = Path(path)
with package_path.open(encoding="utf-8") as file:
raw = json.load(file)
if not isinstance(raw, dict):
raise MigrationDataError("Migration package JSON must be an object.")
package = MigrationPackage.from_mapping(raw)
if package.metadata.version != PACKAGE_VERSION:
raise MigrationDataError(f"Unsupported migration package version: {package.metadata.version}")
return package
def save_package(self, package: MigrationPackage, path: str | Path, *, overwrite: bool) -> None:
package_path = Path(path)
if package_path.exists() and not overwrite:
raise MigrationDataError(f"Output file already exists: {package_path}")
package_path.parent.mkdir(parents=True, exist_ok=True)
with package_path.open("w", encoding="utf-8") as file:
json.dump(self.to_mapping(package), file, ensure_ascii=False, indent=2)
file.write("\n")
def build_empty_package(
self,
*,
source_tenant_id: str,
source_tenant_name: str,
include_secrets: bool,
import_options: ImportOptions | None = None,
target_tenant: TargetTenantSelector | None = None,
) -> MigrationPackage:
return MigrationPackage(
metadata=MigrationMetadata(
version=PACKAGE_VERSION,
source_scope="single",
source_tenants=[SourceTenant(id=source_tenant_id, name=source_tenant_name)],
target_tenant=target_tenant,
created_at=datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
include_secrets=include_secrets,
import_options=import_options or ImportOptions(),
)
)
def to_mapping(self, package: MigrationPackage) -> dict[str, Any]:
return asdict(package)

View File

@ -0,0 +1,76 @@
from __future__ import annotations
from collections import Counter
from services.data_migration.entities import ReportContext, ResourceIdMapping, ResourceReportItem
class MigrationReportService:
"""Render structured migration resource results into CLI-friendly summary lines."""
def render(self, items: list[ResourceReportItem], *, context: ReportContext | None = None) -> list[str]:
counts = Counter((item.resource_type.value, item.status) for item in items)
lines = self._render_context(context)
lines.extend(
[f"{resource_type} {status}: {count}" for (resource_type, status), count in sorted(counts.items())]
)
actionable_items = [
item for item in items if item.status in {"dependency-only", "skipped", "unresolved"} and item.message
]
for item in actionable_items:
lines.append(self._render_actionable_detail(item))
return lines
def _render_context(self, context: ReportContext | None) -> list[str]:
if context is None:
return []
lines: list[str] = []
if context.output_path:
lines.append(f"output: {context.output_path}")
if context.source_scope:
lines.append(f"source scope: {context.source_scope}")
if context.selected_app_count is not None:
lines.append(f"selected apps: {context.selected_app_count}")
if context.include_secrets is not None:
lines.append(f"include secrets: {str(context.include_secrets).lower()}")
if context.target_tenant:
lines.append(f"target tenant: {context.target_tenant}")
if context.operator_email:
lines.append(f"operator: {context.operator_email}")
if context.app_api_tokens_created or context.app_api_tokens_reused:
lines.append(
f"app api tokens: {context.app_api_tokens_created} created, {context.app_api_tokens_reused} reused"
)
if context.id_mappings:
lines.append(f"resource references resolved: {len(context.id_mappings)}")
if context.id_mapping_details:
lines.extend(
self._render_id_mapping_detail(item)
for item in sorted(
context.id_mapping_details,
key=lambda item: (item.resource_type.value, item.name or "", item.source_id),
)
)
else:
lines.extend(
f"- {source_id} -> {target_id}" for source_id, target_id in sorted(context.id_mappings.items())
)
elif context.id_mapping_count:
lines.append(f"resource references resolved: {context.id_mapping_count}")
return lines
def _render_id_mapping_detail(self, item: ResourceIdMapping) -> str:
label = item.resource_type.value
if item.name:
label = f"{label} {item.name}"
return f"- {label}: {item.source_id} -> {item.target_id}"
def _render_actionable_detail(self, item: ResourceReportItem) -> str:
if item.resource_type.value == "dependency" and item.name and self._has_dependency_type_prefix(item.name):
if item.identifier and item.identifier not in item.name:
return f"dependency {item.name}: {item.identifier}: {item.message}"
return f"dependency {item.name}: {item.message}"
return f"{item.resource_type.value} {item.identifier}: {item.message}"
def _has_dependency_type_prefix(self, name: str) -> bool:
return name.startswith(("workflow ", "api_tool ", "workflow_tool ", "mcp_tool ", "builtin_or_plugin_tool "))

View File

@ -41,6 +41,7 @@ class WorkflowToolManageService:
parameters: list[WorkflowToolParameterConfiguration],
privacy_policy: str = "",
labels: list[str] | None = None,
import_id: str = "",
):
# check if the name is unique
existing_workflow_tool_provider: WorkflowToolProvider | None = None
@ -92,7 +93,8 @@ class WorkflowToolManageService:
privacy_policy=privacy_policy,
version=workflow.version,
)
if import_id:
workflow_tool_provider.id = import_id
try:
WorkflowToolProviderController.from_db(workflow_tool_provider)
except Exception as e:

View File

@ -0,0 +1,71 @@
import json
from click.testing import CliRunner
from commands.data_migration import (
ID_STRATEGY_CHOICES,
export_migration_data,
export_migration_data_template,
import_migration_data,
)
def test_export_command_requires_input_and_output():
result = CliRunner().invoke(export_migration_data, [])
assert result.exit_code != 0
assert export_migration_data.name == "export-app-migration"
assert "--input" in result.output
assert "--output" in result.output
def test_import_command_requires_input_and_target_tenant_or_package_metadata():
result = CliRunner().invoke(import_migration_data, [])
assert result.exit_code != 0
assert import_migration_data.name == "import-app-migration"
assert "--input" in result.output
def test_import_command_does_not_expose_unimplemented_map_id_strategy():
assert ID_STRATEGY_CHOICES == ["preserve-id", "generate-new-id"]
def test_export_template_command_prints_scripted_json_template():
result = CliRunner().invoke(export_migration_data_template, [])
assert result.exit_code == 0
assert export_migration_data_template.name == "app-migration-template"
template = json.loads(result.output)
assert template == {
"source_tenant": {"mode": "single", "id": "", "name": "admin's Workspace"},
"apps": {"modes": ["workflow", "advanced-chat"], "ids": [], "all": True},
"include_referenced_tools": True,
"additional_tools": {"api_tools": [], "workflow_tools": [], "mcp_tools": []},
"include_secrets": False,
"import_options": {
"create_app_api_token_on_import": False,
"id_strategy": "preserve-id",
"conflict_strategy": "fail",
},
}
def test_export_template_command_writes_output_file(tmp_path):
output_file = tmp_path / "export-template.json"
result = CliRunner().invoke(export_migration_data_template, ["--output", str(output_file)])
assert result.exit_code == 0
assert f"Output written to {output_file}" in result.output
assert json.loads(output_file.read_text())["apps"]["all"] is True
def test_export_template_command_requires_overwrite_for_existing_output(tmp_path):
output_file = tmp_path / "export-template.json"
output_file.write_text("{}")
result = CliRunner().invoke(export_migration_data_template, ["--output", str(output_file)])
assert result.exit_code != 0
assert "already exists" in result.output

View File

@ -0,0 +1,384 @@
from commands.data_migration import (
CONFLICT_STRATEGY_CHOICES,
ID_STRATEGY_CHOICES,
_confirm_wizard_summary,
_print_auto_tools,
_print_final_tool_selection,
_print_wizard_step,
_prompt_additional_tools,
_prompt_output_file,
_prompt_tool_category,
_resolve_mcp_tool_names,
migration_data_wizard,
parse_index_selection,
)
def test_parse_index_selection_supports_all():
assert parse_index_selection("all", ["a", "b", "c"]) == ["a", "b", "c"]
def test_wizard_command_uses_app_migration_name():
assert migration_data_wizard.name == "app-migration-wizard"
def test_parse_index_selection_supports_comma_indexes():
assert parse_index_selection("1, 3", ["a", "b", "c"]) == ["a", "c"]
def test_print_wizard_step_adds_separator(monkeypatch):
output_lines = []
monkeypatch.setattr("commands.data_migration.click.echo", output_lines.append)
_print_wizard_step("App Selection")
assert output_lines == ["", "==== App Selection ===="]
def test_conflict_strategy_choices_exclude_replace():
assert CONFLICT_STRATEGY_CHOICES == ["fail", "skip", "update"]
def test_prompt_app_ids_explains_comma_selection_and_default(monkeypatch):
from commands.data_migration import _prompt_app_ids
prompts = []
output_lines = []
apps = [
type("App", (), {"id": "app-1", "name": "embedded", "mode": "workflow"})(),
type("App", (), {"id": "app-2", "name": "main", "mode": "advanced-chat"})(),
]
def capture_prompt(text, **kwargs):
prompts.append((text, kwargs))
return "1,2"
monkeypatch.setattr("commands.data_migration.click.echo", output_lines.append)
monkeypatch.setattr("commands.data_migration.click.prompt", capture_prompt)
assert _prompt_app_ids(apps) == ["app-1", "app-2"]
assert prompts == [("Select apps by number, comma-separated numbers, or all", {"default": "all"})]
assert "Currently supported app types: workflow and chatflow." in output_lines
def test_prompt_tool_category_marks_auto_discovered_tools(monkeypatch):
output_lines = []
monkeypatch.setattr("commands.data_migration.click.echo", output_lines.append)
monkeypatch.setattr("commands.data_migration.click.prompt", lambda *args, **kwargs: "")
selected = _prompt_tool_category(
"Custom API tools",
[("weather", "weather", "tool-id"), ("calendar", "calendar", "calendar-id")],
auto_tools={"weather": "tool-id"},
)
assert selected == []
assert "1. [auto] weather (tool-id)" in output_lines
assert "2. [ ] calendar (calendar-id)" in output_lines
assert output_lines[:2] == ["", "==== Custom API tools ===="]
def test_prompt_tool_category_explains_comma_selection_and_default(monkeypatch):
prompts = []
def capture_prompt(text, **kwargs):
prompts.append((text, kwargs))
return ""
monkeypatch.setattr("commands.data_migration.click.echo", lambda *_args, **_kwargs: None)
monkeypatch.setattr("commands.data_migration.click.prompt", capture_prompt)
selected = _prompt_tool_category(
"Custom API tools",
[("weather", "weather", "tool-id")],
auto_tools={},
)
assert selected == []
assert prompts == [
(
"Select custom api tools by number, comma-separated numbers, all, or empty",
{"default": "", "show_default": "empty"},
)
]
def test_prompt_output_file_shows_default(monkeypatch):
prompts = []
def capture_prompt(text, **kwargs):
prompts.append((text, kwargs))
return "migration-data.json"
monkeypatch.setattr("commands.data_migration.click.prompt", capture_prompt)
assert _prompt_output_file() == ("migration-data.json", False)
assert prompts[0][0] == "Output path"
assert prompts[0][1]["show_default"] is True
def test_prompt_tool_category_marks_auto_by_detail_and_supports_multi_select(monkeypatch):
monkeypatch.setattr("commands.data_migration.click.echo", lambda *_args, **_kwargs: None)
monkeypatch.setattr("commands.data_migration.click.prompt", lambda *args, **kwargs: "1,2")
selected = _prompt_tool_category(
"Workflow tools",
[("tool-1", "embedded", "app-1"), ("tool-2", "other", "app-2")],
auto_tools={"embedded": "app-1"},
)
assert selected == ["tool-1", "tool-2"]
def test_prompt_tool_category_marks_auto_by_value():
output_lines = []
from commands import data_migration
original_echo = data_migration.click.echo
original_prompt = data_migration.click.prompt
data_migration.click.echo = output_lines.append
data_migration.click.prompt = lambda *args, **kwargs: ""
try:
_prompt_tool_category(
"Workflow tools",
[("tool-1", "embedded_workflow_as_tool", "tool-1")],
auto_tools={"embedded_workflow_as_tool": "tool-1"},
)
finally:
data_migration.click.echo = original_echo
data_migration.click.prompt = original_prompt
assert "1. [auto] embedded_workflow_as_tool (tool-1)" in output_lines
def test_print_auto_tools_lists_each_category(monkeypatch):
output_lines = []
monkeypatch.setattr("commands.data_migration.click.echo", output_lines.append)
_print_auto_tools(
{
"api_tools": {"weather": "3bac3aa9-dd87-4351-9459-a7099137b028"},
"workflow_tools": {"embedded_workflow_as_tool": "e6024578-41b7-4fb5-a81f-9201358e5835"},
"mcp_tools": {},
}
)
assert "Automatically discovered tools:" in output_lines
assert "Custom API tools" in output_lines
assert "- weather: 3bac3aa9-dd87-4351-9459-a7099137b028" in output_lines
assert "Workflow tools" in output_lines
assert "- embedded_workflow_as_tool: e6024578-41b7-4fb5-a81f-9201358e5835" in output_lines
assert "MCP tools" in output_lines
assert "- none" in output_lines
def test_resolve_mcp_tool_names_does_not_compare_non_uuid_identifier_to_uuid_id(monkeypatch):
statements = []
def capture_scalar(statement):
statements.append(str(statement))
monkeypatch.setattr("commands.data_migration.db.session.scalar", capture_scalar)
assert _resolve_mcp_tool_names("49a99e46-bc2c-4885-91fa-47615f6192b5", {"my-test-mcp": "my-test-mcp"}) == {
"my-test-mcp": "my-test-mcp"
}
assert "tool_mcp_providers.id =" not in statements[0]
assert "tool_mcp_providers.server_identifier =" in statements[0]
def test_prompt_additional_tools_prints_final_selection_when_skipped(monkeypatch):
output_lines = []
confirm_prompts = []
def capture_confirm(prompt, **kwargs):
confirm_prompts.append((prompt, kwargs))
return False
monkeypatch.setattr("commands.data_migration.click.confirm", capture_confirm)
monkeypatch.setattr("commands.data_migration.click.echo", output_lines.append)
selected = _prompt_additional_tools(
"tenant-id",
{
"api_tools": {"weather": "3bac3aa9-dd87-4351-9459-a7099137b028"},
"workflow_tools": {},
"mcp_tools": {},
},
)
assert selected == {"api_tools": [], "workflow_tools": [], "mcp_tools": []}
assert confirm_prompts == [
("Export additional tools manually? [y/n, default: n]", {"default": False, "show_default": False})
]
assert "Final tools to export:" in output_lines
assert "- [auto] weather: 3bac3aa9-dd87-4351-9459-a7099137b028" in output_lines
def test_final_tool_selection_deduplicates_manual_tool_already_auto(monkeypatch):
output_lines = []
monkeypatch.setattr("commands.data_migration.click.echo", output_lines.append)
_print_final_tool_selection(
{
"api_tools": {},
"workflow_tools": {"embedded_workflow_as_tool": "e6024578-41b7-4fb5-a81f-9201358e5835"},
"mcp_tools": {},
},
{
"api_tools": [],
"workflow_tools": ["e6024578-41b7-4fb5-a81f-9201358e5835"],
"mcp_tools": [],
},
{"e6024578-41b7-4fb5-a81f-9201358e5835": "embedded_workflow_as_tool: e6024578"},
)
assert "- [auto] embedded_workflow_as_tool: e6024578-41b7-4fb5-a81f-9201358e5835" in output_lines
assert not any(line.startswith("- [manual]") for line in output_lines)
def test_prompt_output_file_rejects_yes_no_typo(monkeypatch):
import click
import pytest
monkeypatch.setattr("commands.data_migration.click.prompt", lambda *args, **kwargs: "y")
with pytest.raises(click.ClickException, match="Output path must be a file path"):
_prompt_output_file()
def test_confirm_wizard_summary_shows_conflict_strategy(monkeypatch):
output_lines = []
confirm_prompts = []
monkeypatch.setattr("commands.data_migration.click.echo", output_lines.append)
monkeypatch.setattr(
"commands.data_migration.click.confirm",
lambda prompt, **kwargs: confirm_prompts.append((prompt, kwargs)) or True,
)
_confirm_wizard_summary(
tenant_name="admin's Workspace",
app_names=["main_chatflow"],
auto_tools={"api_tools": {}, "workflow_tools": {}, "mcp_tools": {}},
additional_tools={"api_tools": [], "workflow_tools": [], "mcp_tools": []},
manual_labels={},
include_referenced_tools=True,
include_secrets=False,
create_tokens=True,
id_strategy="preserve-id",
conflict_strategy="fail",
output_file="migration-data.json",
)
assert "id strategy: preserve-id" in output_lines
assert "conflict strategy: fail" in output_lines
assert confirm_prompts == [("Write migration package? [y/n, default: y]", {"default": True, "show_default": False})]
def test_confirm_wizard_summary_shows_final_deduplicated_tool_selection(monkeypatch):
output_lines = []
monkeypatch.setattr("commands.data_migration.click.echo", output_lines.append)
monkeypatch.setattr("commands.data_migration.click.confirm", lambda *args, **kwargs: True)
_confirm_wizard_summary(
tenant_name="admin's Workspace",
app_names=["main_chatflow"],
auto_tools={
"api_tools": {"weather": "weather-id"},
"workflow_tools": {"embedded_workflow_as_tool": "workflow-tool-id"},
"mcp_tools": {},
},
additional_tools={
"api_tools": ["weather-id", "calendar"],
"workflow_tools": [],
"mcp_tools": ["mcp-id"],
},
manual_labels={
"calendar": "calendar: calendar-id",
"mcp-id": "my-test-mcp: mcp-id",
},
include_referenced_tools=True,
include_secrets=False,
create_tokens=False,
id_strategy="preserve-id",
conflict_strategy="update",
output_file="migration-data.json",
)
assert "Final tools to export:" in output_lines
assert "Custom API tools" in output_lines
assert "- [auto] weather: weather-id" in output_lines
assert "- [manual] calendar: calendar-id" in output_lines
assert "Workflow tools" in output_lines
assert "- [auto] embedded_workflow_as_tool: workflow-tool-id" in output_lines
assert "MCP tools" in output_lines
assert "- [manual] my-test-mcp: mcp-id" in output_lines
assert not any(line.startswith("additional api tools:") for line in output_lines)
assert not any(line.startswith("additional workflow tools:") for line in output_lines)
assert not any(line.startswith("additional mcp tools:") for line in output_lines)
assert "- [manual] weather-id" not in output_lines
def test_import_options_prompts_explain_secrets_reuse_and_conflicts(monkeypatch):
from commands.data_migration import _prompt_import_options
output_lines = []
confirm_prompts = []
prompt_calls = []
def capture_confirm(prompt, **kwargs):
confirm_prompts.append((prompt, kwargs))
return False
def capture_prompt(prompt, **kwargs):
prompt_calls.append((prompt, kwargs))
return kwargs["default"]
monkeypatch.setattr("commands.data_migration.click.echo", output_lines.append)
monkeypatch.setattr("commands.data_migration.click.confirm", capture_confirm)
monkeypatch.setattr("commands.data_migration.click.prompt", capture_prompt)
include_secrets, create_tokens, id_strategy, conflict_strategy = _prompt_import_options()
assert include_secrets is False
assert create_tokens is False
assert id_strategy == "preserve-id"
assert conflict_strategy == "update"
assert "Secrets include workflow/app DSL secret values, custom API tool credentials," in output_lines
assert "-- Secrets --" in output_lines
assert "If you choose no, credentials are omitted or masked," in output_lines
assert "-- App API Tokens --" in output_lines
assert "When enabled, import will create an app API token if the imported app has none," in output_lines
assert "or reuse an existing app API token if one already exists." in output_lines
assert "-- ID Strategy --" in output_lines
assert "ID strategy controls whether imported app and tool IDs preserve source IDs" in output_lines
assert "or use target-generated IDs." in output_lines
assert "preserve-id: keep source IDs where the target service supports it." in output_lines
assert (
"generate-new-id: let the target environment generate new IDs and rewrite references via mapping."
in output_lines
)
assert "-- Conflict Strategy --" in output_lines
assert "Conflict strategy controls what import does when a target resource already exists." in output_lines
assert "fail: stop at the first conflict; previously committed resources are not rolled back." in output_lines
assert "skip: keep the existing target resource and skip importing that resource." in output_lines
assert "update: update the existing target resource in place." in output_lines
assert confirm_prompts == [
("Include secrets in output JSON? [y/n, default: n]", {"default": False, "show_default": False}),
("Create or reuse app API tokens during import? [y/n, default: n]", {"default": False, "show_default": False}),
]
assert prompt_calls[0][0] == "Import ID strategy. Enter one of: preserve-id, generate-new-id"
assert prompt_calls[0][1]["default"] == "preserve-id"
assert prompt_calls[0][1]["show_default"] is True
assert prompt_calls[0][1]["type"].choices == ID_STRATEGY_CHOICES
assert prompt_calls[1][0] == "Import conflict strategy. Enter one of: fail, skip, update"
assert prompt_calls[1][1]["default"] == "update"
assert prompt_calls[1][1]["show_default"] is True
assert prompt_calls[1][1]["type"].choices == CONFLICT_STRATEGY_CHOICES

View File

@ -1036,6 +1036,48 @@ class TestSegmentListAdvancedCases:
assert status == 200
assert response["total"] == 1
def test_segment_list_postgres_keyword_filter_handles_scalar_keywords(self, app: Flask):
api = DatasetDocumentSegmentListApi()
method = unwrap(api.get)
dataset = MagicMock()
document = MagicMock()
pagination = MagicMock(items=[], total=0, pages=0)
with (
app.test_request_context("/?keyword=test"),
patch(
"controllers.console.datasets.datasets_segments.current_account_with_tenant",
return_value=(MagicMock(), "11111111-1111-1111-1111-111111111111"),
),
patch(
"controllers.console.datasets.datasets_segments.DatasetService.get_dataset",
return_value=dataset,
),
patch(
"controllers.console.datasets.datasets_segments.DatasetService.check_dataset_permission",
return_value=None,
),
patch(
"controllers.console.datasets.datasets_segments.DocumentService.get_document",
return_value=document,
),
patch(
"controllers.console.datasets.datasets_segments.dify_config",
SimpleNamespace(SQLALCHEMY_DATABASE_URI_SCHEME="postgresql"),
),
patch(
"controllers.console.datasets.datasets_segments.db.paginate",
return_value=pagination,
) as paginate_mock,
):
method(api, "22222222-2222-2222-2222-222222222222", "33333333-3333-3333-3333-333333333333")
query = paginate_mock.call_args.kwargs["select"]
sql = str(query.compile(compile_kwargs={"literal_binds": True}))
assert "jsonb_array_elements_text(CASE" in sql
assert "ELSE CAST('[]' AS JSONB)" in sql
def test_segment_list_permission_denied(self, app: Flask):
"""Test segment list with permission denied"""
api = DatasetDocumentSegmentListApi()

View File

@ -0,0 +1,42 @@
import logging
from types import SimpleNamespace
from core.tools.errors import ToolProviderNotFoundError
from events.event_handlers import delete_tool_parameters_cache_when_sync_draft_workflow as handler_module
def test_missing_tool_provider_does_not_log_error_traceback(monkeypatch, caplog):
app = SimpleNamespace(id="workflow-id", tenant_id="tenant-id")
workflow = SimpleNamespace(
graph_dict={
"nodes": [
{
"id": "node-id",
"data": {
"type": "tool",
},
}
]
}
)
tool_entity = SimpleNamespace(
provider_type=SimpleNamespace(value="mcp"),
provider_id="my-test-mcp-server",
provider_name="my-test-mcp-server",
tool_name="echo",
credential_id=None,
)
monkeypatch.setattr(handler_module, "adapt_node_config_for_graph", lambda node_data: {"data": node_data["data"]})
monkeypatch.setattr(handler_module.ToolEntity, "model_validate", lambda data: tool_entity)
monkeypatch.setattr(
handler_module.ToolManager,
"get_tool_runtime",
lambda **kwargs: (_ for _ in ()).throw(ToolProviderNotFoundError("mcp provider not found")),
)
with caplog.at_level(logging.INFO, logger=handler_module.logger.name):
handler_module.handle(app, synced_draft_workflow=workflow)
assert not [record for record in caplog.records if record.levelno >= logging.ERROR]
assert "Skipped deleting tool parameters cache" in caplog.text

View File

@ -0,0 +1,115 @@
from services.data_migration.dependency_discovery_service import DependencyDiscoveryService
from services.data_migration.entities import DependencyKind
def test_discovers_and_deduplicates_standalone_tool_nodes():
graph = {
"graph": {
"nodes": [
{"data": {"type": "tool", "provider_type": "api", "provider_id": "weather"}},
{"data": {"type": "tool", "provider_type": "api", "provider_id": "weather"}},
{"data": {"type": "tool", "provider_type": "workflow", "provider_id": "wf-tool-1"}},
{"data": {"type": "tool", "provider_type": "builtin", "provider_id": "google_search"}},
]
}
}
dependencies = DependencyDiscoveryService().discover_from_dsl(graph)
assert [(item.kind, item.provider_id) for item in dependencies] == [
(DependencyKind.API_TOOL, "weather"),
(DependencyKind.WORKFLOW_TOOL, "wf-tool-1"),
(DependencyKind.BUILTIN_OR_PLUGIN_TOOL, "google_search"),
]
def test_discovers_agent_node_tools():
graph = {
"graph": {
"nodes": [
{
"data": {
"type": "agent",
"tools": [
{"provider_type": "mcp", "provider_id": "mcp-1"},
{"provider_type": "api", "provider_id": "api-1"},
],
}
}
]
}
}
dependencies = DependencyDiscoveryService().discover_from_dsl(graph)
assert [(item.kind, item.provider_id) for item in dependencies] == [
(DependencyKind.MCP_TOOL, "mcp-1"),
(DependencyKind.API_TOOL, "api-1"),
]
def test_discovers_tool_nodes_from_exported_workflow_dsl_shape():
dsl = {
"workflow": {
"graph": {
"nodes": [
{
"data": {
"type": "tool",
"provider_type": "api",
"provider_id": "api-provider-id",
"provider_name": "weather",
}
},
{
"data": {
"type": "tool",
"provider_type": "workflow",
"provider_id": "workflow-tool-id",
"provider_name": "embedded_workflow",
}
},
]
}
}
}
dependencies = DependencyDiscoveryService().discover_from_dsl(dsl)
assert [(item.kind, item.provider_id, item.provider_name) for item in dependencies] == [
(DependencyKind.API_TOOL, "api-provider-id", "weather"),
(DependencyKind.WORKFLOW_TOOL, "workflow-tool-id", "embedded_workflow"),
]
def test_discovers_agent_tools_from_exported_agent_parameter_shape():
dsl = {
"workflow": {
"graph": {
"nodes": [
{
"data": {
"type": "agent",
"agent_parameters": {
"tools": {
"value": [
{
"provider_type": "api",
"provider_id": "api-provider-id",
"provider_name": "weather",
}
]
}
},
}
}
]
}
}
}
dependencies = DependencyDiscoveryService().discover_from_dsl(dsl)
assert [(item.kind, item.provider_id, item.provider_name) for item in dependencies] == [
(DependencyKind.API_TOOL, "api-provider-id", "weather"),
]

View File

@ -0,0 +1,71 @@
import pytest
from services.data_migration.entities import (
ConflictStrategy,
IdStrategy,
ImportOptions,
MigrationDataError,
MigrationMetadata,
MigrationPackage,
SourceTenant,
)
def test_import_options_defaults_match_spec():
options = ImportOptions()
assert options.create_app_api_token_on_import is False
assert options.id_strategy == IdStrategy.PRESERVE_ID
assert options.conflict_strategy == ConflictStrategy.FAIL
def test_metadata_requires_version():
with pytest.raises(MigrationDataError, match="metadata.version"):
MigrationMetadata.from_mapping({})
def test_metadata_parses_source_tenants_and_import_options():
metadata = MigrationMetadata.from_mapping(
{
"version": "1",
"source_scope": "single",
"source_tenants": [{"id": "tenant-1", "name": "source"}],
"target_tenant": {"name": "prod"},
"include_secrets": False,
"import_options": {"conflict_strategy": "update"},
}
)
assert metadata.version == "1"
assert metadata.source_scope == "single"
assert metadata.source_tenants == [SourceTenant(id="tenant-1", name="source")]
assert metadata.target_tenant == {"name": "prod"}
assert metadata.import_options.conflict_strategy == ConflictStrategy.UPDATE
assert metadata.import_options.id_strategy == IdStrategy.PRESERVE_ID
def test_import_options_invalid_strategy_raises_domain_error():
with pytest.raises(MigrationDataError, match="id_strategy"):
ImportOptions.from_mapping({"id_strategy": "unknown"})
with pytest.raises(MigrationDataError, match="id_strategy"):
ImportOptions.from_mapping({"id_strategy": "map-id"})
with pytest.raises(MigrationDataError, match="conflict_strategy"):
ImportOptions.from_mapping({"conflict_strategy": "unknown"})
with pytest.raises(MigrationDataError, match="conflict_strategy"):
ImportOptions.from_mapping({"conflict_strategy": "replace"})
def test_metadata_rejects_invalid_target_tenant_shape():
with pytest.raises(MigrationDataError, match="target_tenant"):
MigrationMetadata.from_mapping({"version": "1", "target_tenant": "prod"})
def test_migration_package_sections_must_be_lists_of_objects():
with pytest.raises(MigrationDataError, match="workflows"):
MigrationPackage.from_mapping({"metadata": {"version": "1"}, "workflows": {"id": "app-1"}})
with pytest.raises(MigrationDataError, match="tools"):
MigrationPackage.from_mapping({"metadata": {"version": "1"}, "tools": ["weather"]})

View File

@ -0,0 +1,201 @@
import pytest
from services.data_migration.dependency_discovery_service import DiscoveredDependency
from services.data_migration.entities import (
ConflictStrategy,
DependencyKind,
IdStrategy,
MigrationDataError,
ResourceType,
)
from services.data_migration.export_service import ExportConfigParser, MigrationExportService
def test_export_config_parser_accepts_new_scripted_shape():
selection = ExportConfigParser().parse(
{
"source_tenant": {"mode": "single", "name": "admin's Workspace"},
"apps": {"modes": ["workflow", "advanced-chat"], "ids": ["app-1"], "all": False},
"include_referenced_tools": True,
"additional_tools": {
"api_tools": ["weather"],
"workflow_tools": ["workflow-tool-1"],
"mcp_tools": ["mcp-1"],
},
"include_secrets": False,
"import_options": {
"create_app_api_token_on_import": True,
"id_strategy": "preserve-id",
"conflict_strategy": "fail",
},
}
)
assert selection.source_tenant_name == "admin's Workspace"
assert selection.app_ids == ["app-1"]
assert selection.export_all_apps is False
assert selection.additional_api_tools == ["weather"]
assert selection.additional_workflow_tools == ["workflow-tool-1"]
assert selection.additional_mcp_tools == ["mcp-1"]
assert selection.include_secrets is False
assert selection.import_options.create_app_api_token_on_import is True
assert selection.import_options.id_strategy == IdStrategy.PRESERVE_ID
assert selection.import_options.conflict_strategy == ConflictStrategy.FAIL
def test_export_config_parser_defaults_to_secret_free_all_apps():
selection = ExportConfigParser().parse(
{
"source_tenant": {"name": "source"},
"apps": {"all": True},
}
)
assert selection.export_all_apps is True
assert selection.include_referenced_tools is True
assert selection.include_secrets is False
def test_export_config_parser_requires_explicit_source_tenant_name_for_new_shape():
with pytest.raises(MigrationDataError, match="source_tenant.name"):
ExportConfigParser().parse({"source_tenant": {"mode": "single"}, "apps": {"all": True}})
def test_export_config_parser_accepts_limited_backwards_draft_shape():
selection = ExportConfigParser().parse(
{
"tenant_name": "legacy-source",
"workflows": ["app-1"],
"tools": ["weather"],
"workflow_tools": ["wf-tool-1"],
"mcp_tools": ["mcp-1"],
"export_all_workflows": False,
}
)
assert selection.source_tenant_name == "legacy-source"
assert selection.app_ids == ["app-1"]
assert selection.additional_api_tools == ["weather"]
assert selection.additional_workflow_tools == ["wf-tool-1"]
assert selection.additional_mcp_tools == ["mcp-1"]
assert selection.include_secrets is False
def test_export_config_parser_rejects_unsupported_app_modes():
with pytest.raises(MigrationDataError, match="Unsupported app modes"):
ExportConfigParser().parse(
{
"source_tenant": {"name": "source"},
"apps": {"modes": ["chat"], "all": True},
}
)
def test_secret_free_api_tool_export_uses_masking_and_omits_credentials(monkeypatch):
calls = []
def fake_get_api_provider(provider: str, tenant_id: str, mask: bool = True):
calls.append((provider, tenant_id, mask))
return {"credentials": {"api_key": "masked"}, "schema": {"openapi": "3.0.0"}, "tools": ["unused"]}
monkeypatch.setattr(
"services.data_migration.export_service.ToolManager.user_get_api_provider",
fake_get_api_provider,
)
service = MigrationExportService()
tools: list[dict] = []
report_items = []
service._export_api_tools(
"tenant-1",
["weather"],
include_secrets=False,
exported_tools=tools,
report_items=report_items,
)
assert calls == [("weather", "tenant-1", True)]
assert tools == [{"schema": {"openapi": "3.0.0"}, "provider_name": "weather", "source_tenant_id": "tenant-1"}]
assert report_items[0].resource_type == ResourceType.API_TOOL
def test_secret_free_mcp_dependencies_are_dependency_only():
service = MigrationExportService()
dependencies: list[dict] = []
mcp_tools: list[dict] = []
report_items = []
service._export_mcp_tools(
tenant_id="tenant-1",
provider_ids=["mcp-1"],
include_secrets=False,
exported_mcp_tools=mcp_tools,
dependencies=dependencies,
report_items=report_items,
)
assert mcp_tools == []
assert dependencies == [
{
"kind": DependencyKind.MCP_TOOL.value,
"provider_id": "mcp-1",
"provider_name": None,
"source": "mcp_provider",
}
]
assert report_items[0].status == "dependency-only"
assert report_items[0].name == "mcp_tool mcp-1"
def test_get_mcp_provider_does_not_compare_non_uuid_identifier_to_uuid_id(monkeypatch):
statements = []
def capture_scalar(statement):
statements.append(str(statement))
monkeypatch.setattr("services.data_migration.export_service.db.session.scalar", capture_scalar)
with pytest.raises(MigrationDataError, match="MCP provider not found"):
MigrationExportService()._get_mcp_provider("tenant-1", "my-test-mcp")
assert len(statements) == 1
assert "tool_mcp_providers.id =" not in statements[0]
assert "tool_mcp_providers.server_identifier =" in statements[0]
def test_dependency_ids_are_deduplicated_with_manual_selection_first():
service = MigrationExportService()
provider_ids = service._provider_ids(
manual_provider_ids=["weather", "weather", "manual"],
discovered_dependencies=[
DiscoveredDependency(DependencyKind.API_TOOL, "weather"),
DiscoveredDependency(DependencyKind.API_TOOL, "forecast"),
DiscoveredDependency(DependencyKind.WORKFLOW_TOOL, "workflow-tool"),
],
kind=DependencyKind.API_TOOL,
)
assert provider_ids == ["weather", "manual", "forecast"]
def test_api_provider_ids_use_provider_name_from_discovered_dependencies():
service = MigrationExportService()
provider_ids = service._provider_ids(
manual_provider_ids=[],
discovered_dependencies=[
DiscoveredDependency(DependencyKind.API_TOOL, "api-provider-id", provider_name="weather"),
],
kind=DependencyKind.API_TOOL,
)
assert provider_ids == ["weather"]
def test_mcp_authentication_export_omits_runtime_header_shape():
service = MigrationExportService()
assert service._serialize_mcp_authentication({"Authorization": "Bearer token"}) is None
assert service._serialize_mcp_authentication({"client_id": "id", "client_secret": "secret"}) == {
"client_id": "id",
"client_secret": "secret",
}

View File

@ -0,0 +1,973 @@
import pytest
import yaml
from models.tools import MCPToolProvider, WorkflowToolProvider
from services.app_dsl_service import Import
from services.data_migration.entities import (
ConflictStrategy,
IdStrategy,
ImportOptions,
ImportTarget,
MigrationDataError,
MigrationPackage,
ResourceIdMapping,
ResourceReportItem,
ResourceType,
)
from services.data_migration.import_service import ImportRequest, ImportTargetResolver, MigrationImportService
from services.entities.dsl_entities import ImportStatus
def test_target_tenant_precedence_cli_then_config_then_package():
package = MigrationPackage.from_mapping(
{
"metadata": {
"version": "1",
"source_scope": "single",
"source_tenants": [],
"target_tenant": {"name": "from-package"},
}
}
)
resolver = ImportTargetResolver()
assert (
resolver.select_target_tenant_name(
ImportRequest(package=package, cli_target_tenant="from-cli", config_target_tenant="from-config")
)
== "from-cli"
)
assert (
resolver.select_target_tenant_name(
ImportRequest(package=package, cli_target_tenant=None, config_target_tenant="from-config")
)
== "from-config"
)
assert (
resolver.select_target_tenant_name(
ImportRequest(package=package, cli_target_tenant=None, config_target_tenant=None)
)
== "from-package"
)
def test_target_tenant_missing_fails_before_import():
package = MigrationPackage.from_mapping({"metadata": {"version": "1", "source_scope": "single"}})
with pytest.raises(MigrationDataError, match="Target tenant"):
ImportTargetResolver().select_target_tenant_name(
ImportRequest(package=package, cli_target_tenant=None, config_target_tenant=None)
)
def test_package_target_tenant_id_can_be_used_without_name():
package = MigrationPackage.from_mapping(
{"metadata": {"version": "1", "source_scope": "single", "target_tenant": {"id": "tenant-id"}}}
)
assert ImportTargetResolver().select_target_tenant_name(ImportRequest(package=package)) == "tenant-id"
def test_target_tenant_name_is_not_treated_as_uuid():
resolver = ImportTargetResolver()
assert resolver._is_uuid("admin's Workspace") is False
assert resolver._is_uuid("49a99e46-bc2c-4885-91fa-47615f6192b5") is True
def test_package_target_tenant_id_ignores_invalid_uuid(monkeypatch):
package = MigrationPackage.from_mapping(
{"metadata": {"version": "1", "source_scope": "single", "target_tenant": {"id": "not-a-uuid"}}}
)
class StubSession:
def get(self, model, identifier):
raise AssertionError("invalid UUID should not be passed to session.get")
def scalars(self, statement):
class EmptyResult:
def all(self):
return []
return EmptyResult()
from services.data_migration import import_service
monkeypatch.setattr(import_service.db, "session", StubSession())
with pytest.raises(MigrationDataError, match="Target tenant not found"):
ImportTargetResolver().resolve(ImportRequest(package=package))
def test_options_override_replaces_package_defaults():
package = MigrationPackage.from_mapping(
{
"metadata": {
"version": "1",
"source_scope": "single",
"target_tenant": {"name": "target"},
"import_options": {
"create_app_api_token_on_import": True,
"conflict_strategy": "update",
},
}
}
)
captured_options: list[ImportOptions] = []
class StubResolver(ImportTargetResolver):
def resolve(self, request: ImportRequest) -> ImportTarget:
return ImportTarget(
tenant_id="tenant-1",
tenant_name="target",
operator_id="account-1",
operator_email="owner@example.com",
)
class CapturingImportService(MigrationImportService):
def _import_workflows(
self,
package: MigrationPackage,
target: ImportTarget,
options: ImportOptions,
report_items: list[ResourceReportItem],
id_mapping: dict[str, str],
**kwargs,
) -> None:
captured_options.append(options)
override = ImportOptions(create_app_api_token_on_import=False, conflict_strategy=ConflictStrategy.SKIP)
CapturingImportService(target_resolver=StubResolver()).import_package(
ImportRequest(package=package, options_override=override)
)
assert captured_options == [override]
def test_only_preserve_id_strategy_reuses_source_app_id():
service = MigrationImportService()
assert service._should_preserve_source_app_id(ImportOptions(id_strategy=IdStrategy.PRESERVE_ID)) is True
assert service._should_preserve_source_app_id(ImportOptions(id_strategy=IdStrategy.GENERATE_NEW_ID)) is False
def test_find_existing_app_ignores_invalid_uuid(monkeypatch):
class StubSession:
def scalar(self, statement):
raise AssertionError("invalid UUID should not be queried against App.id")
from services.data_migration import import_service
monkeypatch.setattr(import_service.db, "session", StubSession())
assert MigrationImportService()._find_existing_app("not-a-uuid", "tenant-1") is None
def test_find_existing_workflow_tool_does_not_compare_invalid_uuid(monkeypatch):
captured = []
class StubSession:
def scalar(self, statement):
captured.append(statement)
from services.data_migration import import_service
monkeypatch.setattr(import_service.db, "session", StubSession())
MigrationImportService()._find_existing_workflow_tool("tenant-1", "not-a-uuid", "tool-name", "app-id")
where_clause = str(captured[0].whereclause)
assert f"{WorkflowToolProvider.__tablename__}.id" not in where_clause
def test_find_existing_mcp_tool_does_not_compare_invalid_uuid(monkeypatch):
captured = []
class StubSession:
def scalar(self, statement):
captured.append(statement)
from services.data_migration import import_service
monkeypatch.setattr(import_service.db, "session", StubSession())
MigrationImportService()._find_existing_mcp_tool("tenant-1", "my-test-mcp", "my-test-mcp")
where_clause = str(captured[0].whereclause)
assert f"{MCPToolProvider.__tablename__}.id" not in where_clause
assert f"{MCPToolProvider.__tablename__}.name" not in where_clause
def test_workflow_app_import_does_not_wrap_app_dsl_import_in_nested_transaction(monkeypatch):
class FailingNestedTransaction:
def __enter__(self):
raise AssertionError("nested transaction should not be opened")
def __exit__(self, exc_type, exc_value, traceback):
return False
class StubSession:
def begin_nested(self):
return FailingNestedTransaction()
def commit(self):
return None
class StubAppDslService:
def __init__(self, session):
self.session = session
def import_app(self, **kwargs):
return Import(id="import-id", status=ImportStatus.COMPLETED, app_id="imported-app-id")
from services.data_migration import import_service
monkeypatch.setattr(import_service.db, "session", StubSession())
monkeypatch.setattr(import_service, "AppDslService", StubAppDslService)
imported_app_id = MigrationImportService()._import_workflow_app(
account=object(),
workflow_data={"name": "main_chatflow"},
dsl_content="app:\n mode: workflow\n",
app_id="source-app-id",
existing_app=None,
options=ImportOptions(id_strategy=IdStrategy.PRESERVE_ID),
)
assert imported_app_id == "imported-app-id"
def test_rewrite_workflow_dsl_replaces_tool_provider_ids():
dsl_content = yaml.safe_dump(
{
"app": {"mode": "workflow"},
"workflow": {
"graph": {
"nodes": [
{
"data": {
"type": "tool",
"provider_id": "source-api-provider-id",
"provider_name": "weather",
}
},
{
"data": {
"type": "agent",
"agent_parameters": {
"tools": {
"value": [
{
"provider_id": "source-agent-provider-id",
"provider_name": "agent_weather",
}
]
}
},
}
},
]
}
},
}
)
rewritten = MigrationImportService()._rewrite_workflow_dsl_provider_ids(
dsl_content,
{
"source-api-provider-id": "target-api-provider-id",
"source-agent-provider-id": "target-agent-provider-id",
},
)
graph = yaml.safe_load(rewritten)["workflow"]["graph"]
assert graph["nodes"][0]["data"]["provider_id"] == "target-api-provider-id"
assert (
graph["nodes"][1]["data"]["agent_parameters"]["tools"]["value"][0]["provider_id"] == "target-agent-provider-id"
)
def test_source_api_provider_ids_are_discovered_from_workflow_dsl():
package = MigrationPackage.from_mapping(
{
"metadata": {"version": "1", "source_scope": "single"},
"workflows": [
{
"dsl": yaml.safe_dump(
{
"workflow": {
"graph": {
"nodes": [
{
"data": {
"type": "tool",
"provider_id": "source-api-provider-id",
"provider_name": "weather",
"provider_type": "api",
}
}
]
}
}
}
)
}
],
}
)
assert MigrationImportService()._source_api_provider_ids_by_name(package) == {"weather": {"source-api-provider-id"}}
def test_workflow_tool_import_publishes_referenced_app_before_create(monkeypatch):
events = []
account = type("Account", (), {"id": "account-1"})()
class StubSession:
def get(self, model, identifier):
return account
class PublishingImportService(MigrationImportService):
def _find_existing_app(self, app_id, tenant_id):
return object()
def _find_existing_workflow_tool(self, tenant_id, workflow_tool_id, tool_name, app_id):
if ("created", app_id) in events:
return type("WorkflowToolProvider", (), {"id": workflow_tool_id or "created-workflow-tool-id"})()
return None
def _ensure_workflow_app_is_published(self, target, account, app_id):
events.append(("published", app_id))
from services.data_migration import import_service
monkeypatch.setattr(import_service.db, "session", StubSession())
monkeypatch.setattr(
import_service.WorkflowToolManageService,
"create_workflow_tool",
lambda **kwargs: events.append(("created", kwargs["workflow_app_id"])),
)
PublishingImportService()._import_workflow_tools(
MigrationPackage.from_mapping(
{
"metadata": {"version": "1", "source_scope": "single"},
"workflow_tools": [
{
"id": "workflow-tool-1",
"name": "embedded_workflow_as_tool",
"app_id": "workflow-app-1",
}
],
}
),
ImportTarget(
tenant_id="tenant-1",
tenant_name="target",
operator_id="account-1",
operator_email="owner@example.com",
),
ImportOptions(),
{},
[],
[],
)
assert events == [("published", "workflow-app-1"), ("created", "workflow-app-1")]
@pytest.mark.parametrize(
("id_strategy", "expected_import_id"),
[
(IdStrategy.PRESERVE_ID, "source-workflow-tool-id"),
(IdStrategy.GENERATE_NEW_ID, ""),
],
)
def test_workflow_tool_import_id_follows_id_strategy(monkeypatch, id_strategy, expected_import_id):
created_kwargs = []
target_provider = type("WorkflowToolProvider", (), {"id": "target-workflow-tool-id"})()
account = type("Account", (), {"id": "account-1"})()
id_mapping = {"source-app-id": "target-app-id"}
id_mapping_details = []
class StubSession:
def get(self, model, identifier):
return account
class StrategyImportService(MigrationImportService):
def _find_existing_app(self, app_id, tenant_id):
return object()
def _find_existing_workflow_tool(self, tenant_id, workflow_tool_id, tool_name, app_id):
return target_provider if created_kwargs else None
def _ensure_workflow_app_is_published(self, target, account, app_id):
return None
from services.data_migration import import_service
monkeypatch.setattr(import_service.db, "session", StubSession())
monkeypatch.setattr(
import_service.WorkflowToolManageService,
"create_workflow_tool",
lambda **kwargs: created_kwargs.append(kwargs),
)
StrategyImportService()._import_workflow_tools(
MigrationPackage.from_mapping(
{
"metadata": {"version": "1", "source_scope": "single"},
"workflow_tools": [
{
"id": "source-workflow-tool-id",
"name": "embedded_workflow_as_tool",
"app_id": "source-app-id",
}
],
}
),
ImportTarget(
tenant_id="tenant-1",
tenant_name="target",
operator_id="account-1",
operator_email="owner@example.com",
),
ImportOptions(id_strategy=id_strategy),
id_mapping,
id_mapping_details,
[],
)
assert created_kwargs[0]["import_id"] == expected_import_id
assert id_mapping["source-workflow-tool-id"] == "target-workflow-tool-id"
assert id_mapping_details == [
ResourceIdMapping(
ResourceType.WORKFLOW_TOOL,
"embedded_workflow_as_tool",
"source-workflow-tool-id",
"target-workflow-tool-id",
)
]
def test_workflow_tool_skip_records_id_mapping(monkeypatch):
account = type("Account", (), {"id": "account-1"})()
existing_provider = type("WorkflowToolProvider", (), {"id": "existing-workflow-tool-id"})()
id_mapping = {"source-app-id": "target-app-id"}
class StubSession:
def get(self, model, identifier):
return account
class SkipImportService(MigrationImportService):
def _find_existing_app(self, app_id, tenant_id):
return object()
def _find_existing_workflow_tool(self, tenant_id, workflow_tool_id, tool_name, app_id):
return existing_provider
def _ensure_workflow_app_is_published(self, target, account, app_id):
return None
from services.data_migration import import_service
monkeypatch.setattr(import_service.db, "session", StubSession())
SkipImportService()._import_workflow_tools(
MigrationPackage.from_mapping(
{
"metadata": {"version": "1", "source_scope": "single"},
"workflow_tools": [
{
"id": "source-workflow-tool-id",
"name": "embedded_workflow_as_tool",
"app_id": "source-app-id",
}
],
}
),
ImportTarget(
tenant_id="tenant-1",
tenant_name="target",
operator_id="account-1",
operator_email="owner@example.com",
),
ImportOptions(conflict_strategy=ConflictStrategy.SKIP, id_strategy=IdStrategy.GENERATE_NEW_ID),
id_mapping,
[],
[],
)
assert id_mapping["source-workflow-tool-id"] == "existing-workflow-tool-id"
@pytest.mark.parametrize("conflict_strategy", [ConflictStrategy.SKIP, ConflictStrategy.UPDATE])
def test_api_tool_existing_provider_records_id_mapping(monkeypatch, conflict_strategy):
target_provider = type("ApiToolProvider", (), {"id": "target-api-provider-id", "name": "weather"})()
id_mapping = {}
id_mapping_details = []
report_items = []
class ExistingApiImportService(MigrationImportService):
def _find_api_tool_provider(self, tenant_id, provider_name):
return target_provider
from services.data_migration import import_service
monkeypatch.setattr(import_service.db.session, "scalar", lambda statement: target_provider)
monkeypatch.setattr(
import_service.ApiToolManageService, "parser_api_schema", lambda schema: {"schema_type": "openapi"}
)
monkeypatch.setattr(import_service.ApiToolManageService, "update_api_tool_provider", lambda **kwargs: None)
ExistingApiImportService()._import_api_tools(
MigrationPackage.from_mapping(
{
"metadata": {"version": "1", "source_scope": "single"},
"tools": [{"id": "source-api-provider-id", "provider_name": "weather", "schema": "openapi: 3.0.0"}],
}
),
ImportTarget(
tenant_id="tenant-1",
tenant_name="target",
operator_id="account-1",
operator_email="owner@example.com",
),
ImportOptions(conflict_strategy=conflict_strategy),
report_items,
id_mapping,
id_mapping_details,
{"weather": {"source-api-provider-id-from-dsl"}},
)
assert id_mapping == {
"source-api-provider-id": "target-api-provider-id",
"source-api-provider-id-from-dsl": "target-api-provider-id",
}
assert (
ResourceIdMapping(ResourceType.API_TOOL, "weather", "source-api-provider-id", "target-api-provider-id")
in id_mapping_details
)
def test_api_tool_create_records_id_mapping(monkeypatch):
target_provider = type("ApiToolProvider", (), {"id": "target-api-provider-id", "name": "weather"})()
id_mapping = {}
class StubSession:
def scalar(self, statement):
return None
class CreatedApiImportService(MigrationImportService):
def _find_api_tool_provider(self, tenant_id, provider_name):
return target_provider
from services.data_migration import import_service
monkeypatch.setattr(import_service.db, "session", StubSession())
monkeypatch.setattr(
import_service.ApiToolManageService, "parser_api_schema", lambda schema: {"schema_type": "openapi"}
)
monkeypatch.setattr(import_service.ApiToolManageService, "create_api_tool_provider", lambda **kwargs: None)
CreatedApiImportService()._import_api_tools(
MigrationPackage.from_mapping(
{
"metadata": {"version": "1", "source_scope": "single"},
"tools": [{"id": "source-api-provider-id", "provider_name": "weather", "schema": "openapi: 3.0.0"}],
}
),
ImportTarget(
tenant_id="tenant-1",
tenant_name="target",
operator_id="account-1",
operator_email="owner@example.com",
),
ImportOptions(),
[],
id_mapping,
[],
{},
)
assert id_mapping["source-api-provider-id"] == "target-api-provider-id"
def test_mcp_tool_import_restores_exported_tool_list(monkeypatch):
provider = type("Provider", (), {"id": "target-provider-id", "tools": "[]", "authed": False})()
report_items = []
class StubSession:
def scalar(self, statement):
return provider
def commit(self):
return None
class StubMCPToolManageService:
def __init__(self, session):
self.session = session
def update_provider(self, **kwargs):
return None
from services.data_migration import import_service
monkeypatch.setattr(import_service.db, "session", StubSession())
monkeypatch.setattr(import_service, "MCPToolManageService", StubMCPToolManageService)
MigrationImportService()._import_mcp_tools(
MigrationPackage.from_mapping(
{
"metadata": {"version": "1", "source_scope": "single"},
"mcp_tools": [
{
"id": "source-provider-id",
"name": "my-test-mcp",
"server_identifier": "my-test-mcp",
"server_url": "http://localhost:3000/mcp",
"configuration": {},
"tools": [{"name": "echo"}],
}
],
}
),
ImportTarget(
tenant_id="tenant-1",
tenant_name="target",
operator_id="account-1",
operator_email="owner@example.com",
),
ImportOptions(conflict_strategy=ConflictStrategy.UPDATE),
report_items,
{},
[],
)
assert provider.tools == '[{"name": "echo"}]'
assert provider.authed is True
@pytest.mark.parametrize("conflict_strategy", [ConflictStrategy.SKIP, ConflictStrategy.UPDATE])
def test_mcp_tool_existing_provider_records_id_mapping(monkeypatch, conflict_strategy):
provider = type("Provider", (), {"id": "target-mcp-provider-id", "tools": "[]", "authed": False})()
id_mapping = {}
id_mapping_details = []
class StubSession:
def commit(self):
return None
class ExistingMCPImportService(MigrationImportService):
def _find_existing_mcp_tool(self, tenant_id, provider_id, server_identifier):
return provider
class StubMCPToolManageService:
def __init__(self, session):
self.session = session
def update_provider(self, **kwargs):
return None
from services.data_migration import import_service
monkeypatch.setattr(import_service.db, "session", StubSession())
monkeypatch.setattr(import_service, "MCPToolManageService", StubMCPToolManageService)
ExistingMCPImportService()._import_mcp_tools(
MigrationPackage.from_mapping(
{
"metadata": {"version": "1", "source_scope": "single"},
"mcp_tools": [
{
"id": "source-mcp-provider-id",
"name": "my-test-mcp",
"server_identifier": "my-test-mcp",
"server_url": "http://localhost:3000/mcp",
"configuration": {},
"tools": [{"name": "echo"}],
}
],
}
),
ImportTarget(
tenant_id="tenant-1",
tenant_name="target",
operator_id="account-1",
operator_email="owner@example.com",
),
ImportOptions(conflict_strategy=conflict_strategy),
[],
id_mapping,
id_mapping_details,
)
assert id_mapping["source-mcp-provider-id"] == "target-mcp-provider-id"
assert "my-test-mcp" not in id_mapping
assert id_mapping_details == [
ResourceIdMapping(ResourceType.MCP_TOOL, "my-test-mcp", "source-mcp-provider-id", "target-mcp-provider-id")
]
def test_mcp_tool_create_records_id_mapping(monkeypatch):
provider = type("Provider", (), {"id": "target-mcp-provider-id", "tools": "[]", "authed": False})()
id_mapping = {}
provider_created = False
class StubSession:
def commit(self):
return None
class CreatedMCPImportService(MigrationImportService):
def _find_existing_mcp_tool(self, tenant_id, provider_id, server_identifier):
return provider if provider_created else None
class StubMCPToolManageService:
def __init__(self, session):
self.session = session
def create_provider(self, **kwargs):
nonlocal provider_created
provider_created = True
from services.data_migration import import_service
monkeypatch.setattr(import_service.db, "session", StubSession())
monkeypatch.setattr(import_service, "MCPToolManageService", StubMCPToolManageService)
CreatedMCPImportService()._import_mcp_tools(
MigrationPackage.from_mapping(
{
"metadata": {"version": "1", "source_scope": "single"},
"mcp_tools": [
{
"id": "source-mcp-provider-id",
"name": "my-test-mcp",
"server_identifier": "my-test-mcp",
"server_url": "http://localhost:3000/mcp",
"configuration": {},
}
],
}
),
ImportTarget(
tenant_id="tenant-1",
tenant_name="target",
operator_id="account-1",
operator_email="owner@example.com",
),
ImportOptions(),
[],
id_mapping,
[],
)
assert id_mapping["source-mcp-provider-id"] == "target-mcp-provider-id"
def test_dependency_only_mcp_preflight_reports_missing_target_provider_with_workflow_context(monkeypatch):
report_items = []
package = MigrationPackage.from_mapping(
{
"metadata": {"version": "1", "source_scope": "single"},
"dependencies": [
{
"kind": "mcp_tool",
"provider_id": "my-test-mcp-server",
"provider_name": "my-test-mcp",
}
],
"workflows": [
{
"name": "workflow2",
"dsl": yaml.safe_dump(
{
"workflow": {
"graph": {
"nodes": [
{
"id": "node-1",
"data": {
"type": "tool",
"provider_type": "mcp",
"provider_id": "my-test-mcp-server",
"tool_name": "echo",
},
}
]
}
}
}
),
}
],
}
)
from services.data_migration import import_service
monkeypatch.setattr(import_service.db.session, "scalar", lambda statement: None)
MigrationImportService()._preflight_dependency_only_mcp(
package,
ImportTarget(
tenant_id="tenant-1",
tenant_name="target",
operator_id="account-1",
operator_email="owner@example.com",
),
report_items,
)
assert report_items == [
ResourceReportItem(
ResourceType.DEPENDENCY,
"my-test-mcp-server",
"mcp_tool my-test-mcp",
"skipped",
"missing in target tenant; referenced by workflow2 / echo; "
"configure it manually before running the workflow.",
)
]
def test_dependency_only_mcp_lookup_does_not_compare_non_uuid_identifier_to_uuid_id(monkeypatch):
captured = []
class StubSession:
def scalar(self, statement):
captured.append(statement)
from services.data_migration import import_service
monkeypatch.setattr(import_service.db, "session", StubSession())
MigrationImportService()._find_dependency_only_mcp_provider(
"tenant-1",
"my-test-mcp-server",
"my-test-mcp",
)
where_clause = str(captured[0].whereclause)
assert f"{MCPToolProvider.__tablename__}.id" not in where_clause
def test_dependency_only_mcp_preflight_reports_available_target_provider(monkeypatch):
report_items = []
package = MigrationPackage.from_mapping(
{
"metadata": {"version": "1", "source_scope": "single"},
"dependencies": [{"kind": "mcp_tool", "provider_id": "my-test-mcp-server"}],
}
)
provider = type(
"Provider",
(),
{"id": "target-provider-id", "name": "my-test-mcp", "server_identifier": "my-test-mcp-server"},
)()
from services.data_migration import import_service
monkeypatch.setattr(import_service.db.session, "scalar", lambda statement: provider)
MigrationImportService()._preflight_dependency_only_mcp(
package,
ImportTarget(
tenant_id="tenant-1",
tenant_name="target",
operator_id="account-1",
operator_email="owner@example.com",
),
report_items,
)
assert report_items == [
ResourceReportItem(
ResourceType.DEPENDENCY,
"my-test-mcp-server",
"mcp_tool my-test-mcp",
"available",
"MCP provider exists in target tenant.",
)
]
def test_import_package_imports_workflow_tool_provider_apps_before_consumers():
events = []
class StubResolver(ImportTargetResolver):
def resolve(self, request):
return ImportTarget(
tenant_id="tenant-1",
tenant_name="target",
operator_id="account-1",
operator_email="owner@example.com",
)
class OrderedImportService(MigrationImportService):
def _import_api_tools(
self,
package,
target,
options,
report_items,
id_mapping,
id_mapping_details,
source_provider_ids_by_name,
):
events.append(("api_tools", "imported"))
def _import_workflows(
self,
package,
target,
options,
report_items,
id_mapping,
id_mapping_details,
*,
imported_workflow_ids=None,
only_app_ids=None,
skip_app_ids=None,
):
only_app_ids = set(only_app_ids or [])
skip_app_ids = set(skip_app_ids or [])
for workflow_data in package.workflows:
app_id = workflow_data["id"]
if only_app_ids and app_id not in only_app_ids:
continue
if app_id in skip_app_ids:
continue
events.append(("workflow", app_id))
id_mapping[app_id] = app_id
if imported_workflow_ids is not None:
imported_workflow_ids.add(app_id)
def _import_workflow_tools(self, package, target, options, id_mapping, id_mapping_details, report_items):
events.append(("workflow_tool", package.workflow_tools[0]["id"]))
def _import_mcp_tools(self, package, target, options, report_items, id_mapping, id_mapping_details):
events.append(("mcp_tools", "imported"))
package = MigrationPackage.from_mapping(
{
"metadata": {"version": "1", "source_scope": "single"},
"workflows": [
{"id": "provider-app", "name": "embedded", "dsl": "app:\n mode: workflow\n"},
{"id": "consumer-app", "name": "main", "dsl": "app:\n mode: advanced-chat\n"},
],
"workflow_tools": [{"id": "workflow-tool", "name": "embedded_tool", "app_id": "provider-app"}],
}
)
OrderedImportService(target_resolver=StubResolver()).import_package(ImportRequest(package=package))
assert events == [
("api_tools", "imported"),
("mcp_tools", "imported"),
("workflow", "provider-app"),
("workflow_tool", "workflow-tool"),
("workflow", "consumer-app"),
]

View File

@ -0,0 +1,53 @@
import json
import pytest
from services.data_migration.entities import MigrationDataError
from services.data_migration.package_service import MigrationPackageService
def test_load_package_rejects_branch_draft_shape(tmp_path):
package_file = tmp_path / "draft.json"
package_file.write_text(json.dumps({"workflows": [], "tools": []}), encoding="utf-8")
with pytest.raises(MigrationDataError, match="metadata.version"):
MigrationPackageService().load_package(package_file)
def test_load_package_rejects_unsupported_version(tmp_path):
package_file = tmp_path / "future.json"
package_file.write_text(json.dumps({"metadata": {"version": "999", "source_scope": "single"}}), encoding="utf-8")
with pytest.raises(MigrationDataError, match="Unsupported migration package version"):
MigrationPackageService().load_package(package_file)
def test_save_package_writes_versioned_shape(tmp_path):
output_file = tmp_path / "migration-data.json"
package = MigrationPackageService().build_empty_package(
source_tenant_id="tenant-1",
source_tenant_name="source",
include_secrets=False,
)
MigrationPackageService().save_package(package, output_file, overwrite=False)
data = json.loads(output_file.read_text(encoding="utf-8"))
assert data["metadata"]["version"] == "1"
assert data["metadata"]["source_tenants"] == [{"id": "tenant-1", "name": "source"}]
assert data["metadata"]["include_secrets"] is False
assert data["workflows"] == []
assert data["dependencies"] == []
def test_save_package_without_overwrite_fails_when_file_exists(tmp_path):
output_file = tmp_path / "migration-data.json"
output_file.write_text("{}", encoding="utf-8")
package = MigrationPackageService().build_empty_package(
source_tenant_id="tenant-1",
source_tenant_name="source",
include_secrets=False,
)
with pytest.raises(MigrationDataError, match="already exists"):
MigrationPackageService().save_package(package, output_file, overwrite=False)

View File

@ -0,0 +1,111 @@
from services.data_migration.entities import ReportContext, ResourceIdMapping, ResourceReportItem, ResourceType
from services.data_migration.report_service import MigrationReportService
def test_report_summarizes_by_resource_type_and_status():
lines = MigrationReportService().render(
[
ResourceReportItem(ResourceType.WORKFLOW, "app-1", "App", "exported"),
ResourceReportItem(ResourceType.API_TOOL, "weather", "Weather", "exported"),
ResourceReportItem(ResourceType.DEPENDENCY, "mcp-1", "MCP", "dependency-only"),
]
)
assert "workflow exported: 1" in lines
assert "api_tool exported: 1" in lines
assert "dependency dependency-only: 1" in lines
def test_report_includes_actionable_lines_for_skipped_and_unresolved_items():
lines = MigrationReportService().render(
[
ResourceReportItem(ResourceType.WORKFLOW, "app-1", "App", "skipped", "App already exists"),
ResourceReportItem(ResourceType.DEPENDENCY, "mcp-1", None, "unresolved", "MCP provider not found"),
ResourceReportItem(ResourceType.API_TOOL, "weather", "Weather", "exported"),
]
)
assert "workflow skipped: 1" in lines
assert "dependency unresolved: 1" in lines
assert "workflow app-1: App already exists" in lines
assert "dependency mcp-1: MCP provider not found" in lines
def test_report_dependency_detail_uses_type_and_name_when_available():
lines = MigrationReportService().render(
[
ResourceReportItem(
ResourceType.DEPENDENCY,
"785e52f1-06bf-483c-8dcf-712e59fd43b9",
"workflow embedded_workflow",
"dependency-only",
"Dependency metadata only; ensure the resource exists in the target environment.",
),
ResourceReportItem(
ResourceType.DEPENDENCY,
"my-test-mcp",
"mcp_tool my-test-mcp",
"dependency-only",
"Configure MCP provider manually in the target tenant unless exporting with secrets enabled.",
),
]
)
assert (
"dependency workflow embedded_workflow: 785e52f1-06bf-483c-8dcf-712e59fd43b9: "
"Dependency metadata only; ensure the resource exists in the target environment."
) in lines
assert (
"dependency mcp_tool my-test-mcp: "
"Configure MCP provider manually in the target tenant unless exporting with secrets enabled."
) in lines
def test_report_includes_dependency_only_detail_lines():
lines = MigrationReportService().render(
[
ResourceReportItem(
ResourceType.DEPENDENCY,
"mcp-1",
"MCP",
"dependency-only",
"Configure manually in target tenant.",
),
]
)
assert "dependency dependency-only: 1" in lines
assert "dependency mcp-1: Configure manually in target tenant." in lines
def test_report_includes_export_and_import_context():
lines = MigrationReportService().render(
[],
context=ReportContext(
output_path="migration-data.json",
source_scope="single",
selected_app_count=2,
include_secrets=False,
target_tenant="prod",
operator_email="admin@example.com",
app_api_tokens_created=1,
app_api_tokens_reused=2,
id_mapping_count=3,
id_mappings={"source-app": "target-app", "source-tool": "target-tool"},
id_mapping_details=[
ResourceIdMapping(ResourceType.WORKFLOW, "Main workflow", "source-app", "target-app"),
ResourceIdMapping(ResourceType.API_TOOL, "weather", "source-tool", "target-tool"),
],
),
)
assert "output: migration-data.json" in lines
assert "source scope: single" in lines
assert "selected apps: 2" in lines
assert "include secrets: false" in lines
assert "target tenant: prod" in lines
assert "operator: admin@example.com" in lines
assert "app api tokens: 1 created, 2 reused" in lines
assert "resource references resolved: 2" in lines
assert "- workflow Main workflow: source-app -> target-app" in lines
assert "- api_tool weather: source-tool -> target-tool" in lines

View File

@ -0,0 +1,286 @@
# Cross-Environment Data Migration
This guide explains how to move workflow apps, chatflow apps, and their custom tool dependencies from one Dify environment to another.
The recommended best-practice path is:
1. Run the export wizard in the source environment.
2. Import the generated migration package in the target environment.
Use scripted export only when you need repeatable automation.
## What Gets Migrated
The migration package can contain:
- Workflow apps and advanced-chat apps.
- Custom API tool providers.
- Workflow tool providers.
- MCP tool providers when secrets are explicitly included.
- Dependency metadata for MCP tools, built-in tools, and plugin tools that must already exist or be configured in the target environment.
Only single source workspace exports are supported. The source and target workspace names do not need to match.
## Recommended Flow: Wizard Export + Import
Run the wizard from the source environment:
```bash
cd api
source .venv/bin/activate
uv run flask app-migration-wizard
```
The wizard asks you to select the source workspace, choose workflow/chatflow apps, discover referenced tools, set import behavior, and write the final migration package JSON.
Then copy the generated JSON package to the target environment and import it:
```bash
cd api
source .venv/bin/activate
uv run flask import-app-migration \
--input migration-data-20260528-120000.json \
--target-tenant "production Workspace"
```
This wizard + import combination is the recommended migration workflow because the wizard shows the available apps and tools, discovers app dependencies, summarizes the package before writing it, and uses the same export service as scripted export.
## Wizard Command
```bash
cd api
source .venv/bin/activate
uv run flask app-migration-wizard
```
The wizard has no CLI options. Its interactive inputs are:
- `Source tenant`: the source workspace to export from. Select one tenant by number.
- `App selection`: apps to export. Supported app types are `workflow` and `advanced-chat`. Enter `all`, one number, or comma-separated numbers.
- `Automatically export tools referenced by selected apps?`: recommended `yes`. This scans selected app graphs and automatically includes referenced custom API tools, workflow tools, and MCP tool references.
- `Export additional tools manually?`: optional. Use this when you need to migrate tools that are not referenced by the selected apps.
- `Include secrets in output JSON?`: default `no`. When `no`, workflow/app DSL secrets are omitted or masked, API tool credentials are omitted, and MCP providers are exported as dependency metadata only. When `yes`, treat the output JSON as sensitive.
- `Create or reuse app API tokens during import?`: default `no`. When `yes`, import creates an app API token for imported apps that have none, or reuses an existing token.
- `Import ID strategy`: `preserve-id` or `generate-new-id`. Default is `preserve-id`.
- `Import conflict strategy`: `fail`, `skip`, or `update`. Wizard default is `update`.
- `Output path`: output migration package path. The default is `migration-data-YYYYMMDD-HHMMSS.json`.
- `Overwrite existing output`: shown only if the selected output file already exists.
- `Write migration package?`: final confirmation after the wizard prints the summary.
## Import Command
```bash
cd api
source .venv/bin/activate
uv run flask import-app-migration --input migration-package.json --target-tenant "target Workspace"
```
Options:
- `--input`: required. Path to the migration package JSON generated by the wizard or scripted export.
- `--target-tenant`: optional only when package metadata already contains a target tenant. Target workspace name or workspace UUID. This overrides package metadata and is recommended for reusable packages.
- `--operator-email`: optional. Email of the target-tenant account used as the import operator. If omitted, import uses the earliest owner account in the target tenant.
- `--id-strategy`: optional override for the package import option. Allowed values:
- `preserve-id`: keep source app/tool IDs when supported. This is the recommended default for preserving workflow references across environments.
- `generate-new-id`: let the target environment generate new IDs and rewrite references through the migration ID mapping.
- `--conflict-strategy`: optional override for the package import option. Allowed values:
- `fail`: stop at the first existing target resource conflict. Previously committed resources are not rolled back.
- `skip`: keep the existing target resource and skip importing that resource.
- `update`: update the existing target resource in place.
- `--create-app-api-token-on-import`: optional override. Create or reuse app API tokens during import.
- `--no-create-app-api-token-on-import`: optional override. Do not create app API tokens during import.
Import prints a report that includes the resolved target tenant, operator, created/updated/skipped resources, unresolved dependencies, app API token counts, and ID mappings used to rewrite references.
## Optional Flow: Scripted Export
Scripted export is supported for automation. The recommended scripted path is:
1. Generate an export config template.
2. Edit the template.
3. Run scripted export.
4. Import the generated package.
Generate the template:
```bash
cd api
source .venv/bin/activate
uv run flask app-migration-template --output export-config.json
```
Edit `export-config.json`, then run:
```bash
cd api
source .venv/bin/activate
uv run flask export-app-migration \
--input export-config.json \
--output migration-package.json
```
Import it:
```bash
cd api
source .venv/bin/activate
uv run flask import-app-migration \
--input migration-package.json \
--target-tenant "production Workspace"
```
### Template Command
```bash
cd api
source .venv/bin/activate
uv run flask app-migration-template [--output export-config.json] [--overwrite]
```
Options:
- `--output`: optional. Path to write the scripted export config JSON template. If omitted, the template is printed to stdout.
- `--overwrite`: optional. Allows replacing an existing output file. Without this option, the command fails if `--output` already exists.
### Scripted Export Command
```bash
cd api
source .venv/bin/activate
uv run flask export-app-migration --input export-config.json --output migration-package.json
```
Options:
- `--input`: required. Path to the export config JSON.
- `--output`: required. Path to write the migration package JSON.
- `--overwrite`: optional. Allows replacing an existing output package. Without this option, the command fails if `--output` already exists.
### Export Config Fields
The generated template uses this shape:
```json
{
"source_tenant": {
"mode": "single",
"id": "",
"name": "admin's Workspace"
},
"apps": {
"modes": ["workflow", "advanced-chat"],
"ids": [],
"all": true
},
"include_referenced_tools": true,
"additional_tools": {
"api_tools": [],
"workflow_tools": [],
"mcp_tools": []
},
"include_secrets": false,
"import_options": {
"create_app_api_token_on_import": false,
"id_strategy": "preserve-id",
"conflict_strategy": "fail"
}
}
```
Fields:
- `source_tenant.mode`: must be `single`.
- `source_tenant.id`: optional source workspace UUID. Use this when workspace names are duplicated or when you want strict source selection.
- `source_tenant.name`: required source workspace name. When `source_tenant.id` is set, the ID and name must match.
- `apps.modes`: optional list of app modes for validation. Supported values are `workflow` and `advanced-chat`.
- `apps.ids`: app IDs to export when `apps.all` is `false`.
- `apps.all`: when `true`, export all supported apps in the selected source workspace. When `false`, export only `apps.ids`.
- `include_referenced_tools`: recommended `true`. Automatically discovers tools referenced by selected workflow/chatflow apps.
- `additional_tools.api_tools`: optional custom API tool provider names to export in addition to referenced tools.
- `additional_tools.workflow_tools`: optional workflow tool provider IDs to export in addition to referenced tools.
- `additional_tools.mcp_tools`: optional MCP provider IDs or server identifiers to export in addition to referenced tools.
- `include_secrets`: default `false`. When `false`, credentials are omitted and MCP providers are recorded as dependency metadata. When `true`, custom API credentials, workflow DSL secrets, and full MCP connection data can be written to the package.
- `import_options.create_app_api_token_on_import`: default `false`. Package-level default for import token creation.
- `import_options.id_strategy`: package-level default ID strategy. Allowed values are `preserve-id` and `generate-new-id`.
- `import_options.conflict_strategy`: package-level default conflict strategy. Allowed values are `fail`, `skip`, and `update`.
## Referenced Tools
Use automatic referenced-tool discovery whenever possible.
When enabled, Dify scans selected workflow/chatflow graphs and agent tool configs, then de-duplicates discovered custom API tools, workflow tools, and MCP tool references before export. This reduces the chance of importing an app whose workflow references missing providers.
Built-in and plugin tools are not serialized into the package. The migration report records them as dependency metadata; make sure the target environment has the required built-in or plugin tools installed and configured.
MCP providers are also dependency-only unless `include_secrets` is enabled. If MCP secrets are not exported, configure the corresponding MCP provider manually in the target workspace before running migrated workflows.
## Secret Handling
The safe default is `include_secrets: false`.
Only enable secret export when you have a controlled transfer path for the JSON package. With secrets enabled, the package may include API tool credentials, workflow/app DSL secret values, MCP server URLs, MCP headers, authentication data, and cached MCP tool lists.
## FAQ
### What happens when `include_secrets` is `false`?
This is the recommended default, but it means the migration package is intentionally incomplete for sensitive runtime configuration.
Important behavior:
- MCP tools are not exported as full tool providers. They are recorded as dependency metadata only.
- Custom API tool credentials are not exported. The provider schema can be exported, but credentials must be configured again in the target workspace.
- Workflow/app DSL secrets are omitted or masked. Any app variables, credentials, or secret-backed settings must be reviewed in the target workspace after import.
- Built-in and plugin tools are never serialized as custom migration data. They are recorded as dependencies only.
How to handle it:
- Before importing, install or enable the required built-in/plugin tools in the target environment.
- For MCP tools, manually create or configure the corresponding MCP provider in the target workspace before running migrated workflows.
- For custom API tools, open the imported provider in the target workspace and re-enter credentials.
- After import, review each migrated workflow/chatflow before production use. Pay special attention to tool nodes, agent tool configs, environment variables, app variables, and credential-backed settings.
- Use the import report. Items marked `dependency-only`, `skipped`, or `unresolved` usually need manual follow-up.
If you need the package to carry full MCP provider configuration or credentials, rerun export with `include_secrets` set to `true` and transfer the generated JSON as sensitive data.
### What should I consider when `include_secrets` is `true`?
The package can include sensitive runtime configuration such as API tool credentials, workflow/app DSL secret values, MCP server URLs, headers, authentication data, and MCP tool lists.
How to handle it:
- Store and transfer the JSON package as a secret.
- Delete the package after import if your security policy requires it.
- Prefer using this only for controlled one-time migrations where manual target-side credential setup is harder than securing the package.
### Should I use `preserve-id` or `generate-new-id`?
The ID strategy controls whether imported resources keep source IDs or receive new IDs in the target environment. It applies to workflow apps and custom tool resources where the target service supports explicit IDs.
Use `preserve-id` by default. It is recommended for cross-environment app migration because imported apps and tools try to keep their source IDs, which makes workflow references easier to preserve.
Use `preserve-id` when:
- You are migrating between environments that are meant to mirror each other, such as staging to production.
- You want workflow tool references and provider references to remain as stable as possible.
- The target environment does not already contain unrelated resources with the same IDs.
Watch out for:
- If the target already has a resource with the same ID, `conflict_strategy=fail` stops the import, `skip` keeps the target resource, and `update` updates it in place.
- If the same ID was reused for a different resource in the target environment, review carefully before using `update`.
Use `generate-new-id` when the target environment should keep its own local IDs.
Use `generate-new-id` when:
- The target environment already has resources that may conflict with source IDs.
- You are importing a package as a copy rather than trying to mirror environments.
- You want to avoid preserving source database IDs in the target environment.
Watch out for:
- Import records source-to-target ID mappings. Always review the import report's ID mappings.
- Workflow DSL provider references are rewritten using the generated ID mapping where possible.
- Dependencies that are metadata-only, such as MCP providers exported with `include_secrets=false`, still require manual target-side configuration.
- Built-in/plugin tools are not remapped as migrated custom resources; the target environment must provide them separately.

View File

@ -147,11 +147,6 @@
"count": 1
}
},
"web/app/(commonLayout)/snippets/[snippetId]/page.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/(humanInputLayout)/form/[token]/form.tsx": {
"react/set-state-in-effect": {
"count": 1
@ -248,11 +243,6 @@
"count": 1
}
},
"web/app/components/app-sidebar/nav-link/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 3
}
},
"web/app/components/app/annotation/add-annotation-modal/edit-item/index.tsx": {
"erasable-syntax-only/enums": {
"count": 1
@ -3172,16 +3162,6 @@
"count": 2
}
},
"web/app/components/snippets/hooks/use-nodes-sync-draft.ts": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/snippets/hooks/use-snippet-run.ts": {
"no-restricted-imports": {
"count": 2
}
},
"web/app/components/tools/edit-custom-collection-modal/get-schema.tsx": {
"no-restricted-imports": {
"count": 1
@ -3352,11 +3332,6 @@
"count": 1
}
},
"web/app/components/workflow/block-selector/blocks.tsx": {
"unused-imports/no-unused-imports": {
"count": 1
}
},
"web/app/components/workflow/block-selector/hooks.ts": {
"react/set-state-in-effect": {
"count": 1
@ -5240,11 +5215,6 @@
"count": 1
}
},
"web/service/__tests__/use-snippet-workflows.spec.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/service/access-control.ts": {
"@tanstack/query/exhaustive-deps": {
"count": 1
@ -5510,11 +5480,6 @@
"count": 3
}
},
"web/service/use-snippet-workflows.ts": {
"no-restricted-imports": {
"count": 1
}
},
"web/service/use-tools.ts": {
"no-restricted-imports": {
"count": 1

View File

@ -1,5 +0,0 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 2.25C3.41421 2.25 3.75 2.58579 3.75 3V15C3.75 15.4142 3.41421 15.75 3 15.75C2.58579 15.75 2.25 15.4142 2.25 15V3C2.25 2.58579 2.58579 2.25 3 2.25Z" fill="#676F83"/>
<path d="M15 2.25C15.4142 2.25 15.75 2.58579 15.75 3V15C15.75 15.4142 15.4142 15.75 15 15.75C14.5858 15.75 14.25 15.4142 14.25 15V3C14.25 2.58579 14.5858 2.25 15 2.25Z" fill="#676F83"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.125 4.5C10.5392 4.5 10.875 4.83579 10.875 5.25V12.75C10.875 13.1642 10.5392 13.5 10.125 13.5H7.875C7.46079 13.5 7.125 13.1642 7.125 12.75V5.25C7.125 4.83579 7.46079 4.5 7.875 4.5H10.125ZM8.625 12H9.375V6H8.625V12Z" fill="#676F83"/>
</svg>

Before

Width:  |  Height:  |  Size: 751 B

View File

@ -1,5 +0,0 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 14.25C15.4142 14.25 15.75 14.5858 15.75 15C15.75 15.4142 15.4142 15.75 15 15.75H3C2.58579 15.75 2.25 15.4142 2.25 15C2.25 14.5858 2.58579 14.25 3 14.25H15Z" fill="#676F83"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.5 7.125C13.9142 7.125 14.25 7.46079 14.25 7.875V10.125C14.25 10.5392 13.9142 10.875 13.5 10.875H4.5C4.08579 10.875 3.75 10.5392 3.75 10.125V7.875C3.75 7.46079 4.08579 7.125 4.5 7.125H13.5ZM5.25 9.375H12.75V8.625H5.25V9.375Z" fill="#676F83"/>
<path d="M15 2.25C15.4142 2.25 15.75 2.58579 15.75 3C15.75 3.41421 15.4142 3.75 15 3.75H3C2.58579 3.75 2.25 3.41421 2.25 3C2.25 2.58579 2.58579 2.25 3 2.25H15Z" fill="#676F83"/>
</svg>

Before

Width:  |  Height:  |  Size: 763 B

View File

@ -1,3 +0,0 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 15V3.75V12.2625V10.6688V15ZM2.5 16.5C1.94772 16.5 1.5 16.0523 1.5 15.5V3.25C1.5 2.69771 1.94772 2.25 2.5 2.25H15.5C16.0523 2.25 16.5 2.69772 16.5 3.25V10.5H15V3.75H3V15H9V16.5H2.5ZM13.0125 17.25L10.35 14.5875L11.4188 13.5375L13.0125 15.1312L16.2 11.9438L17.25 13.0125L13.0125 17.25ZM7.5 9.75H13.5V8.25H7.5V9.75ZM7.5 6.75H13.5V5.25H7.5V6.75ZM4.5 9.75H6V8.25H4.5V9.75ZM4.5 6.75H6V5.25H4.5V6.75Z" fill="#495464"/>
</svg>

Before

Width:  |  Height:  |  Size: 526 B

View File

@ -1,3 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.33317 3.33333H7.33317V12.6667H5.33317V14H10.6665V12.6667H8.6665V3.33333H10.6665V2H5.33317V3.33333ZM1.33317 4.66667C0.964984 4.66667 0.666504 4.96515 0.666504 5.33333V10.6667C0.666504 11.0349 0.964984 11.3333 1.33317 11.3333H5.33317V10H1.99984V6H5.33317V4.66667H1.33317ZM10.6665 6H13.9998V10H10.6665V11.3333H14.6665C15.0347 11.3333 15.3332 11.0349 15.3332 10.6667V5.33333C15.3332 4.96515 15.0347 4.66667 14.6665 4.66667H10.6665V6Z" fill="#354052"/>
</svg>

Before

Width:  |  Height:  |  Size: 563 B

View File

@ -513,27 +513,12 @@
"width": 14,
"height": 14
},
"line-others-dhs": {
"body": "<g fill=\"currentColor\"><path d=\"M3 2.25a.75.75 0 0 1 .75.75v12a.75.75 0 0 1-1.5 0V3A.75.75 0 0 1 3 2.25m12 0a.75.75 0 0 1 .75.75v12a.75.75 0 0 1-1.5 0V3a.75.75 0 0 1 .75-.75\"/><path fill-rule=\"evenodd\" d=\"M10.125 4.5a.75.75 0 0 1 .75.75v7.5a.75.75 0 0 1-.75.75h-2.25a.75.75 0 0 1-.75-.75v-7.5a.75.75 0 0 1 .75-.75zm-1.5 7.5h.75V6h-.75z\" clip-rule=\"evenodd\"/></g>",
"width": 18,
"height": 18
},
"line-others-drag-handle": {
"body": "<g fill=\"none\"><g id=\"Drag Handle\"><path id=\"drag-handle\" fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M6 5C6.55228 5 7 4.55228 7 4C7 3.44772 6.55228 3 6 3C5.44772 3 5 3.44772 5 4C5 4.55228 5.44772 5 6 5ZM6 9C6.55228 9 7 8.55228 7 8C7 7.44772 6.55228 7 6 7C5.44772 7 5 7.44772 5 8C5 8.55228 5.44772 9 6 9ZM11 4C11 4.55228 10.5523 5 10 5C9.44772 5 9 4.55228 9 4C9 3.44772 9.44772 3 10 3C10.5523 3 11 3.44772 11 4ZM10 9C10.5523 9 11 8.55228 11 8C11 7.44772 10.5523 7 10 7C9.44772 7 9 7.44772 9 8C9 8.55228 9.44772 9 10 9ZM7 12C7 12.5523 6.55228 13 6 13C5.44772 13 5 12.5523 5 12C5 11.4477 5.44772 11 6 11C6.55228 11 7 11.4477 7 12ZM10 13C10.5523 13 11 12.5523 11 12C11 11.4477 10.5523 11 10 11C9.44772 11 9 11.4477 9 12C9 12.5523 9.44772 13 10 13Z\" fill=\"currentColor\"/></g></g>"
},
"line-others-dvs": {
"body": "<g fill=\"currentColor\"><path d=\"M15 14.25a.75.75 0 0 1 0 1.5H3a.75.75 0 0 1 0-1.5z\"/><path fill-rule=\"evenodd\" d=\"M13.5 7.125a.75.75 0 0 1 .75.75v2.25a.75.75 0 0 1-.75.75h-9a.75.75 0 0 1-.75-.75v-2.25a.75.75 0 0 1 .75-.75zm-8.25 2.25h7.5v-.75h-7.5z\" clip-rule=\"evenodd\"/><path d=\"M15 2.25a.75.75 0 0 1 0 1.5H3a.75.75 0 0 1 0-1.5z\"/></g>",
"width": 18,
"height": 18
},
"line-others-env": {
"body": "<g fill=\"none\"><g id=\"env\"><g id=\"Vector\"><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M1.33325 3.33325C1.33325 2.22868 2.22868 1.33325 3.33325 1.33325H12.6666C13.7712 1.33325 14.6666 2.22869 14.6666 3.33325V3.66659C14.6666 4.03478 14.3681 4.33325 13.9999 4.33325C13.6317 4.33325 13.3333 4.03478 13.3333 3.66659V3.33325C13.3333 2.96506 13.0348 2.66659 12.6666 2.66659H3.33325C2.96506 2.66659 2.66659 2.96506 2.66659 3.33325V3.66659C2.66659 4.03478 2.36811 4.33325 1.99992 4.33325C1.63173 4.33325 1.33325 4.03478 1.33325 3.66659V3.33325Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M14.6666 12.6666C14.6666 13.7712 13.7712 14.6666 12.6666 14.6666L3.33325 14.6666C2.22866 14.6666 1.33325 13.7711 1.33325 12.6666L1.33325 12.3333C1.33325 11.9651 1.63173 11.6666 1.99992 11.6666C2.36811 11.6666 2.66659 11.9651 2.66659 12.3333V12.6666C2.66659 13.0348 2.96505 13.3333 3.33325 13.3333L12.6666 13.3333C13.0348 13.3333 13.3333 13.0348 13.3333 12.6666V12.3333C13.3333 11.9651 13.6317 11.6666 13.9999 11.6666C14.3681 11.6666 14.6666 11.9651 14.6666 12.3333V12.6666Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M1.33325 5.99992C1.33325 5.63173 1.63173 5.33325 1.99992 5.33325H4.33325C4.70144 5.33325 4.99992 5.63173 4.99992 5.99992C4.99992 6.36811 4.70144 6.66658 4.33325 6.66658H2.66659V7.33325H3.99992C4.36811 7.33325 4.66659 7.63173 4.66659 7.99992C4.66659 8.36811 4.36811 8.66658 3.99992 8.66658H2.66659V9.33325H4.33325C4.70144 9.33325 4.99992 9.63173 4.99992 9.99992C4.99992 10.3681 4.70144 10.6666 4.33325 10.6666H1.99992C1.63173 10.6666 1.33325 10.3681 1.33325 9.99992V5.99992Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M6.4734 5.36186C6.75457 5.27673 7.05833 5.38568 7.22129 5.63012L8.66659 7.79807V5.99992C8.66659 5.63173 8.96506 5.33325 9.33325 5.33325C9.70144 5.33325 9.99992 5.63173 9.99992 5.99992V9.99992C9.99992 10.2937 9.80761 10.5528 9.52644 10.638C9.24527 10.7231 8.94151 10.6142 8.77855 10.3697L7.33325 8.20177V9.99992C7.33325 10.3681 7.03478 10.6666 6.66659 10.6666C6.2984 10.6666 5.99992 10.3681 5.99992 9.99992V5.99992C5.99992 5.70614 6.19222 5.44699 6.4734 5.36186Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M11.0768 5.38453C11.4167 5.24292 11.807 5.40364 11.9486 5.74351L12.9999 8.26658L14.0512 5.74351C14.1928 5.40364 14.5831 5.24292 14.923 5.38453C15.2629 5.52614 15.4236 5.91646 15.282 6.25633L13.6153 10.2563C13.5118 10.5048 13.2691 10.6666 12.9999 10.6666C12.7308 10.6666 12.488 10.5048 12.3845 10.2563L10.7179 6.25633C10.5763 5.91646 10.737 5.52614 11.0768 5.38453Z\" fill=\"currentColor\"/></g></g></g>"
},
"line-others-evaluation": {
"body": "<path fill=\"currentColor\" d=\"M3 15V3.75v8.513v-1.594zm-.5 1.5a1 1 0 0 1-1-1V3.25a1 1 0 0 1 1-1h13a1 1 0 0 1 1 1v7.25H15V3.75H3V15h6v1.5zm10.513.75l-2.663-2.662l1.069-1.05l1.593 1.593l3.188-3.187l1.05 1.068zM7.5 9.75h6v-1.5h-6zm0-3h6v-1.5h-6zm-3 3H6v-1.5H4.5zm0-3H6v-1.5H4.5z\"/>",
"width": 18,
"height": 18
},
"line-others-global-variable": {
"body": "<g fill=\"none\"><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M6.23814 1.33333H9.76188C10.4844 1.33332 11.0672 1.33332 11.5391 1.37187C12.025 1.41157 12.4518 1.49545 12.8466 1.69664C13.4739 2.01622 13.9838 2.52615 14.3034 3.15336C14.5046 3.54822 14.5884 3.97501 14.6281 4.46091C14.6667 4.93283 14.6667 5.51559 14.6667 6.23811V9.76188C14.6667 10.4844 14.6667 11.0672 14.6281 11.5391C14.5884 12.025 14.5046 12.4518 14.3034 12.8466C13.9838 13.4738 13.4739 13.9838 12.8466 14.3033C12.4518 14.5045 12.025 14.5884 11.5391 14.6281C11.0672 14.6667 10.4844 14.6667 9.7619 14.6667H6.23812C5.51561 14.6667 4.93284 14.6667 4.46093 14.6281C3.97503 14.5884 3.54824 14.5045 3.15338 14.3033C2.52617 13.9838 2.01623 13.4738 1.69666 12.8466C1.49546 12.4518 1.41159 12.025 1.37189 11.5391C1.33333 11.0672 1.33334 10.4844 1.33334 9.76187V6.23812C1.33334 5.5156 1.33333 4.93283 1.37189 4.46091C1.41159 3.97501 1.49546 3.54822 1.69666 3.15336C2.01623 2.52615 2.52617 2.01622 3.15338 1.69664C3.54824 1.49545 3.97503 1.41157 4.46093 1.37187C4.93285 1.33332 5.51561 1.33332 6.23814 1.33333ZM4.5695 2.70078C4.16606 2.73374 3.93427 2.79519 3.7587 2.88465C3.38237 3.0764 3.07641 3.38236 2.88466 3.75868C2.79521 3.93425 2.73376 4.16604 2.70079 4.56949C2.6672 4.98072 2.66668 5.50892 2.66668 6.26666V9.73333C2.66668 10.4911 2.6672 11.0193 2.70079 11.4305C2.73376 11.8339 2.79521 12.0657 2.88466 12.2413C3.07641 12.6176 3.38237 12.9236 3.7587 13.1153C3.93427 13.2048 4.16606 13.2662 4.5695 13.2992C4.98073 13.3328 5.50894 13.3333 6.26668 13.3333H9.73334C10.4911 13.3333 11.0193 13.3328 11.4305 13.2992C11.834 13.2662 12.0658 13.2048 12.2413 13.1153C12.6176 12.9236 12.9236 12.6176 13.1154 12.2413C13.2048 12.0657 13.2663 11.8339 13.2992 11.4305C13.3328 11.0193 13.3333 10.4911 13.3333 9.73333V6.26666C13.3333 5.50892 13.3328 4.98072 13.2992 4.56949C13.2663 4.16604 13.2048 3.93425 13.1154 3.75868C12.9236 3.38236 12.6176 3.0764 12.2413 2.88465C12.0658 2.79519 11.834 2.73374 11.4305 2.70078C11.0193 2.66718 10.4911 2.66666 9.73334 2.66666H6.26668C5.50894 2.66666 4.98073 2.66718 4.5695 2.70078ZM5.08339 5.33333C5.08339 4.96514 5.38187 4.66666 5.75006 4.66666H6.68433C7.324 4.66666 7.87606 5.09677 8.04724 5.70542L8.30138 6.60902L9.2915 5.43554C9.7018 4.94926 10.3035 4.66666 10.9399 4.66666H11C11.3682 4.66666 11.6667 4.96514 11.6667 5.33333C11.6667 5.70152 11.3682 5.99999 11 5.99999H10.9399C10.7005 5.99999 10.4702 6.10616 10.3106 6.29537L8.73751 8.15972L9.23641 9.93357C9.24921 9.97909 9.28574 10 9.31579 10H10.2501C10.6182 10 10.9167 10.2985 10.9167 10.6667C10.9167 11.0349 10.6182 11.3333 10.2501 11.3333H9.31579C8.67612 11.3333 8.12406 10.9032 7.95288 10.2946L7.69871 9.39088L6.70852 10.5644C6.29822 11.0507 5.6965 11.3333 5.06011 11.3333H5.00001C4.63182 11.3333 4.33334 11.0349 4.33334 10.6667C4.33334 10.2985 4.63182 10 5.00001 10H5.06011C5.29949 10 5.52982 9.89383 5.68946 9.70462L7.26258 7.84019L6.76371 6.06642C6.75091 6.0209 6.71438 5.99999 6.68433 5.99999H5.75006C5.38187 5.99999 5.08339 5.70152 5.08339 5.33333Z\" fill=\"currentColor\"/></g>"
},
@ -1040,11 +1025,6 @@
"workflow-if-else": {
"body": "<g fill=\"none\"><g id=\"icons/if-else\"><path id=\"Vector (Stroke)\" fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M8.16667 2.98975C7.80423 2.98975 7.51042 2.69593 7.51042 2.3335C7.51042 1.97106 7.80423 1.67725 8.16667 1.67725H11.0833C11.4458 1.67725 11.7396 1.97106 11.7396 2.3335V5.25016C11.7396 5.6126 11.4458 5.90641 11.0833 5.90641C10.7209 5.90641 10.4271 5.6126 10.4271 5.25016V3.91782L7.34474 7.00016L10.4271 10.0825V8.75016C10.4271 8.38773 10.7209 8.09391 11.0833 8.09391C11.4458 8.09391 11.7396 8.38773 11.7396 8.75016V11.6668C11.7396 12.0293 11.4458 12.3231 11.0833 12.3231H8.16667C7.80423 12.3231 7.51042 12.0293 7.51042 11.6668C7.51042 11.3044 7.80423 11.0106 8.16667 11.0106H9.49901L6.14484 7.65641H1.75C1.38756 7.65641 1.09375 7.3626 1.09375 7.00016C1.09375 6.63773 1.38756 6.34391 1.75 6.34391H6.14484L9.49901 2.98975H8.16667Z\" fill=\"currentColor\"/></g></g>"
},
"workflow-input-field": {
"body": "<path fill=\"currentColor\" d=\"M5.333 3.333h2v9.334h-2V14h5.333v-1.333h-2V3.333h2V2H5.333zm-4 1.334a.667.667 0 0 0-.666.666v5.334c0 .368.298.666.666.666h4V10H2V6h3.333V4.667zM10.667 6H14v4h-3.334v1.333h4a.667.667 0 0 0 .667-.666V5.333a.667.667 0 0 0-.667-.666h-4z\"/>",
"width": 16,
"height": 16
},
"workflow-iteration": {
"body": "<g fill=\"none\"><g id=\"icons/iteration\"><path id=\"Vector\" d=\"M6.82849 0.754349C6.6007 0.526545 6.23133 0.526545 6.00354 0.754349C5.77573 0.982158 5.77573 1.3515 6.00354 1.57931L6.82849 0.754349ZM8.16602 2.91683L8.57849 3.32931C8.80628 3.1015 8.80628 2.73216 8.57849 2.50435L8.16602 2.91683ZM6.00354 4.25435C5.77573 4.48216 5.77573 4.8515 6.00354 5.07931C6.23133 5.30711 6.6007 5.30711 6.82849 5.07931L6.00354 4.25435ZM7.99516 9.74597C8.22295 9.51818 8.22295 9.14881 7.99516 8.92102C7.76737 8.69323 7.398 8.69323 7.17021 8.92102L7.99516 9.74597ZM5.83268 11.0835L5.4202 10.671C5.1924 10.8988 5.1924 11.2682 5.4202 11.496L5.83268 11.0835ZM7.17021 13.246C7.398 13.4738 7.76737 13.4738 7.99516 13.246C8.22295 13.0182 8.22295 12.6488 7.99516 12.421L7.17021 13.246ZM11.4993 3.73414C11.2738 3.50404 10.9045 3.5003 10.6744 3.72578C10.4443 3.95127 10.4405 4.32059 10.6661 4.55069L11.4993 3.73414ZM7.58268 3.50016C7.90486 3.50016 8.16602 3.23899 8.16602 2.91683C8.16602 2.59467 7.90486 2.3335 7.58268 2.3335L7.58268 3.50016ZM2.49938 10.2662C2.72486 10.4963 3.09419 10.5 3.32429 10.2745C3.55439 10.0491 3.55814 9.6797 3.33266 9.44964L2.49938 10.2662ZM6.00354 1.57931L7.75354 3.32931L8.57849 2.50435L6.82849 0.754349L6.00354 1.57931ZM7.75354 2.50435L6.00354 4.25435L6.82849 5.07931L8.57849 3.32931L7.75354 2.50435ZM7.17021 8.92102L5.4202 10.671L6.24516 11.496L7.99516 9.74597L7.17021 8.92102ZM5.4202 11.496L7.17021 13.246L7.99516 12.421L6.24516 10.671L5.4202 11.496ZM8.16602 10.5002L6.41602 10.5002V11.6668L8.16602 11.6668V10.5002ZM11.666 7.00016C11.666 8.93316 10.099 10.5002 8.16602 10.5002V11.6668C10.7434 11.6668 12.8327 9.57751 12.8327 7.00016H11.666ZM12.8327 7.00016C12.8327 5.72882 12.3235 4.57524 11.4993 3.73414L10.6661 4.55069C11.2852 5.18256 11.666 6.0463 11.666 7.00016H12.8327ZM5.83268 3.50016H7.58268L7.58268 2.3335H5.83268L5.83268 3.50016ZM2.33268 7.00016C2.33268 5.06717 3.89968 3.50016 5.83268 3.50016L5.83268 2.3335C3.25535 2.3335 1.16602 4.42283 1.16602 7.00016H2.33268ZM1.16602 7.00016C1.16602 8.27148 1.67517 9.42508 2.49938 10.2662L3.33266 9.44964C2.71348 8.81777 2.33268 7.95403 2.33268 7.00016H1.16602Z\" fill=\"currentColor\"/></g></g>"
},

View File

@ -1,7 +1,7 @@
{
"prefix": "custom-vender",
"name": "Dify Custom Vender",
"total": 281,
"total": 277,
"version": "0.0.0-private",
"author": {
"name": "LangGenius, Inc.",

View File

@ -340,11 +340,16 @@ describe('App List Browsing Flow', () => {
// -- Tab navigation --
describe('Tab Navigation', () => {
it('should render the app type dropdown trigger', () => {
it('should render all category tabs', () => {
mockPages = [createPage([createMockApp()])]
renderList()
expect(screen.getByText('app.studio.filters.types')).toBeInTheDocument()
expect(screen.getByText('app.types.all')).toBeInTheDocument()
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
expect(screen.getByText('app.types.advanced')).toBeInTheDocument()
expect(screen.getByText('app.types.chatbot')).toBeInTheDocument()
expect(screen.getByText('app.types.agent')).toBeInTheDocument()
expect(screen.getByText('app.types.completion')).toBeInTheDocument()
})
})
@ -375,19 +380,21 @@ describe('App List Browsing Flow', () => {
// -- "Created by me" filter --
describe('Created By Me Filter', () => {
it('should not render a standalone "created by me" checkbox in the current header layout', () => {
it('should render the "created by me" checkbox', () => {
mockPages = [createPage([createMockApp()])]
renderList()
expect(screen.queryByText('app.showMyCreatedAppsOnly')).not.toBeInTheDocument()
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
})
it('should keep the current layout stable without a "created by me" control', () => {
it('should toggle the "created by me" filter on click', () => {
mockPages = [createPage([createMockApp()])]
renderList()
expect(screen.getByText('app.studio.filters.types')).toBeInTheDocument()
expect(screen.queryByText('app.showMyCreatedAppsOnly')).not.toBeInTheDocument()
const checkbox = screen.getByText('app.showMyCreatedAppsOnly')
fireEvent.click(checkbox)
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
})
})

View File

@ -6,7 +6,7 @@ import Loading from '@/app/components/base/loading'
import { useAppContext } from '@/context/app-context'
import { usePathname, useRouter } from '@/next/navigation'
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/snippets', '/explore', '/tools'] as const
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/explore', '/tools'] as const
const isPathUnderRoute = (pathname: string, route: string) => pathname === route || pathname.startsWith(`${route}/`)

View File

@ -1,11 +0,0 @@
import SnippetPage from '@/app/components/snippets'
const Page = async (props: {
params: Promise<{ snippetId: string }>
}) => {
const { snippetId } = await props.params
return <SnippetPage snippetId={snippetId} />
}
export default Page

View File

@ -1,21 +0,0 @@
import Page from './page'
const mockRedirect = vi.fn()
vi.mock('next/navigation', () => ({
redirect: (path: string) => mockRedirect(path),
}))
describe('snippet detail redirect page', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should redirect legacy snippet detail routes to orchestrate', async () => {
await Page({
params: Promise.resolve({ snippetId: 'snippet-1' }),
})
expect(mockRedirect).toHaveBeenCalledWith('/snippets/snippet-1/orchestrate')
})
})

View File

@ -1,11 +0,0 @@
import { redirect } from 'next/navigation'
const Page = async (props: {
params: Promise<{ snippetId: string }>
}) => {
const { snippetId } = await props.params
redirect(`/snippets/${snippetId}/orchestrate`)
}
export default Page

View File

@ -1,7 +0,0 @@
import SnippetList from '@/app/components/snippet-list'
const SnippetsPage = () => {
return <SnippetList />
}
export default SnippetsPage

View File

@ -168,21 +168,6 @@ describe('AppDetailNav', () => {
)
expect(screen.queryByTestId('extra-info')).not.toBeInTheDocument()
})
it('should render custom header and navigation when provided', () => {
render(
<AppDetailNav
navigation={navigation}
renderHeader={mode => <div data-testid="custom-header" data-mode={mode} />}
renderNavigation={mode => <div data-testid="custom-navigation" data-mode={mode} />}
/>,
)
expect(screen.getByTestId('custom-header')).toHaveAttribute('data-mode', 'expand')
expect(screen.getByTestId('custom-navigation')).toHaveAttribute('data-mode', 'expand')
expect(screen.queryByTestId('app-info')).not.toBeInTheDocument()
expect(screen.queryByTestId('nav-link-Overview')).not.toBeInTheDocument()
})
})
describe('Workflow canvas mode', () => {

View File

@ -28,16 +28,12 @@ type IAppDetailNavProps = {
disabled?: boolean
}>
extraInfo?: (modeState: string) => React.ReactNode
renderHeader?: (modeState: string) => React.ReactNode
renderNavigation?: (modeState: string) => React.ReactNode
appInfoActions?: AppInfoActions
}
const AppDetailNav = ({
navigation,
extraInfo,
renderHeader,
renderNavigation,
iconType = 'app',
appInfoActions,
}: IAppDetailNavProps) => {
@ -116,20 +112,18 @@ const AppDetailNav = ({
expand ? 'p-2' : 'p-1',
)}
>
{renderHeader
? renderHeader(appSidebarExpand)
: iconType === 'app' && (
appInfoActions
? (
<AppInfoView
expand={expand}
actions={appInfoActions}
renderDetail={false}
/>
)
: <AppInfo expand={expand} />
)}
{!renderHeader && iconType !== 'app' && (
{iconType === 'app' && (
appInfoActions
? (
<AppInfoView
expand={expand}
actions={appInfoActions}
renderDetail={false}
/>
)
: <AppInfo expand={expand} />
)}
{iconType !== 'app' && (
<DatasetInfo expand={expand} />
)}
</div>
@ -158,20 +152,18 @@ const AppDetailNav = ({
expand ? 'px-3 py-2' : 'p-3',
)}
>
{renderNavigation
? renderNavigation(appSidebarExpand)
: navigation.map((item, index) => {
return (
<NavLink
key={index}
mode={appSidebarExpand}
iconMap={{ selected: item.selectedIcon, normal: item.icon }}
name={item.name}
href={item.href}
disabled={!!item.disabled}
/>
)
})}
{navigation.map((item, index) => {
return (
<NavLink
key={index}
mode={appSidebarExpand}
iconMap={{ selected: item.selectedIcon, normal: item.icon }}
name={item.name}
href={item.href}
disabled={!!item.disabled}
/>
)
})}
</nav>
{iconType !== 'app' && extraInfo && extraInfo(appSidebarExpand)}
</div>

View File

@ -262,20 +262,4 @@ describe('NavLink Animation and Layout Issues', () => {
expect(iconWrapper).toHaveClass('-ml-1')
})
})
describe('Button Mode', () => {
it('should render as an interactive button when href is omitted', () => {
const onClick = vi.fn()
render(<NavLink {...mockProps} href={undefined} active={true} onClick={onClick} />)
const buttonElement = screen.getByText('Orchestrate').closest('button')
expect(buttonElement).not.toBeNull()
expect(buttonElement).toHaveClass('bg-components-menu-item-bg-active')
expect(buttonElement).toHaveClass('text-text-accent-light-mode-only')
buttonElement?.click()
expect(onClick).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -14,15 +14,13 @@ export type NavIcon = React.ComponentType<
export type NavLinkProps = {
name: string
href?: string
href: string
iconMap: {
selected: NavIcon
normal: NavIcon
}
mode?: string
disabled?: boolean
active?: boolean
onClick?: () => void
}
const NavLink = ({
@ -31,8 +29,6 @@ const NavLink = ({
iconMap,
mode = 'expand',
disabled = false,
active,
onClick,
}: NavLinkProps) => {
const segment = useSelectedLayoutSegment()
const formattedSegment = (() => {
@ -43,11 +39,8 @@ const NavLink = ({
return res
})()
const isActive = active ?? (href ? href.toLowerCase().split('/')?.pop() === formattedSegment : false)
const isActive = href.toLowerCase().split('/')?.pop() === formattedSegment
const NavIcon = isActive ? iconMap.selected : iconMap.normal
const linkClassName = cn(isActive
? 'border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only system-sm-semibold'
: 'text-components-menu-item-text system-sm-medium hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pl-3 pr-1')
const renderIcon = () => (
<div className={cn(mode !== 'expand' && '-ml-1')}>
@ -77,32 +70,13 @@ const NavLink = ({
)
}
if (!href) {
return (
<button
key={name}
type="button"
className={linkClassName}
title={mode === 'collapse' ? name : ''}
onClick={onClick}
>
{renderIcon()}
<span
className={cn('overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out', mode === 'expand'
? 'ml-2 max-w-none opacity-100'
: 'ml-0 max-w-0 opacity-0')}
>
{name}
</span>
</button>
)
}
return (
<Link
key={name}
href={href}
className={linkClassName}
className={cn(isActive
? 'border-t-[0.75px] border-r-[0.25px] border-b-[0.25px] border-l-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active system-sm-semibold text-text-accent-light-mode-only'
: 'system-sm-medium text-components-menu-item-text hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pr-1 pl-3')}
title={mode === 'collapse' ? name : ''}
>
{renderIcon()}

View File

@ -1,270 +0,0 @@
import type { CreateSnippetDialogPayload } from '@/app/components/snippets/create-snippet-dialog'
import type { SnippetDetail } from '@/models/snippet'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import SnippetInfoDropdown from '../dropdown'
const mockReplace = vi.fn()
const mockDownloadBlob = vi.fn()
const mockToastSuccess = vi.fn()
const mockToastError = vi.fn()
const mockUpdateMutate = vi.fn()
const mockExportMutateAsync = vi.fn()
const mockDeleteMutate = vi.fn()
let mockDropdownOpen = false
let mockDropdownOnOpenChange: ((open: boolean) => void) | undefined
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
replace: mockReplace,
}),
}))
vi.mock('@/utils/download', () => ({
downloadBlob: (args: { data: Blob, fileName: string }) => mockDownloadBlob(args),
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
success: (...args: unknown[]) => mockToastSuccess(...args),
error: (...args: unknown[]) => mockToastError(...args),
},
}))
vi.mock('@langgenius/dify-ui/dropdown-menu', () => ({
DropdownMenu: ({
open,
onOpenChange,
children,
}: {
open?: boolean
onOpenChange?: (open: boolean) => void
children: React.ReactNode
}) => {
mockDropdownOpen = !!open
mockDropdownOnOpenChange = onOpenChange
return <div>{children}</div>
},
DropdownMenuTrigger: ({
children,
className,
}: {
children: React.ReactNode
className?: string
}) => (
<button
type="button"
className={className}
onClick={() => mockDropdownOnOpenChange?.(!mockDropdownOpen)}
>
{children}
</button>
),
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
mockDropdownOpen ? <div>{children}</div> : null
),
DropdownMenuItem: ({
children,
onClick,
}: {
children: React.ReactNode
onClick?: () => void
}) => (
<button type="button" onClick={onClick}>
{children}
</button>
),
DropdownMenuSeparator: () => <hr />,
}))
vi.mock('@/service/use-snippets', () => ({
useUpdateSnippetMutation: () => ({
mutate: mockUpdateMutate,
isPending: false,
}),
useExportSnippetMutation: () => ({
mutateAsync: mockExportMutateAsync,
isPending: false,
}),
useDeleteSnippetMutation: () => ({
mutate: mockDeleteMutate,
isPending: false,
}),
}))
type MockCreateSnippetDialogProps = {
isOpen: boolean
title?: string
confirmText?: string
initialValue?: {
name?: string
description?: string
}
onClose: () => void
onConfirm: (payload: CreateSnippetDialogPayload) => void
}
vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({
default: ({
isOpen,
title,
confirmText,
initialValue,
onClose,
onConfirm,
}: MockCreateSnippetDialogProps) => {
if (!isOpen)
return null
return (
<div data-testid="create-snippet-dialog">
<div>{title}</div>
<div>{confirmText}</div>
<div>{initialValue?.name}</div>
<div>{initialValue?.description}</div>
<button
type="button"
onClick={() => onConfirm({
name: 'Updated snippet',
description: 'Updated description',
graph: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
})}
>
submit-edit
</button>
<button type="button" onClick={onClose}>close-edit</button>
</div>
)
},
}))
const mockSnippet: SnippetDetail = {
id: 'snippet-1',
name: 'Social Media Repurposer',
description: 'Turn one blog post into multiple social media variations.',
updatedAt: '2026-03-25 10:00',
usage: '12',
tags: [],
status: undefined,
}
describe('SnippetInfoDropdown', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDropdownOpen = false
mockDropdownOnOpenChange = undefined
})
// Rendering coverage for the menu trigger itself.
describe('Rendering', () => {
it('should render the dropdown trigger button', () => {
render(<SnippetInfoDropdown snippet={mockSnippet} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
// Edit flow should seed the dialog with current snippet info and submit updates.
describe('Edit Snippet', () => {
it('should open the edit dialog and submit snippet updates', async () => {
const user = userEvent.setup()
mockUpdateMutate.mockImplementation((_variables: unknown, options?: { onSuccess?: () => void }) => {
options?.onSuccess?.()
})
render(<SnippetInfoDropdown snippet={mockSnippet} />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('snippet.menu.editInfo'))
expect(screen.getByTestId('create-snippet-dialog')).toBeInTheDocument()
expect(screen.getByText('snippet.editDialogTitle')).toBeInTheDocument()
expect(screen.getByText('common.operation.save')).toBeInTheDocument()
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
expect(screen.getByText(mockSnippet.description)).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'submit-edit' }))
expect(mockUpdateMutate).toHaveBeenCalledWith({
params: { snippetId: mockSnippet.id },
body: {
name: 'Updated snippet',
description: 'Updated description',
},
}, expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}))
expect(mockToastSuccess).toHaveBeenCalledWith('snippet.editDone')
})
})
// Export should call the export hook and download the returned YAML blob.
describe('Export Snippet', () => {
it('should export and download the snippet yaml', async () => {
const user = userEvent.setup()
mockExportMutateAsync.mockResolvedValue('yaml: content')
render(<SnippetInfoDropdown snippet={mockSnippet} />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('snippet.menu.exportSnippet'))
await waitFor(() => {
expect(mockExportMutateAsync).toHaveBeenCalledWith({ snippetId: mockSnippet.id })
})
expect(mockDownloadBlob).toHaveBeenCalledWith({
data: expect.any(Blob),
fileName: `${mockSnippet.name}.yml`,
})
})
it('should show an error toast when export fails', async () => {
const user = userEvent.setup()
mockExportMutateAsync.mockRejectedValue(new Error('export failed'))
render(<SnippetInfoDropdown snippet={mockSnippet} />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('snippet.menu.exportSnippet'))
await waitFor(() => {
expect(mockToastError).toHaveBeenCalledWith('snippet.exportFailed')
})
})
})
// Delete should require confirmation and redirect after a successful mutation.
describe('Delete Snippet', () => {
it('should confirm deletion and redirect to the snippets list', async () => {
const user = userEvent.setup()
mockDeleteMutate.mockImplementation((_variables: unknown, options?: { onSuccess?: () => void }) => {
options?.onSuccess?.()
})
render(<SnippetInfoDropdown snippet={mockSnippet} />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('snippet.menu.deleteSnippet'))
expect(screen.getByText('snippet.deleteConfirmTitle')).toBeInTheDocument()
expect(screen.getByText('snippet.deleteConfirmContent')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'snippet.menu.deleteSnippet' }))
expect(mockDeleteMutate).toHaveBeenCalledWith({
params: { snippetId: mockSnippet.id },
}, expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}))
expect(mockToastSuccess).toHaveBeenCalledWith('snippet.deleted')
expect(mockReplace).toHaveBeenCalledWith('/snippets')
})
})
})

View File

@ -1,60 +0,0 @@
import type { SnippetDetail } from '@/models/snippet'
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import SnippetInfo from '..'
vi.mock('../dropdown', () => ({
default: () => <div data-testid="snippet-info-dropdown" />,
}))
const mockSnippet: SnippetDetail = {
id: 'snippet-1',
name: 'Social Media Repurposer',
description: 'Turn one blog post into multiple social media variations.',
updatedAt: '2026-03-25 10:00',
usage: '12',
tags: [],
status: undefined,
}
describe('SnippetInfo', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering tests for the collapsed and expanded sidebar header states.
describe('Rendering', () => {
it('should render the expanded snippet details and dropdown when expand is true', () => {
render(<SnippetInfo expand={true} snippet={mockSnippet} />)
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
expect(screen.getByText('snippet.typeLabel')).toBeInTheDocument()
expect(screen.getByText(mockSnippet.description)).toBeInTheDocument()
expect(screen.getByTestId('snippet-info-dropdown')).toBeInTheDocument()
})
it('should hide the expanded-only content when expand is false', () => {
render(<SnippetInfo expand={false} snippet={mockSnippet} />)
expect(screen.queryByText(mockSnippet.name)).not.toBeInTheDocument()
expect(screen.queryByText('snippet.typeLabel')).not.toBeInTheDocument()
expect(screen.queryByText(mockSnippet.description)).not.toBeInTheDocument()
expect(screen.queryByTestId('snippet-info-dropdown')).not.toBeInTheDocument()
})
})
// Edge cases around optional snippet fields should not break the header layout.
describe('Edge Cases', () => {
it('should omit the description block when the snippet has no description', () => {
render(
<SnippetInfo
expand={true}
snippet={{ ...mockSnippet, description: '' }}
/>,
)
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
expect(screen.queryByText(mockSnippet.description)).not.toBeInTheDocument()
})
})
})

View File

@ -1,177 +0,0 @@
'use client'
import type { SnippetDetail } from '@/models/snippet'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { toast } from '@langgenius/dify-ui/toast'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog'
import { useRouter } from '@/next/navigation'
import { useDeleteSnippetMutation, useExportSnippetMutation, useUpdateSnippetMutation } from '@/service/use-snippets'
import { downloadBlob } from '@/utils/download'
type SnippetInfoDropdownProps = {
snippet: SnippetDetail
}
const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => {
const { t } = useTranslation('snippet')
const { replace } = useRouter()
const [open, setOpen] = React.useState(false)
const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false)
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false)
const updateSnippetMutation = useUpdateSnippetMutation()
const exportSnippetMutation = useExportSnippetMutation()
const deleteSnippetMutation = useDeleteSnippetMutation()
const initialValue = React.useMemo(() => ({
name: snippet.name,
description: snippet.description,
}), [snippet.description, snippet.name])
const handleOpenEditDialog = React.useCallback(() => {
setOpen(false)
setIsEditDialogOpen(true)
}, [])
const handleExportSnippet = React.useCallback(async () => {
setOpen(false)
try {
const data = await exportSnippetMutation.mutateAsync({ snippetId: snippet.id })
const file = new Blob([data], { type: 'application/yaml' })
downloadBlob({ data: file, fileName: `${snippet.name}.yml` })
}
catch {
toast.error(t('exportFailed'))
}
}, [exportSnippetMutation, snippet.id, snippet.name, t])
const handleEditSnippet = React.useCallback(async ({ name, description }: {
name: string
description: string
}) => {
updateSnippetMutation.mutate({
params: { snippetId: snippet.id },
body: {
name,
description: description || undefined,
},
}, {
onSuccess: () => {
toast.success(t('editDone'))
setIsEditDialogOpen(false)
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : t('editFailed'))
},
})
}, [snippet.id, t, updateSnippetMutation])
const handleDeleteSnippet = React.useCallback(() => {
deleteSnippetMutation.mutate({
params: { snippetId: snippet.id },
}, {
onSuccess: () => {
toast.success(t('deleted'))
setIsDeleteDialogOpen(false)
replace('/snippets')
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : t('deleteFailed'))
},
})
}, [deleteSnippetMutation, replace, snippet.id, t])
return (
<>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
className={cn('action-btn action-btn-m size-6 rounded-md text-text-tertiary', open && 'bg-state-base-hover text-text-secondary')}
>
<span aria-hidden className="i-ri-more-fill size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="w-[180px] p-1"
>
<DropdownMenuItem className="mx-0 gap-2" onClick={handleOpenEditDialog}>
<span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" />
<span className="grow">{t('menu.editInfo')}</span>
</DropdownMenuItem>
<DropdownMenuItem className="mx-0 gap-2" onClick={handleExportSnippet}>
<span aria-hidden className="i-ri-download-2-line size-4 shrink-0 text-text-tertiary" />
<span className="grow">{t('menu.exportSnippet')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator className="my-1! bg-divider-subtle" />
<DropdownMenuItem
className="mx-0 gap-2"
variant="destructive"
onClick={() => {
setOpen(false)
setIsDeleteDialogOpen(true)
}}
>
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
<span className="grow">{t('menu.deleteSnippet')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{isEditDialogOpen && (
<CreateSnippetDialog
isOpen={isEditDialogOpen}
initialValue={initialValue}
title={t('editDialogTitle')}
confirmText={t('operation.save', { ns: 'common' })}
isSubmitting={updateSnippetMutation.isPending}
onClose={() => setIsEditDialogOpen(false)}
onConfirm={handleEditSnippet}
/>
)}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent className="w-100">
<div className="space-y-2 p-6">
<AlertDialogTitle className="title-md-semi-bold text-text-primary">
{t('deleteConfirmTitle')}
</AlertDialogTitle>
<AlertDialogDescription className="system-sm-regular text-text-tertiary">
{t('deleteConfirmContent')}
</AlertDialogDescription>
</div>
<AlertDialogActions className="pt-0">
<AlertDialogCancelButton>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
loading={deleteSnippetMutation.isPending}
onClick={handleDeleteSnippet}
>
{t('menu.deleteSnippet')}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}
export default React.memo(SnippetInfoDropdown)

View File

@ -1,46 +0,0 @@
'use client'
import type { SnippetDetail } from '@/models/snippet'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import SnippetInfoDropdown from './dropdown'
type SnippetInfoProps = {
expand: boolean
snippet: SnippetDetail
}
const SnippetInfo = ({
expand,
snippet,
}: SnippetInfoProps) => {
const { t } = useTranslation('snippet')
if (!expand)
return null
return (
<div className="flex flex-col px-2 pt-2 pb-1">
<div className="flex flex-col gap-2 rounded-xl p-2">
<div className="flex items-center justify-end">
<SnippetInfoDropdown snippet={snippet} />
</div>
<div className="min-w-0">
<div className="truncate system-md-semibold text-text-secondary">
{snippet.name}
</div>
<div className="pt-1 system-2xs-medium-uppercase text-text-tertiary">
{t('typeLabel')}
</div>
</div>
{snippet.description && (
<p className="line-clamp-3 system-xs-regular break-words text-text-tertiary">
{snippet.description}
</p>
)}
</div>
</div>
)
}
export default React.memo(SnippetInfo)

View File

@ -1,6 +1,7 @@
import type { AppPublisherProps, AppPublisherPublishParams } from '@/app/components/app/app-publisher'
import type { Features, FileUpload } from '@/app/components/base/features/types'
import type { ModelConfig } from '@/models/debug'
import type { AppPublisherProps } from '@/app/components/app/app-publisher'
import type { ModelAndParameter } from '@/app/components/app/configuration/debug/types'
import type { FileUpload } from '@/app/components/base/features/types'
import type { PublishWorkflowParams } from '@/types/workflow'
import {
AlertDialog,
AlertDialogActions,
@ -20,15 +21,9 @@ import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { Resolution } from '@/types/app'
type PublishedModelConfig = ModelConfig & {
resetAppConfig?: () => void
}
type Props = Omit<AppPublisherProps, 'onPublish'> & {
onPublish?: (params?: AppPublisherPublishParams, features?: Features) => Promise<unknown> | unknown
publishedConfig: {
modelConfig: PublishedModelConfig
}
onPublish?: (params?: ModelAndParameter | PublishWorkflowParams, features?: any) => Promise<any> | any
publishedConfig?: any
resetAppConfig?: () => void
}
@ -76,7 +71,7 @@ const FeaturesWrappedAppPublisher = (props: Props) => {
setRestoreConfirmOpen(false)
}, [featuresStore, props])
const handlePublish = useCallback((params?: AppPublisherPublishParams) => {
const handlePublish = useCallback((params?: ModelAndParameter | PublishWorkflowParams) => {
return props.onPublish?.(params, features)
}, [features, props])

View File

@ -85,10 +85,8 @@ export type AppPublisherProps = {
const PUBLISH_SHORTCUT = ['Mod', 'Shift', 'P']
export type AppPublisherPublishParams = ModelAndParameter | PublishWorkflowParams
type AppPublisherPublishHandler
= | ((params?: AppPublisherPublishParams) => Promise<unknown> | unknown)
= | ((params?: ModelAndParameter | PublishWorkflowParams) => Promise<unknown> | unknown)
| ((params?: unknown) => Promise<unknown> | unknown)
type AppPublisherRestoreHandler = () => Promise<unknown> | unknown

View File

@ -211,12 +211,6 @@ describe('ConfigModalFormFields', () => {
expect(docLink).toHaveAttribute('rel', 'noopener noreferrer')
textInputView.unmount()
const hiddenFieldDisabledProps = createBaseProps()
const hiddenFieldDisabledView = render(<ConfigModalFormFields {...hiddenFieldDisabledProps} showHiddenField={false} />)
expect(screen.queryByText('variableConfig.hidden')).not.toBeInTheDocument()
expect(screen.queryByText('variableConfig.hiddenDescription')).not.toBeInTheDocument()
hiddenFieldDisabledView.unmount()
const singleFileProps = createBaseProps()
singleFileProps.tempPayload = {
...singleFileProps.tempPayload,

View File

@ -49,7 +49,6 @@ type ConfigModalFormFieldsProps = {
onVarNameChange: (event: ChangeEvent<HTMLInputElement>) => void
options?: string[]
selectOptions: SelectOptionItem[]
showHiddenField?: boolean
tempPayload: InputVar
t: Translate
}
@ -68,7 +67,6 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
onVarNameChange,
options,
selectOptions,
showHiddenField = true,
tempPayload,
t,
}) => {
@ -244,7 +242,7 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
<span className="system-sm-semibold text-text-secondary">{t('variableConfig.required', { ns: 'appDebug' })}</span>
</label>
{showHiddenField && !isFileInput && (
{!isFileInput && (
<div className="mt-5! flex h-6 items-center gap-2">
<label className="flex items-center gap-2">
<Checkbox

View File

@ -33,7 +33,6 @@ type IConfigModalProps = {
onClose: () => void
onConfirm: (newValue: InputVar, moreInfo?: MoreInfo) => void
supportFile?: boolean
showHiddenField?: boolean
}
const ConfigModal: FC<IConfigModalProps> = ({
@ -42,7 +41,6 @@ const ConfigModal: FC<IConfigModalProps> = ({
isShow,
onClose,
onConfirm,
showHiddenField,
supportFile,
}) => {
const { modelConfig } = useContext(ConfigContext)
@ -175,7 +173,6 @@ const ConfigModal: FC<IConfigModalProps> = ({
onVarNameChange={handleVarNameChange}
options={options}
selectOptions={selectOptions}
showHiddenField={showHiddenField}
tempPayload={tempPayload}
t={t}
/>

View File

@ -96,7 +96,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
const [model, setModel] = React.useState<Model>(localModel || {
name: '',
provider: '',
mode: mode as unknown as ModelModeType,
mode: mode as unknown as ModelModeType.chat,
completion_params: {} as CompletionParams,
})
const {

View File

@ -78,7 +78,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
const [model, setModel] = React.useState<Model>(localModel || {
name: '',
provider: '',
mode: mode as unknown as ModelModeType,
mode: mode as unknown as ModelModeType.chat,
completion_params: defaultCompletionParams,
})
const {

View File

@ -1,6 +1,5 @@
'use client'
import type { ComponentProps } from 'react'
import type { AppPublisherPublishParams } from '@/app/components/app/app-publisher'
import type AppPublisher from '@/app/components/app/app-publisher/features-wrapper'
import type { ModelAndParameter } from '@/app/components/app/configuration/debug/types'
import type { Features as FeaturesData, OnFeaturesChange } from '@/app/components/base/features/types'
@ -22,6 +21,7 @@ import type {
TextToSpeechConfig,
} from '@/models/debug'
import type { VisionSettings } from '@/types/app'
import type { PublishWorkflowParams } from '@/types/workflow'
import { useBoolean, useGetState } from 'ahooks'
import { clone } from 'es-toolkit/object'
import { produce } from 'immer'
@ -481,7 +481,7 @@ export const useConfiguration = (): ConfigurationViewModel => {
resolvedModelModeType,
])
const onPublish = useCallback(async (params?: AppPublisherPublishParams, features?: FeaturesData) => {
const onPublish = useCallback(async (params?: ModelAndParameter | PublishWorkflowParams, features?: FeaturesData) => {
const modelAndParameter = params && 'model' in params && 'provider' in params && 'parameters' in params
? params
: undefined

View File

@ -346,40 +346,29 @@ function AppTypeCard({ icon, title, description, active, onClick }: AppTypeCardP
function AppPreview({ mode }: { mode: AppModeEnum }) {
const { t } = useTranslation()
const previewInfo = (() => {
switch (mode) {
case AppModeEnum.CHAT:
return {
title: t('types.chatbot', { ns: 'app' }),
description: t('newApp.chatbotUserDescription', { ns: 'app' }),
}
case AppModeEnum.ADVANCED_CHAT:
return {
title: t('types.advanced', { ns: 'app' }),
description: t('newApp.advancedUserDescription', { ns: 'app' }),
}
case AppModeEnum.AGENT_CHAT:
return {
title: t('types.agent', { ns: 'app' }),
description: t('newApp.agentUserDescription', { ns: 'app' }),
}
case AppModeEnum.COMPLETION:
return {
title: t('newApp.completeApp', { ns: 'app' }),
description: t('newApp.completionUserDescription', { ns: 'app' }),
}
case AppModeEnum.WORKFLOW:
return {
title: t('types.workflow', { ns: 'app' }),
description: t('newApp.workflowUserDescription', { ns: 'app' }),
}
default:
return {
title: t('types.workflow', { ns: 'app' }),
description: t('newApp.workflowUserDescription', { ns: 'app' }),
}
}
})()
const modeToPreviewInfoMap = {
[AppModeEnum.CHAT]: {
title: t('types.chatbot', { ns: 'app' }),
description: t('newApp.chatbotUserDescription', { ns: 'app' }),
},
[AppModeEnum.ADVANCED_CHAT]: {
title: t('types.advanced', { ns: 'app' }),
description: t('newApp.advancedUserDescription', { ns: 'app' }),
},
[AppModeEnum.AGENT_CHAT]: {
title: t('types.agent', { ns: 'app' }),
description: t('newApp.agentUserDescription', { ns: 'app' }),
},
[AppModeEnum.COMPLETION]: {
title: t('newApp.completeApp', { ns: 'app' }),
description: t('newApp.completionUserDescription', { ns: 'app' }),
},
[AppModeEnum.WORKFLOW]: {
title: t('types.workflow', { ns: 'app' }),
description: t('newApp.workflowUserDescription', { ns: 'app' }),
},
}
const previewInfo = modeToPreviewInfoMap[mode]
return (
<div className="px-8 py-4">
<h4 className="system-sm-semibold-uppercase text-text-secondary">{previewInfo.title}</h4>

View File

@ -2,8 +2,6 @@ import { render, screen } from '@testing-library/react'
import * as React from 'react'
import Empty from '../empty'
const defaultMessage = 'workflow.tabs.noSnippetsFound'
describe('Empty', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -11,32 +9,32 @@ describe('Empty', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Empty message={defaultMessage} />)
expect(screen.getByText(defaultMessage)).toBeInTheDocument()
render(<Empty />)
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
})
it('should render 36 placeholder cards', () => {
const { container } = render(<Empty message={defaultMessage} />)
const { container } = render(<Empty />)
const placeholderCards = container.querySelectorAll('.bg-background-default-lighter')
expect(placeholderCards).toHaveLength(36)
})
it('should display the provided message', () => {
render(<Empty message="app.newApp.noAppsFound" />)
it('should display the no apps found message', () => {
render(<Empty />)
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
})
})
describe('Styling', () => {
it('should have correct container styling for overlay', () => {
const { container } = render(<Empty message={defaultMessage} />)
const { container } = render(<Empty />)
const overlay = container.querySelector('.pointer-events-none')
expect(overlay).toBeInTheDocument()
expect(overlay).toHaveClass('absolute', 'inset-0', 'z-20')
})
it('should have correct styling for placeholder cards', () => {
const { container } = render(<Empty message={defaultMessage} />)
const { container } = render(<Empty />)
const card = container.querySelector('.bg-background-default-lighter')
expect(card).toHaveClass('inline-flex', 'h-[160px]', 'rounded-xl')
})
@ -44,10 +42,10 @@ describe('Empty', () => {
describe('Edge Cases', () => {
it('should handle multiple renders without issues', () => {
const { rerender } = render(<Empty message={defaultMessage} />)
expect(screen.getByText(defaultMessage)).toBeInTheDocument()
const { rerender } = render(<Empty />)
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
rerender(<Empty message="app.newApp.noAppsFound" />)
rerender(<Empty />)
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
})
})

View File

@ -45,19 +45,18 @@ vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor(),
isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator(),
userProfile: { id: 'creator-1' },
}),
}))
const mockSetKeywords = vi.fn()
const mockSetTagIDs = vi.fn()
const mockSetCreatorID = vi.fn()
const mockSetIsCreatedByMe = vi.fn()
const mockSetCategory = vi.fn()
const mockQueryState = {
category: 'all',
tagIDs: [] as string[],
keywords: '',
creatorID: '',
isCreatedByMe: false,
}
vi.mock('../hooks/use-apps-query-state', () => ({
isAppListCategory: (value: string) => value === 'all' || Object.values(AppModeEnum).includes(value as AppModeEnum),
@ -66,18 +65,7 @@ vi.mock('../hooks/use-apps-query-state', () => ({
setCategory: mockSetCategory,
setKeywords: mockSetKeywords,
setTagIDs: mockSetTagIDs,
setCreatorID: mockSetCreatorID,
}),
}))
vi.mock('@/service/use-common', () => ({
useMembers: () => ({
data: {
accounts: [
{ id: 'creator-1', name: 'Alice', avatar_url: null, status: 'active' },
{ id: 'creator-2', name: 'Bob', avatar_url: null, status: 'active' },
],
},
setIsCreatedByMe: mockSetIsCreatedByMe,
}),
}))
@ -202,9 +190,9 @@ vi.mock('../app-card', () => ({
}))
vi.mock('../new-app-card', () => ({
default: ({ ref: _ref }: { ref?: React.Ref<HTMLDivElement> }) => {
return React.createElement('div', { 'data-testid': 'new-app-card', 'role': 'button', 'ref': _ref }, 'New App Card')
},
default: React.forwardRef((_props: unknown, _ref: React.ForwardedRef<unknown>) => {
return React.createElement('div', { 'data-testid': 'new-app-card', 'role': 'button' }, 'New App Card')
}),
}))
vi.mock('../empty', () => ({
@ -241,15 +229,11 @@ beforeAll(() => {
// Render helper wrapping with shared nuqs testing helper plus a seeded
// systemFeatures cache so List can resolve its useSuspenseQuery.
const renderList = (searchParams = '', pageType: 'apps' | 'snippets' = 'apps') => {
const renderList = (searchParams = '') => {
const { wrapper: SystemFeaturesWrapper } = createSystemFeaturesWrapper({
systemFeatures: { branding: { enabled: false } },
})
return renderWithNuqs(<SystemFeaturesWrapper><List pageType={pageType} /></SystemFeaturesWrapper>, { searchParams })
}
const openTypeFilter = () => {
fireEvent.click(screen.getByRole('button', { name: /^app\.(studio\.filters\.types|types\.)/ }))
return renderWithNuqs(<SystemFeaturesWrapper><List /></SystemFeaturesWrapper>, { searchParams })
}
type AppListInfiniteOptions = {
@ -271,7 +255,7 @@ describe('List', () => {
mockQueryState.category = 'all'
mockQueryState.tagIDs = []
mockQueryState.keywords = ''
mockQueryState.creatorID = ''
mockQueryState.isCreatedByMe = false
mockUseWorkflowOnlineUsers.mockClear()
intersectionCallback = null
localStorage.clear()
@ -280,12 +264,11 @@ describe('List', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
renderList()
expect(screen.getByText('app.studio.filters.types'))!.toBeInTheDocument()
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
})
it('should render app type dropdown with all app types', () => {
it('should render tab slider with all app types', () => {
renderList()
openTypeFilter()
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
expect(screen.getByText('app.types.workflow'))!.toBeInTheDocument()
@ -305,21 +288,9 @@ describe('List', () => {
expect(screen.getByText('common.tag.placeholder'))!.toBeInTheDocument()
})
it('should render creators filter', () => {
it('should render created by me checkbox', () => {
renderList()
expect(screen.getByText('app.studio.filters.allCreators'))!.toBeInTheDocument()
})
it('should render link to snippets on apps page', () => {
renderList()
expect(screen.getByRole('link', { name: 'app.studio.viewSnippets' })).toHaveAttribute('href', '/snippets')
})
it('should not render link to snippets on snippets page', () => {
renderList('', 'snippets')
expect(screen.queryByRole('link', { name: 'app.studio.viewSnippets' })).not.toBeInTheDocument()
expect(screen.getByText('app.showMyCreatedAppsOnly'))!.toBeInTheDocument()
})
it('should render app cards when apps exist', () => {
@ -354,22 +325,20 @@ describe('List', () => {
})
})
describe('Type Filter', () => {
it('should update category when workflow type is selected', () => {
describe('Tab Navigation', () => {
it('should update category when workflow tab is clicked', () => {
renderList()
openTypeFilter()
fireEvent.click(screen.getByRole('menuitemradio', { name: 'app.types.workflow' }))
fireEvent.click(screen.getByText('app.types.workflow'))
expect(mockSetCategory).toHaveBeenCalledWith(AppModeEnum.WORKFLOW)
})
it('should update category when all type is selected', () => {
it('should update category when all tab is clicked', () => {
mockQueryState.category = AppModeEnum.WORKFLOW
renderList()
openTypeFilter()
fireEvent.click(screen.getByRole('menuitemradio', { name: 'app.types.all' }))
fireEvent.click(screen.getByText('app.types.all'))
expect(mockSetCategory).toHaveBeenCalledWith('all')
})
@ -395,7 +364,10 @@ describe('List', () => {
renderList()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' }))
const clearButton = document.querySelector('.group')
expect(clearButton)!.toBeInTheDocument()
if (clearButton)
fireEvent.click(clearButton)
expect(mockSetKeywords).toHaveBeenCalledWith('')
})
@ -405,7 +377,7 @@ describe('List', () => {
it('should build paged query input from active filters', () => {
mockQueryState.tagIDs = ['tag-1']
mockQueryState.keywords = 'sales'
mockQueryState.creatorID = 'creator-1'
mockQueryState.isCreatedByMe = true
mockQueryState.category = AppModeEnum.WORKFLOW
renderList()
@ -418,7 +390,7 @@ describe('List', () => {
limit: 30,
name: 'sales',
tag_ids: ['tag-1'],
creator_id: 'creator-1',
is_created_by_me: true,
mode: AppModeEnum.WORKFLOW,
},
})
@ -434,19 +406,19 @@ describe('List', () => {
})
})
describe('Creators Filter', () => {
it('should render creators filter with correct label', () => {
describe('Created By Me Filter', () => {
it('should render checkbox with correct label', () => {
renderList()
expect(screen.getByText('app.studio.filters.allCreators'))!.toBeInTheDocument()
expect(screen.getByText('app.showMyCreatedAppsOnly'))!.toBeInTheDocument()
})
it('should handle creator selection as a single creator filter', () => {
it('should handle checkbox change', () => {
renderList()
fireEvent.click(screen.getByRole('button', { name: 'app.studio.filters.allCreators' }))
fireEvent.click(screen.getByRole('button', { name: /Bob/ }))
const checkbox = screen.getByRole('checkbox', { name: 'app.showMyCreatedAppsOnly' })
fireEvent.click(checkbox)
expect(mockSetCreatorID).toHaveBeenCalledWith('creator-2')
expect(mockSetIsCreatedByMe).toHaveBeenCalledWith(true)
})
})
@ -492,11 +464,11 @@ describe('List', () => {
describe('Edge Cases', () => {
it('should handle multiple renders without issues', () => {
const { unmount } = renderList()
expect(screen.getByText('app.studio.filters.types'))!.toBeInTheDocument()
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
unmount()
renderList()
expect(screen.getByText('app.studio.filters.types'))!.toBeInTheDocument()
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
})
it('should render app cards correctly', () => {
@ -509,10 +481,9 @@ describe('List', () => {
it('should render with all filter options visible', () => {
renderList()
expect(screen.getByText('app.studio.filters.types'))!.toBeInTheDocument()
expect(screen.getByRole('textbox'))!.toBeInTheDocument()
expect(screen.getByText('app.studio.filters.allCreators'))!.toBeInTheDocument()
expect(screen.getByText('common.tag.placeholder'))!.toBeInTheDocument()
expect(screen.getByText('app.showMyCreatedAppsOnly'))!.toBeInTheDocument()
})
})
@ -529,10 +500,9 @@ describe('List', () => {
})
})
describe('App Type Dropdown', () => {
it('should render all app type options', () => {
describe('App Type Tabs', () => {
it('should render all app type tabs', () => {
renderList()
openTypeFilter()
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
expect(screen.getByText('app.types.workflow'))!.toBeInTheDocument()
@ -542,7 +512,9 @@ describe('List', () => {
expect(screen.getByText('app.types.completion'))!.toBeInTheDocument()
})
it('should update category for each app type option click', () => {
it('should update category for each app type tab click', () => {
renderList()
const appTypeTexts = [
{ mode: AppModeEnum.WORKFLOW, text: 'app.types.workflow' },
{ mode: AppModeEnum.ADVANCED_CHAT, text: 'app.types.advanced' },
@ -553,11 +525,8 @@ describe('List', () => {
for (const { mode, text } of appTypeTexts) {
mockSetCategory.mockClear()
const { unmount } = renderList()
openTypeFilter()
fireEvent.click(screen.getByRole('menuitemradio', { name: text }))
fireEvent.click(screen.getByText(text))
expect(mockSetCategory).toHaveBeenCalledWith(mode)
unmount()
}
})
})

View File

@ -1,16 +0,0 @@
import { parseAsStringLiteral } from 'nuqs'
import { AppModes } from '@/types/app'
const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const
type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number]
export type { AppListCategory }
const appListCategorySet = new Set<string>(APP_LIST_CATEGORY_VALUES)
export const isAppListCategory = (value: string): value is AppListCategory => {
return appListCategorySet.has(value)
}
export const parseAsAppListCategory = parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
.withDefault('all')
.withOptions({ history: 'push' })

View File

@ -1,76 +0,0 @@
'use client'
import type { AppListCategory } from './app-type-filter-shared'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuRadioItemIndicator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { AppModeEnum } from '@/types/app'
import { isAppListCategory } from './app-type-filter-shared'
const chipClassName = 'flex h-8 items-center rounded-lg border-[0.5px] px-2 text-[13px] leading-4 transition-colors'
type AppTypeFilterProps = {
value: AppListCategory
onChange: (value: AppListCategory) => void
}
export function AppTypeFilter({
value,
onChange,
}: AppTypeFilterProps) {
const { t } = useTranslation()
const options = useMemo(() => ([
{ value: 'all', text: t('types.all', { ns: 'app' }), iconClassName: 'i-ri-apps-2-line' },
{ value: AppModeEnum.WORKFLOW, text: t('types.workflow', { ns: 'app' }), iconClassName: 'i-ri-exchange-2-line' },
{ value: AppModeEnum.ADVANCED_CHAT, text: t('types.advanced', { ns: 'app' }), iconClassName: 'i-ri-message-3-line' },
{ value: AppModeEnum.CHAT, text: t('types.chatbot', { ns: 'app' }), iconClassName: 'i-ri-message-3-line' },
{ value: AppModeEnum.AGENT_CHAT, text: t('types.agent', { ns: 'app' }), iconClassName: 'i-ri-robot-3-line' },
{ value: AppModeEnum.COMPLETION, text: t('types.completion', { ns: 'app' }), iconClassName: 'i-ri-file-4-line' },
]), [t])
const activeOption = options.find(option => option.value === value)
const isSelected = value !== 'all'
const triggerLabel = isSelected ? activeOption?.text : t('studio.filters.types', { ns: 'app' })
return (
<DropdownMenu>
<DropdownMenuTrigger
render={(
<button
type="button"
className={cn(
chipClassName,
isSelected
? 'border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs hover:bg-state-base-hover'
: 'border-transparent bg-components-input-bg-normal text-text-tertiary hover:bg-components-input-bg-hover',
)}
/>
)}
>
<span aria-hidden className={cn('h-4 w-4 shrink-0 text-text-tertiary', activeOption?.iconClassName ?? 'i-ri-apps-2-line')} />
<span className="px-1 text-text-tertiary">{triggerLabel}</span>
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary" />
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-start" popupClassName="w-[220px]">
<DropdownMenuRadioGroup value={value} onValueChange={nextValue => isAppListCategory(nextValue) && onChange(nextValue)}>
{options.map(option => (
<DropdownMenuRadioItem key={option.value} value={option.value}>
<span aria-hidden className={cn('h-4 w-4 shrink-0 text-text-tertiary', option.iconClassName)} />
<span>{option.text}</span>
<DropdownMenuRadioItemIndicator />
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -1,230 +0,0 @@
'use client'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { Input } from '@langgenius/dify-ui/input'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useAppContext } from '@/context/app-context'
import { useMembers } from '@/service/use-common'
type CreatorsFilterProps = {
value: string[]
onChange: (value: string[]) => void
}
type CreatorOption = {
id: string
name: string
avatarUrl: string | null
isYou: boolean
}
const baseChipClassName = 'flex h-8 items-center rounded-lg border-[0.5px] px-2 text-[13px] leading-4 transition-colors'
const CreatorsFilter = ({
value,
onChange,
}: CreatorsFilterProps) => {
const { t } = useTranslation()
const { userProfile } = useAppContext()
const { data: membersData } = useMembers()
const [keywords, setKeywords] = useState('')
const creatorOptions = useMemo<CreatorOption[]>(() => {
const currentUserId = userProfile?.id
const members = membersData?.accounts ?? []
return [...members]
.filter(member => member.status !== 'pending')
.sort((left, right) => {
if (left.id === currentUserId)
return -1
if (right.id === currentUserId)
return 1
return left.name.localeCompare(right.name)
})
.map(member => ({
id: member.id,
name: member.name,
avatarUrl: member.avatar_url,
isYou: member.id === currentUserId,
}))
}, [membersData?.accounts, userProfile?.id])
const filteredCreators = useMemo(() => {
const normalizedKeywords = keywords.trim().toLowerCase()
if (!normalizedKeywords)
return creatorOptions
return creatorOptions.filter((creator) => {
const keyword = normalizedKeywords
return creator.name.toLowerCase().includes(keyword)
})
}, [creatorOptions, keywords])
const selectedCreators = useMemo(() => {
const creatorMap = new Map(creatorOptions.map(creator => [creator.id, creator]))
return value
.map(id => creatorMap.get(id))
.filter((creator): creator is CreatorOption => Boolean(creator))
}, [creatorOptions, value])
const toggleCreator = useCallback((creatorId: string) => {
if (value.includes(creatorId)) {
onChange(value.filter(id => id !== creatorId))
return
}
onChange([...value, creatorId])
}, [onChange, value])
const resetCreators = useCallback(() => {
onChange([])
setKeywords('')
}, [onChange])
const selectedCount = value.length
const selectedAvatarCreators = selectedCreators.slice(0, 3)
const isSelected = selectedCount > 0
return (
<DropdownMenu>
<DropdownMenuTrigger
render={(
<button
type="button"
className={cn(
baseChipClassName,
isSelected
? 'border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs hover:bg-state-base-hover'
: 'border-transparent bg-components-input-bg-normal text-text-tertiary hover:bg-components-input-bg-hover',
)}
/>
)}
>
<span aria-hidden className="i-ri-user-shared-line h-4 w-4 shrink-0 text-text-tertiary" />
{!isSelected && (
<>
<span className="px-1 text-text-tertiary">{t('studio.filters.allCreators', { ns: 'app' })}</span>
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary" />
</>
)}
{isSelected && (
<>
<span className="px-1 text-text-tertiary">{t('studio.filters.creators', { ns: 'app' })}</span>
<span className="flex items-center pr-1">
{selectedAvatarCreators.map((creator, index) => (
<Avatar
key={creator.id}
avatar={creator.avatarUrl}
name={creator.name}
size="xs"
className={cn(
'border border-components-panel-bg',
index > 0 && '-ml-1',
)}
/>
))}
</span>
<span className="text-xs leading-4 font-medium text-text-tertiary">{`+${selectedCount}`}</span>
<span
role="button"
tabIndex={0}
aria-label={t('studio.filters.reset', { ns: 'app' })}
className="ml-1 flex h-4 w-4 shrink-0 items-center justify-center rounded-xs text-text-quaternary hover:text-text-tertiary"
onClick={(event) => {
event.stopPropagation()
resetCreators()
}}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ')
return
event.preventDefault()
event.stopPropagation()
resetCreators()
}}
>
<span aria-hidden className="i-ri-close-circle-fill h-3.5 w-3.5" />
</span>
</>
)}
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-start" popupClassName="w-[280px] p-0">
<div className="flex items-center gap-1 p-2 pb-1">
<div className="relative min-w-0 grow">
<span aria-hidden className="pointer-events-none absolute top-1/2 left-2 i-ri-search-line size-4 -translate-y-1/2 text-components-input-text-placeholder" />
<Input
className={cn('pl-6.5', keywords && 'pr-6.5')}
value={keywords}
onChange={e => setKeywords(e.target.value)}
placeholder={t('studio.filters.searchCreators', { ns: 'app' })}
/>
{!!keywords && (
<button
type="button"
aria-label={t('operation.clear', { ns: 'common' })}
className="absolute top-1/2 right-2 flex size-4 -translate-y-1/2 items-center justify-center text-components-input-text-placeholder hover:text-components-input-text-filled"
onClick={() => setKeywords('')}
>
<span aria-hidden className="i-ri-close-circle-fill size-4" />
</button>
)}
</div>
{isSelected && (
<button
type="button"
className="shrink-0 rounded-sm px-2 py-1 text-xs font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
onClick={resetCreators}
>
{t('studio.filters.reset', { ns: 'app' })}
</button>
)}
</div>
<div className="max-h-60 overflow-y-auto px-1 pb-1">
{filteredCreators.map((creator) => {
const checked = value.includes(creator.id)
return (
<button
key={creator.id}
type="button"
className="flex w-full items-center gap-1 rounded-md px-2 py-1.5 hover:bg-state-base-hover"
onClick={() => toggleCreator(creator.id)}
>
<Checkbox
id={creator.id}
checked={checked}
className="shrink-0"
/>
<div className="flex min-w-0 grow items-center gap-2 px-1">
<Avatar
avatar={creator.avatarUrl}
name={creator.name}
size="xs"
className="border-[0.5px] border-divider-regular"
/>
<div className="flex min-w-0 grow items-center justify-between gap-2">
<span className="truncate text-sm text-text-secondary">{creator.name}</span>
{creator.isYou && (
<span className="shrink-0 text-sm text-text-quaternary">{t('studio.filters.you', { ns: 'app' })}</span>
)}
</div>
</div>
</button>
)
})}
</div>
</DropdownMenuContent>
</DropdownMenu>
)
}
export default CreatorsFilter

View File

@ -17,11 +17,7 @@ const DefaultCards = React.memo(() => {
)
})
type EmptyProps = {
message?: string
}
const Empty = ({ message }: EmptyProps) => {
const Empty = () => {
const { t } = useTranslation()
return (
@ -29,7 +25,7 @@ const Empty = ({ message }: EmptyProps) => {
<DefaultCards />
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-linear-to-t from-background-body to-transparent">
<span className="system-md-medium text-text-tertiary">
{message ?? t('newApp.noAppsFound', { ns: 'app' })}
{t('newApp.noAppsFound', { ns: 'app' })}
</span>
</div>
</>

View File

@ -5,7 +5,6 @@ import { APP_LIST_SEARCH_DEBOUNCE_MS } from '../../constants'
import { useAppsQueryState } from '../use-apps-query-state'
const renderWithAdapter = (searchParams = '') => {
// eslint-disable-next-line react/use-state -- testing a custom URL query hook, not React.useState
return renderHookWithNuqs(() => useAppsQueryState(), { searchParams })
}
@ -21,24 +20,24 @@ describe('useAppsQueryState', () => {
category: 'all',
tagIDs: [],
keywords: '',
creatorID: '',
isCreatedByMe: false,
})
expect(typeof result.current.setCategory).toBe('function')
expect(typeof result.current.setKeywords).toBe('function')
expect(typeof result.current.setTagIDs).toBe('function')
expect(typeof result.current.setCreatorID).toBe('function')
expect(typeof result.current.setIsCreatedByMe).toBe('function')
})
it('should parse app list filters from URL', () => {
const { result } = renderWithAdapter(
'?category=workflow&tagIDs=tag1;tag2&keywords=search+term&creatorID=creator-1',
'?category=workflow&tagIDs=tag1;tag2&keywords=search+term&isCreatedByMe=true',
)
expect(result.current.query).toEqual({
category: AppModeEnum.WORKFLOW,
tagIDs: ['tag1', 'tag2'],
keywords: 'search term',
creatorID: 'creator-1',
isCreatedByMe: true,
})
})
@ -145,30 +144,30 @@ describe('useAppsQueryState', () => {
expect(update.searchParams.has('tagIDs')).toBe(false)
})
it('should update creator ID URL state', async () => {
it('should update created-by-me URL state', async () => {
const { result, onUrlUpdate } = renderWithAdapter()
act(() => {
result.current.setCreatorID('creator-1')
result.current.setIsCreatedByMe(true)
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls.at(-1)![0]
expect(result.current.query.creatorID).toBe('creator-1')
expect(update.searchParams.get('creatorID')).toBe('creator-1')
expect(result.current.query.isCreatedByMe).toBe(true)
expect(update.searchParams.get('isCreatedByMe')).toBe('true')
expect(update.options.history).toBe('push')
})
it('should remove creatorID from URL when cleared', async () => {
const { result, onUrlUpdate } = renderWithAdapter('?creatorID=creator-1')
it('should remove isCreatedByMe from URL when disabled', async () => {
const { result, onUrlUpdate } = renderWithAdapter('?isCreatedByMe=true')
act(() => {
result.current.setCreatorID('')
result.current.setIsCreatedByMe(false)
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls.at(-1)![0]
expect(result.current.query.creatorID).toBe('')
expect(update.searchParams.has('creatorID')).toBe(false)
expect(result.current.query.isCreatedByMe).toBe(false)
expect(update.searchParams.has('isCreatedByMe')).toBe(false)
})
})

View File

@ -1,19 +1,29 @@
import type { AppListCategory } from '../app-type-filter-shared'
import { debounce, parseAsArrayOf, parseAsString, useQueryStates } from 'nuqs'
import { debounce, parseAsArrayOf, parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryStates } from 'nuqs'
import { useCallback, useMemo } from 'react'
import { parseAsAppListCategory } from '../app-type-filter-shared'
import { AppModes } from '@/types/app'
import { APP_LIST_SEARCH_DEBOUNCE_MS } from '../constants'
const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const
export type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number]
const appListCategorySet = new Set<string>(APP_LIST_CATEGORY_VALUES)
export const isAppListCategory = (value: string): value is AppListCategory => {
return appListCategorySet.has(value)
}
const appListQueryParsers = {
category: parseAsAppListCategory,
category: parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
.withDefault('all')
.withOptions({ history: 'push' }),
tagIDs: parseAsArrayOf(parseAsString, ';')
.withDefault([])
.withOptions({ history: 'push' }),
keywords: parseAsString.withDefault('').withOptions({
limitUrlUpdates: debounce(APP_LIST_SEARCH_DEBOUNCE_MS),
}),
creatorID: parseAsString
.withDefault('')
isCreatedByMe: parseAsBoolean
.withDefault(false)
.withOptions({ history: 'push' }),
}
@ -32,8 +42,8 @@ export function useAppsQueryState() {
setQuery({ tagIDs })
}, [setQuery])
const setCreatorID = useCallback((creatorID: string) => {
setQuery({ creatorID })
const setIsCreatedByMe = useCallback((isCreatedByMe: boolean) => {
setQuery({ isCreatedByMe })
}, [setQuery])
return useMemo(() => ({
@ -41,6 +51,6 @@ export function useAppsQueryState() {
setCategory,
setKeywords,
setTagIDs,
setCreatorID,
}), [query, setCategory, setKeywords, setTagIDs, setCreatorID])
setIsCreatedByMe,
}), [query, setCategory, setKeywords, setTagIDs, setIsCreatedByMe])
}

View File

@ -15,29 +15,19 @@ import { fetchAppDetail } from '@/service/explore'
import { trackCreateApp } from '@/utils/create-app-tracking'
import List from './list'
export type StudioPageType = 'apps' | 'snippets'
type AppsProps = {
pageType?: StudioPageType
}
const DSLConfirmModal = dynamic(() => import('../app/create-from-dsl-modal/dsl-confirm-modal'), { ssr: false })
const CreateAppModal = dynamic(() => import('../explore/create-app-modal'), { ssr: false })
const TryApp = dynamic(() => import('../explore/try-app'), { ssr: false })
const ImportFromMarketplaceTemplateModal = dynamic(() => import('./import-from-marketplace-template-modal'), { ssr: false })
const Apps = ({
pageType = 'apps',
}: AppsProps) => {
const Apps = () => {
const { t } = useTranslation()
const searchParams = useSearchParams()
const { replace } = useRouter()
const templateId = searchParams.get('template-id')
const templateDismissedRef = useRef(false)
useDocumentTitle(pageType === 'apps'
? t('menus.apps', { ns: 'common' })
: t('tabs.snippets', { ns: 'workflow' }))
useDocumentTitle(t('menus.apps', { ns: 'common' }))
useEducationInit()
const [currentTryAppParams, setCurrentTryAppParams] = useState<TryAppSelection | undefined>(undefined)
@ -175,7 +165,7 @@ const Apps = ({
}}
>
<div className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
<List controlRefreshList={controlRefreshList} pageType={pageType} />
<List controlRefreshList={controlRefreshList} />
{isShowTryAppPanel && (
<TryApp
appId={currentTryAppParams?.appId || ''}

View File

@ -1,31 +1,29 @@
'use client'
import type { FC } from 'react'
import type { StudioPageType } from '.'
import type { AppListQuery } from '@/contract/console/apps'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { cn } from '@langgenius/dify-ui/cn'
import { Input } from '@langgenius/dify-ui/input'
import { keepPreviousData, useInfiniteQuery, useSuspenseQuery } from '@tanstack/react-query'
import { useDebounce } from 'ahooks'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import TabSliderNew from '@/app/components/base/tab-slider-new'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { TagFilter } from '@/features/tag-management/components/tag-filter'
import { CheckModal } from '@/hooks/use-pay'
import dynamic from '@/next/dynamic'
import Link from '@/next/link'
import { consoleQuery } from '@/service/client'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { AppModeEnum } from '@/types/app'
import AppCard from './app-card'
import { AppCardSkeleton } from './app-card-skeleton'
import { AppTypeFilter } from './app-type-filter'
import { APP_LIST_SEARCH_DEBOUNCE_MS } from './constants'
import CreatorsFilter from './creators-filter'
import Empty from './empty'
import Footer from './footer'
import { useAppsQueryState } from './hooks/use-apps-query-state'
import { isAppListCategory, useAppsQueryState } from './hooks/use-apps-query-state'
import { useDSLDragDrop } from './hooks/use-dsl-drag-drop'
import { useWorkflowOnlineUsers } from './hooks/use-workflow-online-users'
import NewAppCard from './new-app-card'
@ -39,11 +37,9 @@ const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-fro
type Props = {
controlRefreshList?: number
pageType?: StudioPageType
}
const List: FC<Props> = ({
controlRefreshList = 0,
pageType = 'apps',
}) => {
const { t } = useTranslation()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
@ -51,11 +47,11 @@ const List: FC<Props> = ({
// eslint-disable-next-line react/use-state -- custom URL query hook, not React.useState
const {
query: { category, tagIDs, keywords, creatorID },
query: { category, tagIDs, keywords, isCreatedByMe },
setCategory,
setKeywords,
setTagIDs,
setCreatorID,
setIsCreatedByMe,
} = useAppsQueryState()
const debouncedKeywords = useDebounce(keywords, { wait: APP_LIST_SEARCH_DEBOUNCE_MS })
const newAppCardRef = useRef<HTMLDivElement>(null)
@ -80,9 +76,9 @@ const List: FC<Props> = ({
limit: 30,
name: debouncedKeywords,
...(tagIDs.length ? { tag_ids: tagIDs } : {}),
...(creatorID ? { creator_id: creatorID } : {}),
...(isCreatedByMe ? { is_created_by_me: isCreatedByMe } : {}),
...(category !== 'all' ? { mode: category } : {}),
}), [category, creatorID, debouncedKeywords, tagIDs])
}), [category, debouncedKeywords, isCreatedByMe, tagIDs])
const {
data,
@ -116,6 +112,14 @@ const List: FC<Props> = ({
}, [controlRefreshList, refetch])
const anchorRef = useRef<HTMLDivElement>(null)
const options = [
{ value: 'all', text: t('types.all', { ns: 'app' }), icon: <span className="mr-1 i-ri-apps-2-line h-[14px] w-[14px]" /> },
{ value: AppModeEnum.WORKFLOW, text: t('types.workflow', { ns: 'app' }), icon: <span className="mr-1 i-ri-exchange-2-line h-[14px] w-[14px]" /> },
{ value: AppModeEnum.ADVANCED_CHAT, text: t('types.advanced', { ns: 'app' }), icon: <span className="mr-1 i-ri-message-3-line h-[14px] w-[14px]" /> },
{ value: AppModeEnum.CHAT, text: t('types.chatbot', { ns: 'app' }), icon: <span className="mr-1 i-ri-message-3-line h-[14px] w-[14px]" /> },
{ value: AppModeEnum.AGENT_CHAT, text: t('types.agent', { ns: 'app' }), icon: <span className="mr-1 i-ri-robot-3-line h-[14px] w-[14px]" /> },
{ value: AppModeEnum.COMPLETION, text: t('types.completion', { ns: 'app' }), icon: <span className="mr-1 i-ri-file-4-line h-[14px] w-[14px]" /> },
]
useEffect(() => {
if (localStorage.getItem(NEED_REFRESH_APP_LIST_KEY) === '1') {
@ -154,9 +158,9 @@ const List: FC<Props> = ({
return () => observer?.disconnect()
}, [isLoading, isFetchingNextPage, fetchNextPage, error, hasNextPage, isCurrentWorkspaceDatasetOperator])
const handleCreatorsChange = useCallback((creatorIDs: string[]) => {
setCreatorID(creatorIDs.at(-1) ?? '')
}, [setCreatorID])
const handleCreatedByMeChange = useCallback((checked: boolean) => {
setIsCreatedByMe(checked)
}, [setIsCreatedByMe])
const pages = useMemo(() => data?.pages ?? [], [data?.pages])
const apps = useMemo(() => pages.flatMap(({ data: pageApps }) => pageApps), [pages])
@ -189,45 +193,32 @@ const List: FC<Props> = ({
</div>
)}
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-x-4 gap-y-2 bg-background-body px-12 pt-7 pb-5">
<div className="flex flex-wrap items-center gap-2">
<AppTypeFilter
value={category}
onChange={setCategory}
/>
<CreatorsFilter
value={creatorID ? [creatorID] : []}
onChange={handleCreatorsChange}
/>
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-y-2 bg-background-body px-12 pt-7 pb-5">
<TabSliderNew
value={category}
onChange={(nextValue) => {
if (isAppListCategory(nextValue))
setCategory(nextValue)
}}
options={options}
/>
<div className="flex items-center gap-2">
<label className="mr-2 flex h-7 items-center space-x-2">
<Checkbox checked={isCreatedByMe} onCheckedChange={handleCreatedByMeChange} />
<div className="text-sm font-normal text-text-secondary">
{t('showMyCreatedAppsOnly', { ns: 'app' })}
</div>
</label>
<TagFilter type="app" value={tagIDs} onChange={setTagIDs} onOpenTagManagement={() => setShowTagManagementModal(true)} />
<div className="relative w-50">
<span aria-hidden className="pointer-events-none absolute top-1/2 left-2 i-ri-search-line size-4 -translate-y-1/2 text-components-input-text-placeholder" />
<Input
className={cn('pl-6.5', keywords && 'pr-6.5')}
value={keywords}
onChange={e => setKeywords(e.target.value)}
placeholder={t('operation.search', { ns: 'common' })}
/>
{!!keywords && (
<button
type="button"
aria-label={t('operation.clear', { ns: 'common' })}
className="absolute top-1/2 right-2 flex size-4 -translate-y-1/2 items-center justify-center text-components-input-text-placeholder hover:text-components-input-text-filled"
onClick={() => setKeywords('')}
>
<span aria-hidden className="i-ri-close-circle-fill size-4" />
</button>
)}
</div>
<Input
showLeftIcon
showClearIcon
wrapperClassName="w-[200px]"
value={keywords}
onChange={e => setKeywords(e.target.value)}
onClear={() => setKeywords('')}
/>
</div>
{pageType === 'apps' && (
<Link
href="/snippets"
className="flex h-8 items-center rounded-lg px-3 text-sm font-semibold text-text-secondary hover:bg-state-base-hover hover:text-text-primary"
>
{t('studio.viewSnippets', { ns: 'app' })}
</Link>
)}
</div>
<div className={cn(
'relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 2k:grid-cols-6 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5',
@ -255,7 +246,7 @@ const List: FC<Props> = ({
onOpenTagManagement={() => setShowTagManagementModal(true)}
/>
))
: <Empty message={pageType === 'snippets' ? t('tabs.noSnippetsFound', { ns: 'workflow' }) : undefined} />}
: <Empty />}
{isFetchingNextPage && (
<AppCardSkeleton count={3} />
)}

View File

@ -104,23 +104,12 @@ vi.mock('../../nav', () => ({
onCreate,
onLoadMore,
navigationItems,
activeSegment,
activeLink,
text,
}: {
onCreate: (state: string) => void
onLoadMore?: () => void
navigationItems?: Array<{ id: string, name: string, link: string }>
activeSegment?: string | string[]
activeLink?: { segment: string, text: string, link: string }
text?: string
}) => (
<div data-testid="nav">
<div data-testid="nav-text">{text}</div>
<div data-testid="nav-active-segment">{JSON.stringify(activeSegment)}</div>
{activeLink && (
<div data-testid="nav-active-link">{`${activeLink.segment}:${activeLink.text}->${activeLink.link}`}</div>
)}
<ul data-testid="nav-items">
{(navigationItems ?? []).map(item => (
<li key={item.id}>{`${item.name} -> ${item.link}`}</li>
@ -212,15 +201,6 @@ describe('AppNav', () => {
expect(options.getNextPageParam({ has_more: false, page: 3 })).toBeUndefined()
})
it('should configure snippets as an active studio child link', () => {
setupDefaultMocks()
render(<AppNav />)
expect(screen.getByTestId('nav-text')).toHaveTextContent('menus.apps')
expect(screen.getByTestId('nav-active-segment')).toHaveTextContent(JSON.stringify(['apps', 'app', 'snippets']))
expect(screen.getByTestId('nav-active-link')).toHaveTextContent('snippets:tabs.snippets->/snippets')
})
it('should build editor links and update app name when app detail changes', async () => {
setupDefaultMocks({
isEditor: true,

View File

@ -103,13 +103,8 @@ const AppNav = () => {
icon={<RiRobot2Line className="size-4" />}
activeIcon={<RiRobot2Fill className="size-4" />}
text={t('menus.apps', { ns: 'common' })}
activeSegment={['apps', 'app', 'snippets']}
activeSegment={['apps', 'app']}
link="/apps"
activeLink={{
segment: 'snippets',
text: t('tabs.snippets', { ns: 'workflow' }),
link: '/snippets',
}}
curNav={appDetail}
navigationItems={navItems}
createText={t('menus.newApp', { ns: 'common' })}

View File

@ -102,13 +102,6 @@ describe('DatasetNav', () => {
icon_info: { icon: 'pipeline' },
provider: 'vendor',
},
{
id: 'dataset-5',
name: 'Null Icon Dataset',
runtime_mode: 'general',
icon_info: null,
provider: 'vendor',
},
],
},
],
@ -148,16 +141,6 @@ describe('DatasetNav', () => {
render(<DatasetNav />)
expect(screen.getByText('common.menus.datasets')).toBeInTheDocument()
})
it('should render current dataset when icon info is null', () => {
vi.mocked(useDatasetDetail).mockReturnValue({
data: { ...mockDataset, icon_info: null },
} as unknown as ReturnType<typeof useDatasetDetail>)
render(<DatasetNav />)
expect(screen.getByRole('button', { name: /Test Dataset/i })).toBeInTheDocument()
})
})
describe('Navigation Items logic', () => {
@ -171,7 +154,6 @@ describe('DatasetNav', () => {
expect(within(menu).getByText('Test Dataset')).toBeInTheDocument()
expect(within(menu).getByText('Pipeline Dataset')).toBeInTheDocument()
expect(within(menu).getByText('External Dataset')).toBeInTheDocument()
expect(within(menu).getByText('Null Icon Dataset')).toBeInTheDocument()
})
it('should navigate to correct link when an item is clicked', () => {

View File

@ -1,7 +1,11 @@
'use client'
import type { NavItem } from '../nav/nav-selector'
import type { DataSet, IconInfo } from '@/models/datasets'
import type { DataSet } from '@/models/datasets'
import {
RiBook2Fill,
RiBook2Line,
} from '@remixicon/react'
import { flatten } from 'es-toolkit/compat'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@ -10,24 +14,6 @@ import { useDatasetDetail, useDatasetList } from '@/service/knowledge/use-datase
import { basePath } from '@/utils/var'
import Nav from '../nav'
const DEFAULT_DATASET_ICON: IconInfo = {
icon_type: 'emoji',
icon: '📙',
icon_background: '#FFF4ED',
icon_url: '',
}
type NullableDatasetIconInfo = Partial<{
[Key in keyof IconInfo]: IconInfo[Key] | null
}>
const normalizeDatasetIconInfo = (iconInfo?: NullableDatasetIconInfo | null): IconInfo => ({
icon_type: iconInfo?.icon_type ?? DEFAULT_DATASET_ICON.icon_type,
icon: iconInfo?.icon ?? DEFAULT_DATASET_ICON.icon,
icon_background: iconInfo?.icon_background ?? DEFAULT_DATASET_ICON.icon_background,
icon_url: iconInfo?.icon_url ?? DEFAULT_DATASET_ICON.icon_url,
})
const DatasetNav = () => {
const { t } = useTranslation()
const router = useRouter()
@ -47,16 +33,15 @@ const DatasetNav = () => {
const curNav = useMemo(() => {
if (!currentDataset)
return
const iconInfo = normalizeDatasetIconInfo(currentDataset.icon_info)
return {
id: currentDataset.id,
name: currentDataset.name,
icon: iconInfo.icon,
icon_type: iconInfo.icon_type,
icon_background: iconInfo.icon_background ?? null,
icon_url: iconInfo.icon_url ?? null,
icon: currentDataset.icon_info.icon,
icon_type: currentDataset.icon_info.icon_type,
icon_background: currentDataset.icon_info.icon_background,
icon_url: currentDataset.icon_info.icon_url,
} as Omit<NavItem, 'link'>
}, [currentDataset])
}, [currentDataset?.id, currentDataset?.name, currentDataset?.icon_info])
const getDatasetLink = useCallback((dataset: DataSet) => {
const isPipelineUnpublished = dataset.runtime_mode === 'rag_pipeline' && !dataset.is_published
@ -71,15 +56,14 @@ const DatasetNav = () => {
const navigationItems = useMemo(() => {
return datasetItems.map((dataset) => {
const link = getDatasetLink(dataset)
const iconInfo = normalizeDatasetIconInfo(dataset.icon_info)
return {
id: dataset.id,
name: dataset.name,
link,
icon: iconInfo.icon,
icon_type: iconInfo.icon_type,
icon_background: iconInfo.icon_background ?? null,
icon_url: iconInfo.icon_url ?? null,
icon: dataset.icon_info.icon,
icon_type: dataset.icon_info.icon_type,
icon_background: dataset.icon_info.icon_background,
icon_url: dataset.icon_info.icon_url,
}
}) as NavItem[]
}, [datasetItems, getDatasetLink])
@ -100,8 +84,8 @@ const DatasetNav = () => {
return (
<Nav
isApp={false}
icon={<span className="i-ri-book-2-line size-4" />}
activeIcon={<span className="i-ri-book-2-fill size-4" />}
icon={<RiBook2Line className="size-4" />}
activeIcon={<RiBook2Fill className="size-4" />}
text={t('menus.datasets', { ns: 'common' })}
activeSegment="datasets"
link="/datasets"

View File

@ -14,7 +14,7 @@ const HeaderWrapper = ({
children,
}: HeaderWrapperProps) => {
const pathname = usePathname()
const isBordered = ['/apps', '/snippets', '/datasets/create', '/tools'].includes(pathname)
const isBordered = ['/apps', '/datasets/create', '/tools'].includes(pathname)
// Check if the current path is a workflow canvas & fullscreen
const inWorkflowCanvas = pathname.endsWith('/workflow')
const isPipelineCanvas = pathname.endsWith('/pipeline')

View File

@ -123,27 +123,6 @@ describe('Nav Component', () => {
expect(screen.getByTestId('active-icon')).toBeInTheDocument()
})
it('should render active child link when activeLink matches the current segment', () => {
vi.mocked(useSelectedLayoutSegment).mockReturnValue('snippets')
render(
<Nav
{...defaultProps}
activeSegment={['apps', 'app', 'snippets']}
activeLink={{
segment: 'snippets',
text: 'SNIPPETS',
link: '/snippets',
}}
/>,
)
expect(screen.getByText('Nav Text')).toBeInTheDocument()
expect(screen.getByText('Nav Text')).toHaveClass('max-[1024px]:hidden')
expect(screen.getByRole('link', { name: 'SNIPPETS' })).toHaveAttribute('href', '/snippets')
expect(screen.getByRole('link', { name: 'SNIPPETS' })).not.toHaveClass('max-[1024px]:hidden')
})
it('should not show hover background if not activated', () => {
vi.mocked(useSelectedLayoutSegment).mockReturnValue('other')
const { container } = render(<Nav {...defaultProps} />)
@ -169,14 +148,6 @@ describe('Nav Component', () => {
expect(mockSetAppDetail).not.toHaveBeenCalled()
})
it('should not call setAppDetail from snippets segment', () => {
vi.mocked(useSelectedLayoutSegment).mockReturnValue('snippets')
render(<Nav {...defaultProps} activeSegment={['apps', 'app', 'snippets']} />)
const link = screen.getByRole('link')
fireEvent.click(link.firstChild!)
expect(mockSetAppDetail).not.toHaveBeenCalled()
})
it('should show ArrowNarrowLeft on hover when curNav is provided and activated', () => {
const curNav = navigationItems[0]
render(<Nav {...defaultProps} curNav={curNav} />)
@ -214,20 +185,19 @@ describe('Nav Component', () => {
})
it('should navigate when an item is selected', async () => {
vi.mocked(useSelectedLayoutSegment).mockReturnValue('snippets')
render(<Nav {...defaultProps} activeSegment={['apps', 'app', 'snippets']} curNav={curNav} />)
render(<Nav {...defaultProps} curNav={curNav} />)
const selectorButton = screen.getByRole('button', { name: /Item 1/i })
await act(async () => {
fireEvent.click(selectorButton)
})
mockSetAppDetail.mockClear()
const item2 = await screen.findByText('Item 2')
await act(async () => {
fireEvent.click(item2)
})
expect(mockSetAppDetail).toHaveBeenCalled()
expect(mockPush).toHaveBeenCalledWith('/item2')
})

View File

@ -5,6 +5,7 @@ import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import { useState } from 'react'
import { useStore as useAppStore } from '@/app/components/app/store'
import { ArrowNarrowLeft } from '@/app/components/base/icons/src/vender/line/arrows'
import Link from '@/next/link'
import { useSelectedLayoutSegment } from '@/next/navigation'
import NavSelector from './nav-selector'
@ -15,11 +16,6 @@ type INavProps = {
text: string
activeSegment: string | string[]
link: string
activeLink?: {
segment: string
text: string
link: string
}
isApp: boolean
} & INavSelectorProps
@ -29,7 +25,6 @@ const Nav = ({
text,
activeSegment,
link,
activeLink,
curNav,
navigationItems,
createText,
@ -42,11 +37,10 @@ const Nav = ({
const [hovered, setHovered] = useState(false)
const segment = useSelectedLayoutSegment()
const isActivated = Array.isArray(activeSegment) ? activeSegment.includes(segment!) : segment === activeSegment
const shouldShowActiveLink = isActivated && activeLink && segment === activeLink.segment
return (
<div className={`
flex h-8 max-w-167.5 shrink-0 items-center rounded-xl px-0.5 text-sm font-medium max-[1024px]:max-w-100
flex h-8 max-w-[670px] shrink-0 items-center rounded-xl px-0.5 text-sm font-medium max-[1024px]:max-w-[400px]
${isActivated && 'bg-components-main-nav-nav-button-bg-active font-semibold shadow-md'}
${!curNav && !isActivated && 'hover:bg-components-main-nav-nav-button-bg-hover'}
`}
@ -57,8 +51,6 @@ const Nav = ({
// Don't clear state if opening in new tab/window
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0)
return
if (segment === 'snippets')
return
setAppDetail()
}}
className={cn('flex h-7 cursor-pointer items-center rounded-[10px] px-2.5', isActivated ? 'text-components-main-nav-nav-button-text-active' : 'text-components-main-nav-nav-button-text', curNav && isActivated && 'hover:bg-components-main-nav-nav-button-bg-active-hover')}
@ -68,7 +60,7 @@ const Nav = ({
<div>
{
(hovered && curNav)
? <span className="i-custom-vender-line-arrows-arrow-narrow-left size-4" />
? <ArrowNarrowLeft className="size-4" />
: isActivated
? activeIcon
: icon
@ -95,19 +87,6 @@ const Nav = ({
</>
)
}
{
!curNav && shouldShowActiveLink && (
<>
<div className="font-light text-divider-deep">/</div>
<Link
href={activeLink.link}
className="hover:bg-components-main-nav-nav-button-bg-active-hover flex h-7 cursor-pointer items-center rounded-[10px] px-2.5 text-components-main-nav-nav-button-text-active"
>
{activeLink.text}
</Link>
</>
)
}
</div>
)
}

View File

@ -465,6 +465,14 @@ describe('Card', () => {
expect(screen.queryByTestId('partner-badge')).not.toBeInTheDocument()
})
it('should handle null badges from the marketplace API', () => {
const plugin = createMockPlugin({ badges: null })
render(<Card payload={plugin} />)
expect(screen.queryByTestId('partner-badge')).not.toBeInTheDocument()
})
})
// ================================

View File

@ -53,7 +53,8 @@ const Card = ({
const { t } = useTranslation()
const { categoriesMap } = useCategories(true)
const currentWorkspaceId = useSelector(s => s.currentWorkspace.id)
const { category, type, name, org, label, brief, icon, icon_dark, verified, badges = [], from } = payload
const { category, type, name, org, label, brief, icon, icon_dark, verified, from } = payload
const badges = payload.badges ?? []
const { theme } = useTheme()
const iconSrc = getPluginCardIconUrl(
{ from, name, org, type },

View File

@ -190,7 +190,7 @@ export type PluginManifestInMarket = {
introduction: string
verified: boolean
install_count: number
badges: string[]
badges: string[] | null
verification: {
authorized_category: 'langgenius' | 'partner' | 'community'
}
@ -255,7 +255,7 @@ export type Plugin = {
settings: CredentialFormSchemaBase[]
}
tags: { name: string }[]
badges: string[]
badges: string[] | null
verification: {
authorized_category: 'langgenius' | 'partner' | 'community'
}

View File

@ -1,278 +0,0 @@
import { fireEvent, screen } from '@testing-library/react'
import * as React from 'react'
import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
import { renderWithNuqs } from '@/test/nuqs-testing'
import SnippetList from '..'
const mockUseInfiniteSnippetList = vi.hoisted(() => vi.fn())
const mockSetKeywords = vi.hoisted(() => vi.fn())
const mockSetTagIDs = vi.hoisted(() => vi.fn())
const mockSetCreatorID = vi.hoisted(() => vi.fn())
const mockQueryState = vi.hoisted(() => ({
tagIDs: [] as string[],
keywords: '',
creatorID: '',
}))
vi.mock('@/service/use-snippets', () => ({
useCreateSnippetMutation: () => ({
mutate: vi.fn(),
isPending: false,
}),
useDeleteSnippetMutation: () => ({
mutate: vi.fn(),
isPending: false,
}),
useExportSnippetMutation: () => ({
mutateAsync: vi.fn(),
}),
useImportSnippetDSLMutation: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),
useConfirmSnippetImportMutation: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),
useInfiniteSnippetList: (params: unknown, options: unknown) => mockUseInfiniteSnippetList(params, options),
useUpdateSnippetMutation: () => ({
mutate: vi.fn(),
isPending: false,
}),
}))
vi.mock('../hooks/use-snippets-query-state', () => ({
useSnippetsQueryState: () => ({
query: mockQueryState,
setKeywords: mockSetKeywords,
setTagIDs: mockSetTagIDs,
setCreatorID: mockSetCreatorID,
}),
}))
vi.mock('@/service/client', () => ({
consoleClient: {
systemFeatures: vi.fn(),
},
consoleQuery: {
tags: {
list: {
queryOptions: (options: unknown) => options,
},
},
systemFeatures: {
queryKey: () => ['console', 'systemFeatures'],
},
},
}))
const mockIsCurrentWorkspaceEditor = vi.fn(() => true)
const mockIsCurrentWorkspaceDatasetOperator = vi.fn(() => false)
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor(),
isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator(),
isLoadingCurrentWorkspace: false,
userProfile: { id: 'creator-1' },
}),
}))
vi.mock('@/service/use-common', () => ({
useMembers: () => ({
data: {
accounts: [
{ id: 'creator-1', name: 'Alice', avatar_url: null, status: 'active' },
{ id: 'creator-2', name: 'Bob', avatar_url: null, status: 'active' },
],
},
}),
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push: vi.fn() }),
useSearchParams: () => new URLSearchParams(''),
}))
vi.mock('@/next/dynamic', () => ({
default: () => {
return function MockDynamicComponent() {
return React.createElement('div', { 'data-testid': 'tag-management-modal' })
}
},
}))
vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({
default: () => null,
}))
vi.mock('@/features/tag-management/components/tag-selector', () => ({
TagSelector: () => <div data-testid="snippet-card-tags" />,
}))
vi.mock('@/hooks/use-document-title', () => ({
default: vi.fn(),
}))
const mockObserve = vi.fn()
const mockDisconnect = vi.fn()
beforeAll(() => {
globalThis.IntersectionObserver = class MockIntersectionObserver {
constructor(_callback: IntersectionObserverCallback) {}
observe = mockObserve
disconnect = mockDisconnect
unobserve = vi.fn()
root = null
rootMargin = ''
thresholds = []
takeRecords = () => []
} as unknown as typeof IntersectionObserver
})
const mockRefetch = vi.fn()
const mockFetchNextPage = vi.fn()
const mockSnippetListState = {
data: {
pages: [{
data: [
{
id: 'snippet-1',
name: 'Sales Snippet',
description: 'Builds a sales follow-up.',
type: 'node',
is_published: true,
use_count: 12,
tags: [],
created_at: 1704067200,
created_by: 'creator-1',
updated_at: 1704153600,
updated_by: 'creator-2',
},
],
page: 1,
limit: 30,
total: 1,
has_more: false,
}],
},
isLoading: false,
isFetching: false,
isFetchingNextPage: false,
hasNextPage: false,
error: null as Error | null,
}
const renderList = () => {
const { wrapper: SystemFeaturesWrapper } = createSystemFeaturesWrapper({
systemFeatures: { branding: { enabled: false } },
})
return renderWithNuqs(
<SystemFeaturesWrapper>
<SnippetList />
</SystemFeaturesWrapper>,
)
}
describe('SnippetList', () => {
beforeEach(() => {
vi.clearAllMocks()
mockQueryState.tagIDs = []
mockQueryState.keywords = ''
mockQueryState.creatorID = ''
mockIsCurrentWorkspaceEditor.mockReturnValue(true)
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(false)
mockUseInfiniteSnippetList.mockReturnValue({
...mockSnippetListState,
refetch: mockRefetch,
fetchNextPage: mockFetchNextPage,
})
})
it('renders the dedicated snippets list layout', () => {
renderList()
expect(screen.getByText('app.studio.filters.allCreators')).toBeInTheDocument()
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
expect(screen.getByPlaceholderText('workflow.tabs.searchSnippets')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'snippet.create' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /Sales Snippet/ })).toHaveAttribute('href', '/snippets/snippet-1/orchestrate')
expect(screen.getByTestId('tag-management-modal')).toBeInTheDocument()
})
it('passes creator, tag, and search filters to the snippets list query', () => {
mockQueryState.tagIDs = ['tag-1', 'tag-2']
mockQueryState.keywords = 'sales'
mockQueryState.creatorID = 'creator-1'
renderList()
expect(mockUseInfiniteSnippetList).toHaveBeenCalledWith({
page: 1,
limit: 30,
keyword: 'sales',
tag_ids: ['tag-1', 'tag-2'],
creator_id: 'creator-1',
}, {
enabled: true,
})
})
it('updates the search query state from the search input', () => {
renderList()
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'summary' } })
expect(mockSetKeywords).toHaveBeenCalledWith('summary')
})
it('clears the search query state', () => {
mockQueryState.keywords = 'summary'
renderList()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' }))
expect(mockSetKeywords).toHaveBeenCalledWith('')
})
it('updates the creator query state as a single creator filter', () => {
renderList()
fireEvent.click(screen.getByRole('button', { name: 'app.studio.filters.allCreators' }))
fireEvent.click(screen.getByRole('button', { name: /Bob/ }))
expect(mockSetCreatorID).toHaveBeenCalledWith('creator-2')
})
it('hides the create button for non-editors', () => {
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
renderList()
expect(screen.queryByRole('button', { name: 'snippet.create' })).not.toBeInTheDocument()
})
it('shows an empty state when no snippets are returned', () => {
mockUseInfiniteSnippetList.mockReturnValue({
...mockSnippetListState,
data: {
pages: [{
data: [],
page: 1,
limit: 30,
total: 0,
has_more: false,
}],
},
refetch: mockRefetch,
fetchNextPage: mockFetchNextPage,
})
renderList()
expect(screen.getByText('workflow.tabs.noSnippetsFound')).toBeInTheDocument()
})
})

View File

@ -1,198 +0,0 @@
import type { SnippetListItem } from '@/types/snippet'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import SnippetCard from '../snippet-card'
const {
mockDeleteMutate,
mockDownloadBlob,
mockExportMutateAsync,
mockOnRefresh,
mockToastError,
mockToastSuccess,
mockUpdateMutate,
} = vi.hoisted(() => ({
mockDeleteMutate: vi.fn(),
mockDownloadBlob: vi.fn(),
mockExportMutateAsync: vi.fn(),
mockOnRefresh: vi.fn(),
mockToastError: vi.fn(),
mockToastSuccess: vi.fn(),
mockUpdateMutate: vi.fn(),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceEditor: true,
}),
}))
vi.mock('@/service/use-common', () => ({
useMembers: () => ({
data: {
accounts: [
{ id: 'creator-id', name: 'Creator', email: 'creator@example.com', avatar: '', avatar_url: null, role: 'editor', last_login_at: '', created_at: '', status: 'active' },
{ id: 'updater-id', name: 'Updater', email: 'updater@example.com', avatar: '', avatar_url: null, role: 'editor', last_login_at: '', created_at: '', status: 'active' },
],
},
}),
}))
vi.mock('@/service/use-snippets', () => ({
useDeleteSnippetMutation: () => ({
mutate: mockDeleteMutate,
isPending: false,
}),
useExportSnippetMutation: () => ({
mutateAsync: mockExportMutateAsync,
}),
useUpdateSnippetMutation: () => ({
mutate: mockUpdateMutate,
isPending: false,
}),
}))
vi.mock('@/utils/time', () => ({
formatTime: () => 'formatted-time',
}))
vi.mock('@/utils/download', () => ({
downloadBlob: mockDownloadBlob,
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
success: mockToastSuccess,
error: mockToastError,
},
}))
vi.mock('@/features/tag-management/components/tag-selector', () => ({
TagSelector: ({ value }: { value: Array<{ name: string }> }) => (
<div data-testid="snippet-tags">{value.map(tag => tag.name).join(', ')}</div>
),
}))
const createSnippet = (overrides: Partial<SnippetListItem> = {}): SnippetListItem => ({
id: 'snippet-1',
name: 'Tone Rewriter',
description: 'Rewrites rough drafts.',
type: 'node',
is_published: true,
use_count: 19,
tags: [],
created_at: 1_704_067_200,
created_by: 'creator-id',
updated_at: 1_704_153_600,
updated_by: 'updater-id',
...overrides,
})
describe('SnippetCard', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render updater name and updated time from member data', () => {
render(<SnippetCard snippet={createSnippet()} />)
expect(screen.getByText('Tone Rewriter')).toBeInTheDocument()
expect(screen.getByText('Updater')).toBeInTheDocument()
expect(screen.getByText('formatted-time')).toBeInTheDocument()
expect(screen.queryByText('snippet.usageCount:{"count":19}')).not.toBeInTheDocument()
expect(screen.queryByText('Creator')).not.toBeInTheDocument()
expect(screen.queryByRole('img')).not.toBeInTheDocument()
})
it('should fall back to creator name when updater is unavailable', () => {
render(<SnippetCard snippet={createSnippet({ updated_by: 'missing-user' })} />)
expect(screen.getByText('Creator')).toBeInTheDocument()
expect(screen.getByText('formatted-time')).toBeInTheDocument()
})
it('should not render draft status for unpublished snippets', () => {
render(<SnippetCard snippet={createSnippet({ is_published: false })} />)
expect(screen.queryByText('snippet.draft')).not.toBeInTheDocument()
})
it('should render supported operations only', async () => {
render(<SnippetCard snippet={createSnippet()} />)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
expect(await screen.findByRole('menuitem', { name: 'snippet.menu.editInfo' })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: 'snippet.menu.exportSnippet' })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: 'snippet.menu.deleteSnippet' })).toBeInTheDocument()
expect(screen.queryByRole('menuitem', { name: /duplicate/i })).not.toBeInTheDocument()
})
it('should export a snippet from the operations menu', async () => {
mockExportMutateAsync.mockResolvedValue('snippet-yaml')
render(<SnippetCard snippet={createSnippet()} />)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
fireEvent.click(await screen.findByRole('menuitem', { name: 'snippet.menu.exportSnippet' }))
await waitFor(() => {
expect(mockExportMutateAsync).toHaveBeenCalledWith({ snippetId: 'snippet-1' })
expect(mockDownloadBlob).toHaveBeenCalledWith(expect.objectContaining({
fileName: 'Tone Rewriter.yml',
}))
})
})
it('should update snippet info from the operations menu', async () => {
mockUpdateMutate.mockImplementation((_payload, options?: { onSuccess?: () => void }) => {
options?.onSuccess?.()
})
render(<SnippetCard snippet={createSnippet()} onRefresh={mockOnRefresh} />)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
fireEvent.click(await screen.findByRole('menuitem', { name: 'snippet.menu.editInfo' }))
fireEvent.change(screen.getByPlaceholderText('workflow.snippet.namePlaceholder'), {
target: { value: 'Updated Snippet' },
})
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
await waitFor(() => {
expect(mockUpdateMutate).toHaveBeenCalledWith({
params: { snippetId: 'snippet-1' },
body: {
name: 'Updated Snippet',
description: 'Rewrites rough drafts.',
},
}, expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}))
expect(mockOnRefresh).toHaveBeenCalled()
})
})
it('should delete a snippet from the operations menu', async () => {
mockDeleteMutate.mockImplementation((_payload, options?: { onSuccess?: () => void }) => {
options?.onSuccess?.()
})
render(<SnippetCard snippet={createSnippet()} onRefresh={mockOnRefresh} />)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
fireEvent.click(await screen.findByRole('menuitem', { name: 'snippet.menu.deleteSnippet' }))
fireEvent.click(screen.getByRole('button', { name: 'snippet.menu.deleteSnippet' }))
await waitFor(() => {
expect(mockDeleteMutate).toHaveBeenCalledWith({
params: { snippetId: 'snippet-1' },
}, expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}))
expect(mockOnRefresh).toHaveBeenCalled()
})
})
})
})

View File

@ -1,111 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import SnippetCreateButton from '../snippet-create-button'
const { mockPush, mockCreateMutate, mockImportMutateAsync, mockConfirmImportMutateAsync, mockToastSuccess, mockToastError } = vi.hoisted(() => ({
mockPush: vi.fn(),
mockCreateMutate: vi.fn(),
mockImportMutateAsync: vi.fn(),
mockConfirmImportMutateAsync: vi.fn(),
mockToastSuccess: vi.fn(),
mockToastError: vi.fn(),
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
success: mockToastSuccess,
error: mockToastError,
},
}))
vi.mock('@/service/use-snippets', () => ({
useCreateSnippetMutation: () => ({
mutate: mockCreateMutate,
isPending: false,
}),
useImportSnippetDSLMutation: () => ({
mutateAsync: mockImportMutateAsync,
isPending: false,
}),
useConfirmSnippetImportMutation: () => ({
mutateAsync: mockConfirmImportMutateAsync,
isPending: false,
}),
}))
describe('SnippetCreateButton', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should open the create dialog and create a snippet from the modal', async () => {
mockCreateMutate.mockImplementation((_payload, options?: { onSuccess?: (snippet: { id: string }) => void }) => {
options?.onSuccess?.({ id: 'snippet-123' })
})
render(<SnippetCreateButton />)
fireEvent.click(screen.getByRole('button', { name: 'snippet.create' }))
fireEvent.click(screen.getByRole('button', { name: 'snippet.createFromBlank' }))
expect(screen.getByText('workflow.snippet.createDialogTitle')).toBeInTheDocument()
fireEvent.change(screen.getByPlaceholderText('workflow.snippet.namePlaceholder'), {
target: { value: 'My Snippet' },
})
fireEvent.change(screen.getByPlaceholderText('workflow.snippet.descriptionPlaceholder'), {
target: { value: 'Useful snippet description' },
})
fireEvent.click(screen.getByRole('button', { name: /workflow\.snippet\.confirm/i }))
expect(mockCreateMutate).toHaveBeenCalledWith({
body: {
name: 'My Snippet',
description: 'Useful snippet description',
},
}, expect.objectContaining({
onSuccess: expect.any(Function),
}))
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith('/snippets/snippet-123/orchestrate')
})
expect(mockToastSuccess).toHaveBeenCalledWith('workflow.snippet.createSuccess')
})
it('should import a snippet from a DSL URL', async () => {
mockImportMutateAsync.mockResolvedValue({
id: 'import-1',
status: 'completed',
snippet_id: 'snippet-imported',
error: '',
})
render(<SnippetCreateButton />)
fireEvent.click(screen.getByRole('button', { name: 'snippet.create' }))
fireEvent.click(screen.getByRole('button', { name: 'snippet.importDSLFile' }))
expect(screen.getByText('snippet.importDialogTitle')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'snippet.importFromDSLUrl' }))
fireEvent.change(screen.getByPlaceholderText('snippet.importFromDSLUrlPlaceholder'), {
target: { value: 'https://example.com/snippet.yml' },
})
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
await waitFor(() => {
expect(mockImportMutateAsync).toHaveBeenCalledWith({
mode: 'yaml-url',
yamlContent: undefined,
yamlUrl: 'https://example.com/snippet.yml',
})
})
expect(mockToastSuccess).toHaveBeenCalledWith('snippet.importSuccess')
expect(mockPush).toHaveBeenCalledWith('/snippets/snippet-imported/orchestrate')
})
})

View File

@ -1,259 +0,0 @@
'use client'
import type { SnippetListItem } from '@/types/snippet'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { toast } from '@langgenius/dify-ui/toast'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog'
import { useAppContext } from '@/context/app-context'
import { TagSelector } from '@/features/tag-management/components/tag-selector'
import Link from '@/next/link'
import { useMembers } from '@/service/use-common'
import { useDeleteSnippetMutation, useExportSnippetMutation, useUpdateSnippetMutation } from '@/service/use-snippets'
import { downloadBlob } from '@/utils/download'
import { formatTime } from '@/utils/time'
type Props = {
snippet: SnippetListItem
onOpenTagManagement?: () => void
onRefresh?: () => void
onTagsChange?: () => void
}
const SnippetCard = ({
snippet,
onOpenTagManagement = () => {},
onRefresh,
onTagsChange,
}: Props) => {
const { t } = useTranslation('snippet')
const { t: tCommon } = useTranslation()
const { isCurrentWorkspaceEditor } = useAppContext()
const { data: membersData } = useMembers()
const [isOperationsMenuOpen, setIsOperationsMenuOpen] = useState(false)
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const updateSnippetMutation = useUpdateSnippetMutation()
const exportSnippetMutation = useExportSnippetMutation()
const deleteSnippetMutation = useDeleteSnippetMutation()
const memberNameById = useMemo(() => {
return new Map((membersData?.accounts ?? []).map(member => [member.id, member.name]))
}, [membersData?.accounts])
const updatedByName = memberNameById.get(snippet.updated_by)
|| memberNameById.get(snippet.created_by)
|| t('unknownUser')
const updatedAt = snippet.updated_at || snippet.created_at
const updatedAtText = formatTime({
date: (updatedAt > 1_000_000_000_000 ? updatedAt : updatedAt * 1000),
dateFormat: `${t('segment.dateTimeFormat', { ns: 'datasetDocuments' })}`,
})
const initialValue = useMemo(() => ({
name: snippet.name,
description: snippet.description,
}), [snippet.description, snippet.name])
const handleOpenEditDialog = () => {
setIsOperationsMenuOpen(false)
setIsEditDialogOpen(true)
}
const handleExportSnippet = async () => {
setIsOperationsMenuOpen(false)
try {
const data = await exportSnippetMutation.mutateAsync({ snippetId: snippet.id })
const file = new Blob([data], { type: 'application/yaml' })
downloadBlob({ data: file, fileName: `${snippet.name}.yml` })
}
catch {
toast.error(t('exportFailed'))
}
}
const handleDeleteSnippet = () => {
deleteSnippetMutation.mutate({
params: { snippetId: snippet.id },
}, {
onSuccess: () => {
toast.success(t('deleted'))
setIsDeleteDialogOpen(false)
onRefresh?.()
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : t('deleteFailed'))
},
})
}
const handleUpdateSnippet = ({ name, description }: {
name: string
description: string
}) => {
updateSnippetMutation.mutate({
params: { snippetId: snippet.id },
body: {
name,
description: description || undefined,
},
}, {
onSuccess: () => {
toast.success(t('editDone'))
setIsEditDialogOpen(false)
onRefresh?.()
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : t('editFailed'))
},
})
}
return (
<>
<article className="group relative col-span-1 inline-flex h-40 w-full cursor-pointer flex-col rounded-xl border border-solid border-components-card-border bg-components-card-bg shadow-sm transition-shadow duration-200 ease-in-out hover:shadow-lg">
<Link href={`/snippets/${snippet.id}/orchestrate`} className="flex min-h-0 flex-1 flex-col">
<div className="flex h-16.5 shrink-0 grow-0 flex-col justify-center px-3.5 pt-3.5 pb-3">
<div className="flex items-center text-sm/5 font-semibold text-text-secondary">
<div className="truncate" title={snippet.name}>{snippet.name}</div>
</div>
<div className="flex items-center gap-1 text-2xs leading-4.5 font-medium text-text-tertiary">
<div className="truncate" title={updatedByName}>{updatedByName}</div>
<div>·</div>
<div className="truncate" title={updatedAtText}>{updatedAtText}</div>
</div>
</div>
<div className="h-22.5 px-3.5 text-xs leading-normal text-text-tertiary">
<div className="line-clamp-2" title={snippet.description}>
{snippet.description}
</div>
</div>
</Link>
<div className="absolute right-0 bottom-1 left-0 flex h-10.5 shrink-0 items-center pt-1 pr-1.5 pb-1.5 pl-3.5">
<div
className="flex w-0 grow items-center gap-1"
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
}}
>
<div className="mr-10.25 min-w-0 grow overflow-hidden">
<TagSelector
placement="bottom-start"
type="snippet"
targetId={snippet.id}
value={snippet.tags}
onOpenTagManagement={onOpenTagManagement}
onTagsChange={onTagsChange}
/>
</div>
</div>
{isCurrentWorkspaceEditor && (
<div
className={cn(
'absolute top-1/2 right-1.5 flex -translate-y-1/2 items-center transition-opacity',
isOperationsMenuOpen
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100',
)}
>
<div className="mx-1 h-3.5 w-px shrink-0 bg-divider-regular" />
<DropdownMenu modal={false} open={isOperationsMenuOpen} onOpenChange={setIsOperationsMenuOpen}>
<DropdownMenuTrigger
aria-label={tCommon('operation.more', { ns: 'common' })}
className="flex size-8 items-center justify-center rounded-md border-none bg-transparent p-2 hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:ring-inset data-popup-open:bg-state-base-hover data-popup-open:shadow-none"
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
}}
>
<div className="flex size-8 cursor-pointer items-center justify-center rounded-md">
<span className="sr-only">{tCommon('operation.more', { ns: 'common' })}</span>
<span aria-hidden className="i-ri-more-fill size-4 text-text-tertiary" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="w-[216px]"
>
<DropdownMenuItem className="gap-2 px-3" onClick={handleOpenEditDialog}>
<span className="system-sm-regular text-text-secondary">{t('menu.editInfo')}</span>
</DropdownMenuItem>
<DropdownMenuItem className="gap-2 px-3" onClick={handleExportSnippet}>
<span className="system-sm-regular text-text-secondary">{t('menu.exportSnippet')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
className="gap-2 px-3"
onClick={() => {
setIsOperationsMenuOpen(false)
setIsDeleteDialogOpen(true)
}}
>
<span className="system-sm-regular">{t('menu.deleteSnippet')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
</article>
{isEditDialogOpen && (
<CreateSnippetDialog
isOpen={isEditDialogOpen}
initialValue={initialValue}
title={t('editDialogTitle')}
confirmText={tCommon('operation.save', { ns: 'common' })}
isSubmitting={updateSnippetMutation.isPending}
onClose={() => setIsEditDialogOpen(false)}
onConfirm={handleUpdateSnippet}
/>
)}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent className="w-100">
<div className="space-y-2 p-6">
<AlertDialogTitle className="title-md-semi-bold text-text-primary">
{t('deleteConfirmTitle')}
</AlertDialogTitle>
<AlertDialogDescription className="system-sm-regular text-text-tertiary">
{t('deleteConfirmContent')}
</AlertDialogDescription>
</div>
<AlertDialogActions className="pt-0">
<AlertDialogCancelButton disabled={deleteSnippetMutation.isPending}>
{tCommon('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
loading={deleteSnippetMutation.isPending}
onClick={handleDeleteSnippet}
>
{t('menu.deleteSnippet')}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}
export default SnippetCard

View File

@ -1,111 +0,0 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { toast } from '@langgenius/dify-ui/toast'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog'
import ImportSnippetDSLDialog from '@/app/components/snippets/import-snippet-dsl-dialog'
import { useRouter } from '@/next/navigation'
import {
useCreateSnippetMutation,
} from '@/service/use-snippets'
const SnippetCreateButton = () => {
const { t } = useTranslation('snippet')
const { push } = useRouter()
const createSnippetMutation = useCreateSnippetMutation()
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false)
const [isMenuOpen, setIsMenuOpen] = useState(false)
const handleCreateSnippet = ({
name,
description,
}: {
name: string
description: string
}) => {
createSnippetMutation.mutate({
body: {
name,
description: description || undefined,
},
}, {
onSuccess: (snippet) => {
toast.success(t('snippet.createSuccess', { ns: 'workflow' }))
setIsCreateDialogOpen(false)
push(`/snippets/${snippet.id}/orchestrate`)
},
})
}
return (
<>
<Popover open={isMenuOpen} onOpenChange={setIsMenuOpen}>
<PopoverTrigger
render={(
<Button disabled={createSnippetMutation.isPending}>
<span aria-hidden className="mr-0.5 i-ri-add-line size-4" />
<span>{t('create')}</span>
<span aria-hidden className="ml-0.5 i-ri-arrow-down-s-line size-4" />
</Button>
)}
/>
<PopoverContent
placement="bottom-end"
sideOffset={6}
popupClassName="w-[228px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-xs"
>
<div className="px-2 pt-2 pb-1 text-xs leading-4.5 font-medium text-text-tertiary">
{t('createFrom')}
</div>
<button
type="button"
className="mb-1 flex w-full cursor-pointer items-center rounded-lg px-2 py-1.75 text-[13px] leading-4.5 font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
onClick={() => {
setIsMenuOpen(false)
setIsCreateDialogOpen(true)
}}
>
<span aria-hidden className="mr-2 i-custom-vender-line-files-file-plus-01 size-4 shrink-0" />
<span>{t('createFromBlank')}</span>
</button>
<button
type="button"
className="flex w-full cursor-pointer items-center rounded-lg px-2 py-1.75 text-[13px] leading-4.5 font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
onClick={() => {
setIsMenuOpen(false)
setIsImportDialogOpen(true)
}}
>
<span aria-hidden className="mr-2 i-custom-vender-line-files-file-arrow-01 size-4 shrink-0" />
<span>{t('importDSLFile')}</span>
</button>
</PopoverContent>
</Popover>
{isCreateDialogOpen && (
<CreateSnippetDialog
isOpen={isCreateDialogOpen}
isSubmitting={createSnippetMutation.isPending}
onClose={() => setIsCreateDialogOpen(false)}
onConfirm={handleCreateSnippet}
/>
)}
{isImportDialogOpen && (
<ImportSnippetDSLDialog
isOpen={isImportDialogOpen}
onClose={() => setIsImportDialogOpen(false)}
/>
)}
</>
)
}
export default SnippetCreateButton

View File

@ -1 +0,0 @@
export const SNIPPET_LIST_SEARCH_DEBOUNCE_MS = 500

View File

@ -1,38 +0,0 @@
import { debounce, parseAsArrayOf, parseAsString, useQueryStates } from 'nuqs'
import { useCallback, useMemo } from 'react'
import { SNIPPET_LIST_SEARCH_DEBOUNCE_MS } from '../constants'
const snippetListQueryParsers = {
tagIDs: parseAsArrayOf(parseAsString, ';')
.withDefault([])
.withOptions({ history: 'push' }),
keywords: parseAsString.withDefault('').withOptions({
limitUrlUpdates: debounce(SNIPPET_LIST_SEARCH_DEBOUNCE_MS),
}),
creatorID: parseAsString
.withDefault('')
.withOptions({ history: 'push' }),
}
export function useSnippetsQueryState() {
const [query, setQuery] = useQueryStates(snippetListQueryParsers)
const setKeywords = useCallback((keywords: string) => {
setQuery({ keywords })
}, [setQuery])
const setTagIDs = useCallback((tagIDs: string[]) => {
setQuery({ tagIDs })
}, [setQuery])
const setCreatorID = useCallback((creatorID: string) => {
setQuery({ creatorID })
}, [setQuery])
return useMemo(() => ({
query,
setKeywords,
setTagIDs,
setCreatorID,
}), [query, setCreatorID, setKeywords, setTagIDs])
}

View File

@ -1,195 +0,0 @@
'use client'
import type { SnippetListItem } from '@/types/snippet'
import { cn } from '@langgenius/dify-ui/cn'
import { Input } from '@langgenius/dify-ui/input'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useDebounce } from 'ahooks'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useAppContext } from '@/context/app-context'
import { TagFilter } from '@/features/tag-management/components/tag-filter'
import useDocumentTitle from '@/hooks/use-document-title'
import dynamic from '@/next/dynamic'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useInfiniteSnippetList } from '@/service/use-snippets'
import CreatorsFilter from '../apps/creators-filter'
import Empty from '../apps/empty'
import Footer from '../apps/footer'
import SnippetCard from './components/snippet-card'
import SnippetCreateButton from './components/snippet-create-button'
import { SNIPPET_LIST_SEARCH_DEBOUNCE_MS } from './constants'
import { useSnippetsQueryState } from './hooks/use-snippets-query-state'
const TagManagementModal = dynamic(() => import('@/features/tag-management/components/tag-management-modal').then(mod => mod.TagManagementModal), {
ssr: false,
})
const SNIPPET_CARD_SKELETON_KEYS = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth']
type SnippetCardSkeletonProps = {
count: number
}
const SnippetCardSkeleton = ({ count }: SnippetCardSkeletonProps) => {
return (
<>
{SNIPPET_CARD_SKELETON_KEYS.slice(0, count).map(key => (
<div
key={key}
className="col-span-1 h-55 animate-pulse rounded-xl bg-background-default-lighter"
/>
))}
</>
)
}
const SnippetList = () => {
const { t } = useTranslation()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
// eslint-disable-next-line react/use-state -- custom URL query hook, not React.useState
const {
query: { tagIDs, keywords, creatorID },
setKeywords,
setTagIDs,
setCreatorID,
} = useSnippetsQueryState()
const debouncedKeywords = useDebounce(keywords, { wait: SNIPPET_LIST_SEARCH_DEBOUNCE_MS })
const containerRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const [showTagManagementModal, setShowTagManagementModal] = useState(false)
useDocumentTitle(t('tabs.snippets', { ns: 'workflow' }))
const snippetListQuery = useMemo(() => ({
page: 1,
limit: 30,
keyword: debouncedKeywords,
...(tagIDs.length ? { tag_ids: tagIDs } : {}),
...(creatorID ? { creator_id: creatorID } : {}),
}), [creatorID, debouncedKeywords, tagIDs])
const {
data,
isLoading,
isFetching,
isFetchingNextPage,
fetchNextPage,
hasNextPage,
error,
refetch,
} = useInfiniteSnippetList(snippetListQuery, {
enabled: !isCurrentWorkspaceDatasetOperator,
})
useEffect(() => {
if (isCurrentWorkspaceDatasetOperator)
return
const hasMore = hasNextPage ?? true
let observer: IntersectionObserver | undefined
if (error) {
if (observer)
observer.disconnect()
return
}
if (anchorRef.current && containerRef.current) {
const containerHeight = containerRef.current.clientHeight
const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200))
observer = new IntersectionObserver((entries) => {
if (entries[0]!.isIntersecting && !isLoading && !isFetchingNextPage && !error && hasMore)
fetchNextPage()
}, {
root: containerRef.current,
rootMargin: `${dynamicMargin}px`,
threshold: 0.1,
})
observer.observe(anchorRef.current)
}
return () => observer?.disconnect()
}, [error, fetchNextPage, hasNextPage, isCurrentWorkspaceDatasetOperator, isFetchingNextPage, isLoading])
const handleCreatorsChange = useCallback((creatorIDs: string[]) => {
setCreatorID(creatorIDs.at(-1) ?? '')
}, [setCreatorID])
const pages = useMemo(() => data?.pages ?? [], [data?.pages])
const snippets = useMemo<SnippetListItem[]>(() => pages.flatMap(({ data: pageSnippets }) => pageSnippets), [pages])
const hasAnySnippet = (pages[0]?.total ?? 0) > 0
const showSkeleton = isLoading || (isFetching && pages.length === 0)
return (
<div ref={containerRef} className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-x-4 gap-y-2 bg-background-body px-12 pt-7 pb-5">
<div className="flex flex-wrap items-center gap-2">
<CreatorsFilter
value={creatorID ? [creatorID] : []}
onChange={handleCreatorsChange}
/>
<TagFilter type="snippet" value={tagIDs} onChange={setTagIDs} onOpenTagManagement={() => setShowTagManagementModal(true)} />
<div className="relative w-50">
<span aria-hidden className="pointer-events-none absolute top-1/2 left-2 i-ri-search-line size-4 -translate-y-1/2 text-components-input-text-placeholder" />
<Input
className={cn('pl-6.5', keywords && 'pr-6.5')}
value={keywords}
onChange={e => setKeywords(e.target.value)}
placeholder={t('tabs.searchSnippets', { ns: 'workflow' })}
/>
{!!keywords && (
<button
type="button"
aria-label={t('operation.clear', { ns: 'common' })}
className="absolute top-1/2 right-2 flex size-4 -translate-y-1/2 items-center justify-center text-components-input-text-placeholder hover:text-components-input-text-filled"
onClick={() => setKeywords('')}
>
<span aria-hidden className="i-ri-close-circle-fill size-4" />
</button>
)}
</div>
</div>
{(isCurrentWorkspaceEditor || isLoadingCurrentWorkspace) && (
<SnippetCreateButton />
)}
</div>
<div className={cn(
'relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 2k:grid-cols-6 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5',
!hasAnySnippet && 'overflow-hidden',
)}
>
{showSkeleton
? <SnippetCardSkeleton count={6} />
: hasAnySnippet
? snippets.map(snippet => (
<SnippetCard
key={snippet.id}
snippet={snippet}
onOpenTagManagement={() => setShowTagManagementModal(true)}
onRefresh={refetch}
onTagsChange={refetch}
/>
))
: <Empty message={t('tabs.noSnippetsFound', { ns: 'workflow' })} />}
{isFetchingNextPage && (
<SnippetCardSkeleton count={3} />
)}
</div>
{!systemFeatures.branding.enabled && (
<Footer />
)}
<div ref={anchorRef} className="h-0"> </div>
<TagManagementModal
type="snippet"
show={showTagManagementModal}
onClose={() => setShowTagManagementModal(false)}
onTagsChange={refetch}
/>
</div>
)
}
export default SnippetList

View File

@ -1,137 +0,0 @@
import type { SnippetDetailPayload } from '@/models/snippet'
import { render, screen } from '@testing-library/react'
import SnippetPage from '..'
const mockUseSnippetInit = vi.fn()
const mockSetAppSidebarExpand = vi.fn()
vi.mock('../hooks/use-snippet-init', () => ({
useSnippetInit: (snippetId: string) => mockUseSnippetInit(snippetId),
}))
vi.mock('../components/snippet-main', () => ({
default: ({ snippetId }: { snippetId: string }) => <div data-testid="snippet-main">{snippetId}</div>,
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
replace: vi.fn(),
push: vi.fn(),
}),
}))
vi.mock('@/hooks/use-breakpoints', () => ({
default: () => 'desktop',
MediaType: { mobile: 'mobile', desktop: 'desktop' },
}))
vi.mock('@/hooks/use-document-title', () => ({
default: vi.fn(),
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: { setAppSidebarExpand: typeof mockSetAppSidebarExpand }) => unknown) => selector({
setAppSidebarExpand: mockSetAppSidebarExpand,
}),
}))
vi.mock('@/app/components/workflow', () => ({
default: ({ children }: { children: React.ReactNode }) => (
<div data-testid="workflow-default-context">{children}</div>
),
}))
vi.mock('@/app/components/workflow/context', () => ({
WorkflowContextProvider: ({ children }: { children: React.ReactNode }) => (
<div data-testid="workflow-context-provider">{children}</div>
),
}))
vi.mock('@/app/components/workflow/utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/workflow/utils')>()
return {
...actual,
initialNodes: (nodes: unknown[]) => nodes,
initialEdges: (edges: unknown[]) => edges,
}
})
vi.mock('@/app/components/app-sidebar', () => ({
default: ({
renderHeader,
renderNavigation,
}: {
renderHeader?: (modeState: string) => React.ReactNode
renderNavigation?: (modeState: string) => React.ReactNode
}) => (
<div data-testid="app-sidebar">
<div data-testid="app-sidebar-header">{renderHeader?.('expand')}</div>
<div data-testid="app-sidebar-navigation">{renderNavigation?.('expand')}</div>
</div>
),
}))
vi.mock('@/app/components/app-sidebar/nav-link', () => ({
default: ({ name, onClick }: { name: string, onClick?: () => void }) => (
<button type="button" onClick={onClick}>{name}</button>
),
}))
vi.mock('@/app/components/app-sidebar/snippet-info', () => ({
default: () => <div data-testid="snippet-info" />,
}))
const mockSnippetDetail: SnippetDetailPayload = {
snippet: {
id: 'snippet-1',
name: 'Tone Rewriter',
description: 'A static snippet mock.',
updatedAt: 'Updated 2h ago',
usage: 'Used 19 times',
tags: [],
status: 'Draft',
},
graph: {
viewport: { x: 0, y: 0, zoom: 1 },
nodes: [],
edges: [],
},
inputFields: [],
uiMeta: {
inputFieldCount: 0,
checklistCount: 0,
autoSavedAt: 'Auto-saved · a few seconds ago',
},
}
describe('SnippetPage', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseSnippetInit.mockReturnValue({
data: mockSnippetDetail,
isLoading: false,
})
})
it('should render the orchestrate route shell with independent main content', () => {
render(<SnippetPage snippetId="snippet-1" />)
expect(screen.getByTestId('app-sidebar')).toBeInTheDocument()
expect(screen.getByTestId('snippet-info')).toBeInTheDocument()
expect(screen.getByTestId('workflow-context-provider')).toBeInTheDocument()
expect(screen.getByTestId('workflow-default-context')).toBeInTheDocument()
expect(screen.getByTestId('snippet-main')).toHaveTextContent('snippet-1')
})
it('should render loading fallback when orchestrate data is unavailable', () => {
mockUseSnippetInit.mockReturnValue({
data: null,
isLoading: false,
})
render(<SnippetPage snippetId="missing-snippet" />)
expect(screen.getByRole('status')).toBeInTheDocument()
})
})

View File

@ -1,60 +0,0 @@
import type { SnippetDetail } from '@/models/snippet'
import { render, screen } from '@testing-library/react'
import SnippetLayout from '../snippet-layout'
const mockUseDocumentTitle = vi.fn()
vi.mock('@/hooks/use-document-title', () => ({
default: (title: string) => mockUseDocumentTitle(title),
}))
const createSnippet = (overrides: Partial<SnippetDetail> = {}): SnippetDetail => ({
id: 'snippet-1',
name: 'Snippet Title',
description: 'Snippet description',
updatedAt: '2026-04-15',
usage: '42',
tags: [],
is_published: true,
...overrides,
})
describe('SnippetLayout', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
})
describe('Document title', () => {
it('should set the document title to the snippet name when snippet detail is available', () => {
render(
<SnippetLayout
snippetId="snippet-1"
snippet={createSnippet()}
section="orchestrate"
>
<div>content</div>
</SnippetLayout>,
)
expect(mockUseDocumentTitle).toHaveBeenCalledWith('Snippet Title')
})
})
describe('Layout', () => {
it('should render the detail content without the app detail sidebar navigation', () => {
render(
<SnippetLayout
snippetId="snippet-1"
snippet={createSnippet()}
section="orchestrate"
>
<div>content</div>
</SnippetLayout>,
)
expect(screen.getByText('content')).toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'snippet.sectionOrchestrate' })).not.toBeInTheDocument()
})
})
})

View File

@ -1,406 +0,0 @@
import type { ReactNode } from 'react'
import type { WorkflowProps } from '@/app/components/workflow'
import type { SnippetDetailPayload, SnippetInputField } from '@/models/snippet'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { BlockEnum } from '@/app/components/workflow/types'
import { PipelineInputVarType } from '@/models/pipeline'
import SnippetMain from '../snippet-main'
const mockSyncInputFieldsDraft = vi.fn()
const mockReset = vi.fn()
const mockSetFields = vi.fn()
const mockPublishSnippetMutateAsync = vi.fn()
const mockUseSnippetPublishedWorkflow = vi.fn()
const mockFetchInspectVars = vi.fn()
const mockHandleBackupDraft = vi.fn()
const mockHandleLoadBackupDraft = vi.fn()
const mockHandleRestoreFromPublishedWorkflow = vi.fn()
const mockHandleRun = vi.fn()
const mockHandleStartWorkflowRun = vi.fn()
const mockHandleStopRun = vi.fn()
const mockHandleWorkflowStartRunInWorkflow = vi.fn()
const mockHandleCheckBeforePublish = vi.fn()
const mockUseAvailableNodesMetaData = vi.hoisted(() => vi.fn())
const mockInspectVarsCrud = {
hasNodeInspectVars: vi.fn(),
hasSetInspectVar: vi.fn(),
fetchInspectVarValue: vi.fn(),
editInspectVarValue: vi.fn(),
renameInspectVarName: vi.fn(),
appendNodeInspectVars: vi.fn(),
deleteInspectVar: vi.fn(),
deleteNodeInspectorVars: vi.fn(),
deleteAllInspectorVars: vi.fn(),
isInspectVarEdited: vi.fn(),
resetToLastRunVar: vi.fn(),
invalidateSysVarValues: vi.fn(),
resetConversationVar: vi.fn(),
invalidateConversationVarValues: vi.fn(),
}
let capturedHooksStore: Record<string, unknown> | undefined
let snippetDetailStoreState: {
fields: SnippetInputField[]
reset: typeof mockReset
setFields: typeof mockSetFields
}
vi.mock('@/app/components/snippets/store', () => ({
useSnippetDetailStore: (selector: (state: typeof snippetDetailStoreState) => unknown) => selector(snippetDetailStoreState),
}))
vi.mock('@/service/use-snippet-workflows', () => ({
usePublishSnippetWorkflowMutation: () => ({
mutateAsync: mockPublishSnippetMutateAsync,
isPending: false,
}),
useSnippetPublishedWorkflow: () => mockUseSnippetPublishedWorkflow(),
}))
vi.mock('@/app/components/snippets/hooks/use-configs-map', () => ({
useConfigsMap: () => ({
flowId: 'snippet-1',
flowType: 'snippet',
fileSettings: {},
}),
}))
vi.mock('@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars', () => ({
useSetWorkflowVarsWithValue: () => ({
fetchInspectVars: mockFetchInspectVars,
}),
}))
vi.mock('@/app/components/workflow/hooks/use-checklist', () => ({
useChecklistBeforePublish: () => ({
handleCheckBeforePublish: mockHandleCheckBeforePublish,
}),
}))
vi.mock('@/app/components/workflow-app/hooks', () => ({
useAvailableNodesMetaData: () => mockUseAvailableNodesMetaData(),
}))
vi.mock('@/app/components/snippets/hooks/use-inspect-vars-crud', () => ({
useInspectVarsCrud: () => mockInspectVarsCrud,
}))
vi.mock('@/app/components/snippets/hooks/use-nodes-sync-draft', () => ({
useNodesSyncDraft: () => ({
doSyncWorkflowDraft: vi.fn(),
syncInputFieldsDraft: mockSyncInputFieldsDraft,
syncWorkflowDraftWhenPageClose: vi.fn(),
}),
}))
vi.mock('@/app/components/snippets/hooks/use-snippet-refresh-draft', () => ({
useSnippetRefreshDraft: () => ({
handleRefreshWorkflowDraft: vi.fn(),
}),
}))
vi.mock('@/app/components/snippets/hooks/use-snippet-run', () => ({
useSnippetRun: () => ({
handleBackupDraft: mockHandleBackupDraft,
handleLoadBackupDraft: mockHandleLoadBackupDraft,
handleRestoreFromPublishedWorkflow: mockHandleRestoreFromPublishedWorkflow,
handleRun: mockHandleRun,
handleStopRun: mockHandleStopRun,
}),
}))
vi.mock('@/app/components/snippets/hooks/use-snippet-start-run', () => ({
useSnippetStartRun: () => ({
handleStartWorkflowRun: mockHandleStartWorkflowRun,
handleWorkflowStartRunInWorkflow: mockHandleWorkflowStartRunInWorkflow,
}),
}))
vi.mock('@/app/components/workflow', () => ({
WorkflowWithInnerContext: ({
children,
hooksStore,
}: {
children: ReactNode
hooksStore?: Record<string, unknown>
}) => {
capturedHooksStore = hooksStore
return (
<div data-testid="workflow-inner-context">{children}</div>
)
},
}))
vi.mock('@/app/components/snippets/components/snippet-children', () => ({
default: ({
onCancel,
onEdit,
onPublish,
isEditing,
}: {
isEditing: boolean
onCancel: () => void
onEdit: () => void
onPublish: () => void
}) => (
<div>
{!isEditing && <button type="button" onClick={onEdit}>edit</button>}
<button type="button" onClick={onPublish}>publish</button>
<button type="button" onClick={onCancel}>cancel</button>
</div>
),
}))
vi.mock('@/app/components/snippets/components/snippet-sidebar', () => ({
default: ({
fields,
onFieldsChange,
}: {
fields: SnippetInputField[]
onFieldsChange: (fields: SnippetInputField[]) => void
}) => (
<div>
<button type="button" onClick={() => onFieldsChange([])}>remove</button>
<button
type="button"
onClick={() => onFieldsChange([
...fields,
{
type: PipelineInputVarType.textInput,
label: 'New Field',
variable: 'new_field',
required: true,
},
])}
>
submit
</button>
</div>
),
}))
const payload: SnippetDetailPayload = {
snippet: {
id: 'snippet-1',
name: 'Snippet',
description: 'desc',
updatedAt: '2026-03-29 10:00',
usage: '0',
tags: [],
},
graph: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
inputFields: [
{
type: PipelineInputVarType.textInput,
label: 'Blog URL',
variable: 'blog_url',
required: true,
},
],
uiMeta: {
inputFieldCount: 1,
checklistCount: 0,
autoSavedAt: '2026-03-29 10:00',
},
}
const renderSnippetMain = () => {
return renderWorkflowComponent(
<SnippetMain
payload={payload}
draftPayload={payload}
hasInitialDraftChanges={false}
snippetId="snippet-1"
nodes={[] as WorkflowProps['nodes']}
edges={[] as WorkflowProps['edges']}
viewport={{ x: 0, y: 0, zoom: 1 }}
draftNodes={[] as WorkflowProps['nodes']}
draftEdges={[] as WorkflowProps['edges']}
draftViewport={{ x: 0, y: 0, zoom: 1 }}
/>,
)
}
const createNodeMetadata = (type: BlockEnum) => ({
metaData: {
type,
},
defaultValue: {},
checkValid: vi.fn(),
})
describe('SnippetMain', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSyncInputFieldsDraft.mockResolvedValue(undefined)
mockPublishSnippetMutateAsync.mockResolvedValue({ created_at: 1_744_000_000 })
mockUseSnippetPublishedWorkflow.mockReturnValue({
data: {
graph: payload.graph,
input_fields: payload.inputFields,
},
refetch: vi.fn(),
})
const llmNodeMetadata = createNodeMetadata(BlockEnum.LLM)
const humanInputNodeMetadata = createNodeMetadata(BlockEnum.HumanInput)
const endNodeMetadata = createNodeMetadata(BlockEnum.End)
const knowledgeRetrievalNodeMetadata = createNodeMetadata(BlockEnum.KnowledgeRetrieval)
mockUseAvailableNodesMetaData.mockReturnValue({
nodes: [
llmNodeMetadata,
humanInputNodeMetadata,
endNodeMetadata,
knowledgeRetrievalNodeMetadata,
],
nodesMap: {
[BlockEnum.LLM]: llmNodeMetadata,
[BlockEnum.HumanInput]: humanInputNodeMetadata,
[BlockEnum.End]: endNodeMetadata,
[BlockEnum.KnowledgeRetrieval]: knowledgeRetrievalNodeMetadata,
},
})
mockHandleCheckBeforePublish.mockResolvedValue(true)
capturedHooksStore = undefined
snippetDetailStoreState = {
fields: [...payload.inputFields],
reset: mockReset,
setFields: mockSetFields,
}
})
describe('Input Fields Sync', () => {
it('should sync draft input_fields when removing a field from the panel', async () => {
renderSnippetMain()
fireEvent.click(screen.getByRole('button', { name: 'remove' }))
await waitFor(() => {
expect(mockSyncInputFieldsDraft).toHaveBeenCalledWith([], {
onRefresh: expect.any(Function),
})
})
})
it('should sync draft input_fields when adding a field from the sidebar', async () => {
renderSnippetMain()
fireEvent.click(screen.getByRole('button', { name: 'submit' }))
await waitFor(() => {
expect(mockSyncInputFieldsDraft).toHaveBeenCalledWith([
payload.inputFields[0],
{
type: PipelineInputVarType.textInput,
label: 'New Field',
variable: 'new_field',
required: true,
},
], {
onRefresh: expect.any(Function),
})
})
})
})
describe('Publish', () => {
it('should call the publish mutation', async () => {
renderSnippetMain()
fireEvent.click(screen.getByRole('button', { name: 'publish' }))
await waitFor(() => {
expect(mockPublishSnippetMutateAsync).toHaveBeenCalledWith({
params: { snippetId: 'snippet-1' },
})
})
})
})
describe('Cancel', () => {
it('should restore from the published workflow and reset published input fields', async () => {
renderSnippetMain()
fireEvent.click(screen.getByRole('button', { name: 'cancel' }))
await waitFor(() => {
expect(mockHandleRestoreFromPublishedWorkflow).toHaveBeenCalledWith({
graph: payload.graph,
input_fields: payload.inputFields,
})
expect(mockSetFields).toHaveBeenCalledWith(payload.inputFields)
expect(mockSyncInputFieldsDraft).toHaveBeenCalledWith(payload.inputFields, {
onRefresh: expect.any(Function),
})
})
})
})
describe('Inspect Vars', () => {
it('should pass inspect vars handlers to WorkflowWithInnerContext', () => {
renderSnippetMain()
expect(capturedHooksStore?.fetchInspectVars).toBe(mockFetchInspectVars)
expect(capturedHooksStore?.hasNodeInspectVars).toBe(mockInspectVarsCrud.hasNodeInspectVars)
expect(capturedHooksStore?.hasSetInspectVar).toBe(mockInspectVarsCrud.hasSetInspectVar)
expect(capturedHooksStore?.fetchInspectVarValue).toBe(mockInspectVarsCrud.fetchInspectVarValue)
expect(capturedHooksStore?.editInspectVarValue).toBe(mockInspectVarsCrud.editInspectVarValue)
expect(capturedHooksStore?.renameInspectVarName).toBe(mockInspectVarsCrud.renameInspectVarName)
expect(capturedHooksStore?.appendNodeInspectVars).toBe(mockInspectVarsCrud.appendNodeInspectVars)
expect(capturedHooksStore?.deleteInspectVar).toBe(mockInspectVarsCrud.deleteInspectVar)
expect(capturedHooksStore?.deleteNodeInspectorVars).toBe(mockInspectVarsCrud.deleteNodeInspectorVars)
expect(capturedHooksStore?.deleteAllInspectorVars).toBe(mockInspectVarsCrud.deleteAllInspectorVars)
expect(capturedHooksStore?.isInspectVarEdited).toBe(mockInspectVarsCrud.isInspectVarEdited)
expect(capturedHooksStore?.resetToLastRunVar).toBe(mockInspectVarsCrud.resetToLastRunVar)
expect(capturedHooksStore?.invalidateSysVarValues).toBe(mockInspectVarsCrud.invalidateSysVarValues)
expect(capturedHooksStore?.resetConversationVar).toBe(mockInspectVarsCrud.resetConversationVar)
expect(capturedHooksStore?.invalidateConversationVarValues).toBe(mockInspectVarsCrud.invalidateConversationVarValues)
})
})
describe('Block Selector', () => {
it('should filter unsupported snippet block types from available node metadata', () => {
renderSnippetMain()
const availableNodesMetaData = capturedHooksStore?.availableNodesMetaData as {
nodes: Array<{ metaData: { type: BlockEnum } }>
nodesMap: Partial<Record<BlockEnum, unknown>>
}
expect(availableNodesMetaData.nodes.map(node => node.metaData.type)).toEqual([BlockEnum.LLM])
expect(availableNodesMetaData.nodesMap[BlockEnum.LLM]).toBeDefined()
expect(availableNodesMetaData.nodesMap[BlockEnum.HumanInput]).toBeUndefined()
expect(availableNodesMetaData.nodesMap[BlockEnum.End]).toBeUndefined()
expect(availableNodesMetaData.nodesMap[BlockEnum.KnowledgeRetrieval]).toBeUndefined()
})
})
describe('Run Hooks', () => {
it('should pass snippet run handlers to WorkflowWithInnerContext', () => {
renderSnippetMain()
expect(capturedHooksStore?.handleBackupDraft).toBe(mockHandleBackupDraft)
expect(capturedHooksStore?.handleLoadBackupDraft).toBe(mockHandleLoadBackupDraft)
expect(capturedHooksStore?.handleRestoreFromPublishedWorkflow).toBe(mockHandleRestoreFromPublishedWorkflow)
expect(capturedHooksStore?.handleRun).toBe(mockHandleRun)
expect(capturedHooksStore?.handleStopRun).toBe(mockHandleStopRun)
expect(capturedHooksStore?.handleStartWorkflowRun).toBe(mockHandleStartWorkflowRun)
expect(capturedHooksStore?.handleWorkflowStartRunInWorkflow).toBe(mockHandleWorkflowStartRunInWorkflow)
})
it('should pass snippet workflow run detail urls to WorkflowWithInnerContext', () => {
renderSnippetMain()
const getWorkflowRunAndTraceUrl = capturedHooksStore?.getWorkflowRunAndTraceUrl as ((runId?: string) => { runUrl: string, traceUrl: string }) | undefined
expect(getWorkflowRunAndTraceUrl?.('run-1')).toEqual({
runUrl: '/snippets/snippet-1/workflow-runs/run-1',
traceUrl: '/snippets/snippet-1/workflow-runs/run-1/node-executions',
})
})
})
})

View File

@ -1,43 +0,0 @@
import type { PanelProps } from '@/app/components/workflow/panel'
import type { SnippetInputField } from '@/models/snippet'
import { render, waitFor } from '@testing-library/react'
import SnippetWorkflowPanel from '../workflow-panel'
let capturedPanelProps: PanelProps | null = null
vi.mock('@/app/components/workflow/panel', () => ({
default: (props: PanelProps) => {
capturedPanelProps = props
return <div data-testid="workflow-panel" />
},
}))
const defaultFields: SnippetInputField[] = []
describe('SnippetWorkflowPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
capturedPanelProps = null
})
// Verifies snippet panel wires version history support into the shared workflow panel.
describe('Rendering', () => {
it('should pass snippet version history panel props to the shared workflow panel', async () => {
render(
<SnippetWorkflowPanel
snippetId="snippet-1"
fields={defaultFields}
/>,
)
await waitFor(() => {
expect(capturedPanelProps?.versionHistoryPanelProps?.getVersionListUrl).toBe('/snippets/snippet-1/workflows')
expect(capturedPanelProps?.versionHistoryPanelProps?.deleteVersionUrl?.('version-1')).toBe('/snippets/snippet-1/workflows/version-1')
expect(capturedPanelProps?.versionHistoryPanelProps?.restoreVersionUrl('version-1')).toBe('/snippets/snippet-1/workflows/version-1/restore')
expect(capturedPanelProps?.versionHistoryPanelProps?.updateVersionUrl?.('version-1')).toBe('/snippets/snippet-1/workflows/version-1')
expect(capturedPanelProps?.versionHistoryPanelProps?.latestVersionId).toBe('')
expect(capturedPanelProps?.components?.right).toBeTruthy()
})
})
})
})

View File

@ -1,70 +0,0 @@
import type { SnippetInputField } from '@/models/snippet'
import { act, renderHook } from '@testing-library/react'
import { PipelineInputVarType } from '@/models/pipeline'
import { useSnippetInputFieldActions } from '../use-snippet-input-field-actions'
const mockSyncInputFieldsDraft = vi.fn()
const mockSetFields = vi.fn()
let snippetDetailStoreState: {
fields: SnippetInputField[]
setFields: typeof mockSetFields
}
vi.mock('../../../hooks/use-nodes-sync-draft', () => ({
useNodesSyncDraft: () => ({
syncInputFieldsDraft: mockSyncInputFieldsDraft,
}),
}))
vi.mock('../../../store', () => ({
useSnippetDetailStore: (selector: (state: typeof snippetDetailStoreState) => unknown) => selector(snippetDetailStoreState),
}))
const createField = (overrides: Partial<SnippetInputField> = {}): SnippetInputField => ({
type: PipelineInputVarType.textInput,
label: 'Blog URL',
variable: 'blog_url',
required: true,
...overrides,
})
describe('useSnippetInputFieldActions', () => {
beforeEach(() => {
vi.clearAllMocks()
snippetDetailStoreState = {
fields: [],
setFields: mockSetFields,
}
mockSetFields.mockImplementation((fields: SnippetInputField[]) => {
snippetDetailStoreState.fields = fields
})
mockSyncInputFieldsDraft.mockResolvedValue(undefined)
})
describe('Field sync', () => {
it('should update fields and sync the draft', () => {
snippetDetailStoreState.fields = [createField()]
const { result } = renderHook(() => useSnippetInputFieldActions({
snippetId: 'snippet-1',
}))
const nextFields = [
createField(),
createField({
label: 'Topic',
variable: 'topic',
}),
]
act(() => {
result.current.handleFieldsChange(nextFields)
})
expect(result.current.fields).toEqual([createField()])
expect(mockSetFields).toHaveBeenCalledWith(nextFields)
expect(mockSyncInputFieldsDraft).toHaveBeenCalledWith(nextFields, {
onRefresh: expect.any(Function),
})
})
})
})

View File

@ -1,119 +0,0 @@
import { toast } from '@langgenius/dify-ui/toast'
import { act, renderHook } from '@testing-library/react'
import { useSnippetPublish } from '../use-snippet-publish'
const mockMutateAsync = vi.fn()
const mockSetPublishedAt = vi.fn()
const mockSetQueryData = vi.fn()
const mockHandleCheckBeforePublish = vi.fn<() => Promise<boolean>>()
let isPending = false
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
error: vi.fn(),
success: vi.fn(),
},
}))
vi.mock('@tanstack/react-query', () => ({
useQueryClient: () => ({
setQueryData: mockSetQueryData,
}),
}))
vi.mock('@/service/use-snippet-workflows', () => ({
usePublishSnippetWorkflowMutation: () => ({
mutateAsync: mockMutateAsync,
isPending,
}),
}))
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: () => ({
setPublishedAt: mockSetPublishedAt,
}),
}),
}))
vi.mock('@/app/components/workflow/hooks/use-checklist', () => ({
useChecklistBeforePublish: () => ({
handleCheckBeforePublish: mockHandleCheckBeforePublish,
}),
}))
describe('useSnippetPublish', () => {
beforeEach(() => {
vi.clearAllMocks()
isPending = false
mockHandleCheckBeforePublish.mockResolvedValue(true)
mockMutateAsync.mockResolvedValue({ created_at: 1_712_345_678 })
})
describe('Publish action', () => {
it('should publish the snippet and show success feedback', async () => {
const { result } = renderHook(() => useSnippetPublish({
snippetId: 'snippet-1',
}))
await act(async () => {
await result.current.handlePublish()
})
expect(mockHandleCheckBeforePublish).toHaveBeenCalledTimes(1)
expect(mockMutateAsync).toHaveBeenCalledWith({
params: { snippetId: 'snippet-1' },
})
expect(mockSetQueryData).toHaveBeenCalledTimes(1)
const setQueryDataCall = mockSetQueryData.mock.calls[0]
expect(setQueryDataCall).toBeDefined()
const updateSnippetDetail = setQueryDataCall![1] as (old: { is_published: boolean }) => { is_published: boolean }
expect(updateSnippetDetail({ is_published: false })).toEqual({ is_published: true })
expect(mockSetPublishedAt).toHaveBeenCalledWith(1_712_345_678)
expect(toast.success).toHaveBeenCalledWith('snippet.saveSuccess')
})
it('should not publish the snippet when checklist validation fails', async () => {
mockHandleCheckBeforePublish.mockResolvedValue(false)
const { result } = renderHook(() => useSnippetPublish({
snippetId: 'snippet-1',
}))
await act(async () => {
await result.current.handlePublish()
})
expect(mockHandleCheckBeforePublish).toHaveBeenCalledTimes(1)
expect(mockMutateAsync).not.toHaveBeenCalled()
expect(mockSetQueryData).not.toHaveBeenCalled()
expect(mockSetPublishedAt).not.toHaveBeenCalled()
expect(toast.success).not.toHaveBeenCalled()
})
it('should surface publish errors through toast feedback', async () => {
mockMutateAsync.mockRejectedValue(new Error('publish failed'))
const { result } = renderHook(() => useSnippetPublish({
snippetId: 'snippet-1',
}))
await act(async () => {
await result.current.handlePublish()
})
expect(toast.error).toHaveBeenCalledWith('publish failed')
})
})
it('should expose publishing pending state', () => {
isPending = true
const { result } = renderHook(() => useSnippetPublish({
snippetId: 'snippet-1',
}))
expect(result.current.isPublishing).toBe(true)
})
})

View File

@ -1,34 +0,0 @@
import type { SnippetInputField } from '@/models/snippet'
import { useCallback } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useNodesSyncDraft } from '../../hooks/use-nodes-sync-draft'
import { useSnippetDetailStore } from '../../store'
type UseSnippetInputFieldActionsOptions = {
snippetId: string
}
export const useSnippetInputFieldActions = ({
snippetId,
}: UseSnippetInputFieldActionsOptions) => {
const { syncInputFieldsDraft } = useNodesSyncDraft(snippetId)
const {
fields,
setFields,
} = useSnippetDetailStore(useShallow(state => ({
fields: state.fields,
setFields: state.setFields,
})))
const handleFieldsChange = useCallback((newFields: SnippetInputField[]) => {
setFields(newFields)
void syncInputFieldsDraft(newFields, {
onRefresh: setFields,
})
}, [setFields, syncInputFieldsDraft])
return {
fields,
handleFieldsChange,
}
}

View File

@ -1,55 +0,0 @@
import type { Snippet as SnippetContract } from '@/types/snippet'
import { toast } from '@langgenius/dify-ui/toast'
import { useQueryClient } from '@tanstack/react-query'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useChecklistBeforePublish } from '@/app/components/workflow/hooks/use-checklist'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { consoleQuery } from '@/service/client'
import { usePublishSnippetWorkflowMutation } from '@/service/use-snippet-workflows'
type UseSnippetPublishOptions = {
snippetId: string
}
export const useSnippetPublish = ({
snippetId,
}: UseSnippetPublishOptions) => {
const { t } = useTranslation('snippet')
const workflowStore = useWorkflowStore()
const queryClient = useQueryClient()
const publishSnippetMutation = usePublishSnippetWorkflowMutation(snippetId)
const { handleCheckBeforePublish } = useChecklistBeforePublish()
const handlePublish = useCallback(async () => {
try {
const canPublish = await handleCheckBeforePublish()
if (!canPublish)
return
const publishedWorkflow = await publishSnippetMutation.mutateAsync({
params: { snippetId },
})
queryClient.setQueryData<SnippetContract | undefined>(
consoleQuery.snippets.detail.queryKey({
input: {
params: { snippetId },
},
}),
old => old ? { ...old, is_published: true } : old,
)
workflowStore.getState().setPublishedAt(publishedWorkflow.created_at)
toast.success(t('saveSuccess'))
return true
}
catch (error) {
toast.error(error instanceof Error ? error.message : t('publishFailed'))
return false
}
}, [handleCheckBeforePublish, publishSnippetMutation, queryClient, snippetId, t, workflowStore])
return {
handlePublish,
isPublishing: publishSnippetMutation.isPending,
}
}

View File

@ -1,59 +0,0 @@
'use client'
import type { SnippetInputField } from '@/models/snippet'
import SnippetHeader from './snippet-header'
import SnippetWorkflowPanel from './workflow-panel'
type SnippetChildrenProps = {
snippetId: string
fields: SnippetInputField[]
hasDraftChanges: boolean
isEditing: boolean
isPublishing: boolean
onCancel: () => void
onDiscardAndExitEditing: () => void | Promise<void>
onEdit: () => void
onExitEditing: () => void | Promise<void>
onPublish: () => void
onSaveAndExitEditing: () => void | Promise<void>
}
const SnippetChildren = ({
snippetId,
fields,
hasDraftChanges,
isEditing,
isPublishing,
onCancel,
onDiscardAndExitEditing,
onEdit,
onExitEditing,
onPublish,
onSaveAndExitEditing,
}: SnippetChildrenProps) => {
return (
<>
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-24 bg-linear-to-b from-background-body to-transparent" />
<SnippetHeader
snippetId={snippetId}
hasDraftChanges={hasDraftChanges}
isEditing={isEditing}
isPublishing={isPublishing}
onCancel={onCancel}
onDiscardAndExitEditing={onDiscardAndExitEditing}
onEdit={onEdit}
onExitEditing={onExitEditing}
onPublish={onPublish}
onSaveAndExitEditing={onSaveAndExitEditing}
/>
<SnippetWorkflowPanel
snippetId={snippetId}
fields={fields}
/>
</>
)
}
export default SnippetChildren

View File

@ -1,102 +0,0 @@
import type { ReactNode } from 'react'
import type { HeaderProps } from '@/app/components/workflow/header'
import { fireEvent, render, screen } from '@testing-library/react'
import SnippetHeader from '..'
vi.mock('@langgenius/dify-ui/alert-dialog', () => ({
AlertDialog: ({ children }: { children: ReactNode }) => <div>{children}</div>,
AlertDialogActions: ({ children }: { children: ReactNode }) => <div>{children}</div>,
AlertDialogCancelButton: ({ children }: { children: ReactNode }) => <button type="button">{children}</button>,
AlertDialogConfirmButton: ({ children, onClick }: { children: ReactNode, onClick?: () => void }) => <button type="button" onClick={onClick}>{children}</button>,
AlertDialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
AlertDialogDescription: ({ children }: { children: ReactNode }) => <div>{children}</div>,
AlertDialogTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>,
AlertDialogTrigger: ({ children, render }: { children?: ReactNode, render?: ReactNode }) => render ?? <button type="button">{children}</button>,
}))
vi.mock('@/app/components/workflow/header', () => ({
default: (props: HeaderProps) => {
return (
<div
data-testid="workflow-header"
data-show-env={String(props.normal?.controls?.showEnvButton ?? true)}
data-show-global-variable={String(props.normal?.controls?.showGlobalVariableButton ?? true)}
data-history-url={props.normal?.runAndHistoryProps?.viewHistoryProps?.historyUrl ?? ''}
>
{props.normal?.components?.title}
{props.normal?.components?.left}
<button type="button">
{props.normal?.runAndHistoryProps?.runButtonText ?? 'snippet.testRunButton'}
</button>
{props.normal?.components?.middle}
</div>
)
},
}))
describe('SnippetHeader', () => {
const mockCancel = vi.fn()
const mockDiscardAndExit = vi.fn()
const mockEdit = vi.fn()
const mockExitEditing = vi.fn()
const mockPublish = vi.fn()
const mockSaveAndExit = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
// Verifies the wrapper passes the expected workflow header configuration.
describe('Rendering', () => {
it('should configure workflow header slots and hide workflow-only controls', () => {
render(
<SnippetHeader
snippetId="snippet-1"
hasDraftChanges={false}
isEditing={false}
isPublishing={false}
onCancel={mockCancel}
onDiscardAndExitEditing={mockDiscardAndExit}
onEdit={mockEdit}
onExitEditing={mockExitEditing}
onPublish={mockPublish}
onSaveAndExitEditing={mockSaveAndExit}
/>,
)
const header = screen.getByTestId('workflow-header')
expect(header).toHaveAttribute('data-show-env', 'false')
expect(header).toHaveAttribute('data-show-global-variable', 'false')
expect(header).toHaveAttribute('data-history-url', '/snippets/snippet-1/workflow-runs')
expect(screen.getByText('snippet.viewOnly')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /snippet\.edit/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /snippet\.testRunButton/i })).toBeInTheDocument()
})
})
// Verifies forwarded callbacks still drive the snippet-specific controls.
describe('User Interactions', () => {
it('should invoke the snippet callbacks when save and discard are clicked in editing mode', () => {
render(
<SnippetHeader
snippetId="snippet-1"
hasDraftChanges
isEditing
isPublishing={false}
onCancel={mockCancel}
onDiscardAndExitEditing={mockDiscardAndExit}
onEdit={mockEdit}
onExitEditing={mockExitEditing}
onPublish={mockPublish}
onSaveAndExitEditing={mockSaveAndExit}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /^snippet\.save$/i }))
fireEvent.click(screen.getByRole('button', { name: /snippet\.discardChanges/i }))
expect(mockPublish).toHaveBeenCalledTimes(1)
expect(mockCancel).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -1,75 +0,0 @@
'use client'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
AlertDialogTrigger,
} from '@langgenius/dify-ui/alert-dialog'
import { memo, useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
type CancelChangesProps = {
onCancel: () => void | Promise<void>
}
const CancelChanges = ({
onCancel,
}: CancelChangesProps) => {
const { t } = useTranslation('snippet')
const [open, setOpen] = useState(false)
const [isDiscarding, setIsDiscarding] = useState(false)
const handleDiscardChanges = useCallback(async () => {
setIsDiscarding(true)
try {
await onCancel()
setOpen(false)
}
finally {
setIsDiscarding(false)
}
}, [onCancel])
return (
<div className="flex items-center gap-2 system-sm-regular">
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger
className="system-sm-semibold text-text-accent hover:text-text-accent-secondary"
>
{t('discardDraft')}
</AlertDialogTrigger>
<AlertDialogContent className="w-160">
<div className="space-y-2 p-8 pb-12">
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
{t('discardChangesTitle')}
</AlertDialogTitle>
<AlertDialogDescription className="system-md-regular text-text-secondary">
{t('discardChangesDescription')}
</AlertDialogDescription>
</div>
<AlertDialogActions className="px-8 pt-0">
<AlertDialogCancelButton disabled={isDiscarding}>
{t('continueEditing')}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
loading={isDiscarding}
disabled={isDiscarding}
onClick={handleDiscardChanges}
>
{t('discardChanges')}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
<span className="text-text-quaternary">·</span>
<span className="text-text-tertiary">{t('editingDraft')}</span>
</div>
)
}
export default memo(CancelChanges)

View File

@ -1,200 +0,0 @@
'use client'
import type { HeaderProps } from '@/app/components/workflow/header'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
AlertDialogTrigger,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import {
memo,
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import Header from '@/app/components/workflow/header'
import CancelChanges from './cancel-changes'
import RunMode from './run-mode'
type SnippetHeaderProps = {
snippetId: string
hasDraftChanges: boolean
isEditing: boolean
isPublishing: boolean
onCancel: () => void
onDiscardAndExitEditing: () => void | Promise<void>
onEdit: () => void
onExitEditing: () => void | Promise<void>
onPublish: () => void
onSaveAndExitEditing: () => void | Promise<void>
}
const ViewOnlyBadge = () => {
const { t } = useTranslation('snippet')
return (
<div className="rounded-md border border-components-badge-status-light-normal-border-inner bg-components-badge-bg-blue-light-soft px-1.5 py-0.5 system-xs-semibold-uppercase text-text-accent">
{t('viewOnly')}
</div>
)
}
const EditActions = ({
hasDraftChanges,
isEditing,
isPublishing,
onEdit,
onExitEditing,
onDiscardAndExitEditing,
onPublish,
onSaveAndExitEditing,
}: Pick<SnippetHeaderProps, 'hasDraftChanges' | 'isEditing' | 'isPublishing' | 'onDiscardAndExitEditing' | 'onEdit' | 'onExitEditing' | 'onPublish' | 'onSaveAndExitEditing'>) => {
const { t } = useTranslation('snippet')
const [exitConfirmOpen, setExitConfirmOpen] = useState(false)
if (!isEditing) {
return (
<Button variant="primary" onClick={onEdit}>
{t('edit')}
</Button>
)
}
return (
<>
<AlertDialog open={exitConfirmOpen} onOpenChange={setExitConfirmOpen}>
<AlertDialogTrigger
render={(
<Button
disabled={isPublishing}
onClick={(event) => {
if (!hasDraftChanges) {
event.preventDefault()
void onExitEditing()
return
}
setExitConfirmOpen(true)
}}
>
{t('exitEditing')}
</Button>
)}
/>
<AlertDialogContent className="w-165">
<div className="space-y-2 p-8 pb-12">
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
{t('saveBeforeLeavingTitle')}
</AlertDialogTitle>
<AlertDialogDescription className="system-md-regular text-text-secondary">
{t('saveBeforeLeavingDescription')}
</AlertDialogDescription>
</div>
<AlertDialogActions className="px-8 pt-0">
<AlertDialogCancelButton disabled={isPublishing}>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
tone="destructive"
disabled={isPublishing}
onClick={async () => {
await onDiscardAndExitEditing()
setExitConfirmOpen(false)
}}
>
{t('doNotSave')}
</AlertDialogConfirmButton>
<AlertDialogConfirmButton
tone="default"
loading={isPublishing}
disabled={isPublishing}
onClick={async () => {
await onSaveAndExitEditing()
setExitConfirmOpen(false)
}}
>
{t('saveAndExit')}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
<Button
variant="primary"
loading={isPublishing}
disabled={isPublishing}
onClick={onPublish}
>
{t('save')}
</Button>
</>
)
}
const SnippetHeader = ({
snippetId,
hasDraftChanges,
isEditing,
isPublishing,
onCancel,
onDiscardAndExitEditing,
onEdit,
onExitEditing,
onPublish,
onSaveAndExitEditing,
}: SnippetHeaderProps) => {
const { t } = useTranslation('snippet')
const viewHistoryProps = useMemo(() => {
return {
historyUrl: `/snippets/${snippetId}/workflow-runs`,
}
}, [snippetId])
const headerProps: HeaderProps = useMemo(() => {
return {
normal: {
components: {
title: isEditing
? (hasDraftChanges ? <CancelChanges onCancel={onCancel} /> : <></>)
: <ViewOnlyBadge />,
left: (
<EditActions
hasDraftChanges={hasDraftChanges}
isEditing={isEditing}
isPublishing={isPublishing}
onDiscardAndExitEditing={onDiscardAndExitEditing}
onEdit={onEdit}
onExitEditing={onExitEditing}
onPublish={onPublish}
onSaveAndExitEditing={onSaveAndExitEditing}
/>
),
},
controls: {
showEnvButton: false,
showGlobalVariableButton: false,
},
runAndHistoryProps: {
showRunButton: true,
runButtonText: t('testRunButton'),
viewHistoryProps,
components: {
RunMode,
},
},
},
viewHistory: {
viewHistoryProps,
},
}
}, [hasDraftChanges, isEditing, isPublishing, onCancel, onDiscardAndExitEditing, onEdit, onExitEditing, onPublish, onSaveAndExitEditing, t, viewHistoryProps])
return <Header {...headerProps} />
}
export default memo(SnippetHeader)

View File

@ -1,30 +0,0 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
type PublisherProps = {
isPublishing: boolean
onPublish: () => void
}
const Publisher = ({
isPublishing,
onPublish,
}: PublisherProps) => {
const { t } = useTranslation('snippet')
return (
<Button
variant="primary"
loading={isPublishing}
disabled={isPublishing}
onClick={onPublish}
>
{t('save')}
</Button>
)
}
export default memo(Publisher)

View File

@ -1,78 +0,0 @@
'use client'
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useWorkflowRun, useWorkflowStartRun } from '@/app/components/workflow/hooks'
import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd'
import { useStore } from '@/app/components/workflow/store'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types'
import { useEventEmitterContextContext } from '@/context/event-emitter'
type RunModeProps = {
text?: string
}
const RunMode = ({
text,
}: RunModeProps) => {
const { t } = useTranslation('snippet')
const { handleWorkflowStartRunInWorkflow } = useWorkflowStartRun()
const { handleStopRun } = useWorkflowRun()
const workflowRunningData = useStore(s => s.workflowRunningData)
const isRunning = workflowRunningData?.result.status === WorkflowRunningStatus.Running
const handleStop = useCallback(() => {
handleStopRun(workflowRunningData?.task_id || '')
}, [handleStopRun, workflowRunningData?.task_id])
const { eventEmitter } = useEventEmitterContextContext()
eventEmitter?.useSubscription((v) => {
if (typeof v !== 'string' && v.type === EVENT_WORKFLOW_STOP)
handleStop()
})
return (
<div className="flex items-center gap-x-px">
<button
type="button"
className={cn(
'flex h-7 items-center gap-x-1 rounded-md px-1.5 system-xs-medium text-components-button-secondary-accent-text hover:bg-state-accent-hover',
isRunning && 'cursor-not-allowed rounded-l-md bg-state-accent-hover',
)}
onClick={handleWorkflowStartRunInWorkflow}
disabled={isRunning}
>
{isRunning
? (
<>
<span aria-hidden className="mr-1 i-ri-loader-2-line size-4 animate-spin" />
{t('common.running', { ns: 'workflow' })}
</>
)
: (
<>
<span aria-hidden className="mr-1 i-ri-play-large-line size-4" />
{text ?? t('common.run', { ns: 'workflow' })}
<ShortcutKbd shortcut="workflow.open-test-run-menu" textColor="secondary" />
</>
)}
</button>
{isRunning && (
<button
type="button"
className="flex size-7 items-center justify-center rounded-r-md bg-state-accent-active"
onClick={handleStop}
>
<span aria-hidden className="i-ri-stop-circle-line size-4 text-text-accent" />
</button>
)}
</div>
)
}
export default React.memo(RunMode)

Some files were not shown because too many files have changed in this diff Show More