mirror of
https://github.com/langgenius/dify.git
synced 2026-05-29 05:07:55 +08:00
Compare commits
290 Commits
fix/device
...
feat/snipp
| Author | SHA1 | Date | |
|---|---|---|---|
| c46a313d78 | |||
| e1fec86a2a | |||
| 6ec893cb0e | |||
| 8be34ee000 | |||
| 458f669883 | |||
| 94fd4e9c67 | |||
| f5da3ce499 | |||
| 08f2971f72 | |||
| 54ac42fbc4 | |||
| 1d1d571213 | |||
| 056caa8b2f | |||
| 1cf6cdb764 | |||
| ac9083fbf1 | |||
| fdfc9ab3d3 | |||
| 83cd1a8d7a | |||
| a3dfd670b0 | |||
| facace019b | |||
| fd9543868d | |||
| 89188256e1 | |||
| bba3a1bcee | |||
| 7c0be7f905 | |||
| 599e3475f2 | |||
| 718fe548e9 | |||
| 060ceaffd1 | |||
| 00908ca0fb | |||
| 2812d61e24 | |||
| be1d6520f9 | |||
| 7fb2e4751f | |||
| 5441992604 | |||
| 9d0597c22d | |||
| 5d489ab92d | |||
| 930da499d1 | |||
| f1527ef7c1 | |||
| 20f89b6e90 | |||
| 05e69b104a | |||
| f39b1b6731 | |||
| a7005efab3 | |||
| f605288429 | |||
| 2bb3b439e0 | |||
| 75daf8e61b | |||
| bf30b11d0d | |||
| 778e472173 | |||
| 2885ba8519 | |||
| e23c3d1491 | |||
| 888292564b | |||
| 124b786dfb | |||
| dd54ca0cab | |||
| 8a72e46ce8 | |||
| f00f8e020f | |||
| aa078a854c | |||
| 712aae4d98 | |||
| bacadc4d35 | |||
| b060e81824 | |||
| b45f83492e | |||
| d1e1a4a8ab | |||
| 4519847e81 | |||
| 3763efbc7c | |||
| 552f202ca8 | |||
| dc76f4082f | |||
| 6d01095586 | |||
| b914e48a41 | |||
| da482ec455 | |||
| 48c38ace54 | |||
| 2b1496c857 | |||
| c15e437ff7 | |||
| 0ac0eccce4 | |||
| 678327e994 | |||
| b0478f4df7 | |||
| 00319f0e43 | |||
| 55eb894d8e | |||
| c59a80a41f | |||
| 24b482893d | |||
| ad58895b25 | |||
| 25fc518c5d | |||
| d92722e7ab | |||
| 4041fd7e5c | |||
| 06ea73a19b | |||
| 7384a3c121 | |||
| c18c953a7c | |||
| ae2df0c35e | |||
| dacc7fc740 | |||
| 9af2c1252c | |||
| 35bfe26a3a | |||
| 8686362aeb | |||
| f5955489ec | |||
| aaa15770d5 | |||
| 08c01c4f3f | |||
| 0903c30060 | |||
| b420298398 | |||
| 2607eb8d32 | |||
| d8173b1cda | |||
| c56f1a8216 | |||
| 31e74371ef | |||
| e48f13f173 | |||
| c574363cf6 | |||
| 70fd4a5c88 | |||
| 42889d23e5 | |||
| 3a7f09a250 | |||
| d95d4335bf | |||
| 735e88f673 | |||
| c55105bff3 | |||
| 77afc805e1 | |||
| 9dd73b4d47 | |||
| f2b12bfef7 | |||
| dbeaf79d77 | |||
| 63dcb4dd6c | |||
| 9df3a7bcf9 | |||
| 89163edd16 | |||
| eaa55aab1e | |||
| 8d3a690c0a | |||
| 5263a65ed6 | |||
| 24d3e8edba | |||
| b371dd2cdf | |||
| 597ad8c425 | |||
| 33f9d96caa | |||
| 689571df22 | |||
| a3242f0634 | |||
| f5112928b3 | |||
| bcd87ddc58 | |||
| 7c8a87af05 | |||
| 8e2d507e5c | |||
| b6fbec066d | |||
| bd136cadce | |||
| 0a934e1143 | |||
| c44ba62da3 | |||
| 76c0aed05c | |||
| e7fc22c6b3 | |||
| b91727b804 | |||
| 534fd79377 | |||
| 3ea4742b29 | |||
| 364c0eb6e2 | |||
| 322b3ff641 | |||
| 38736c154b | |||
| 129f681c59 | |||
| d776fc0827 | |||
| 7af6074cb5 | |||
| 7aa700bf2b | |||
| 0d47750b15 | |||
| a9dc57eeef | |||
| 5bfebd371d | |||
| f1da2c76d1 | |||
| b5dc774093 | |||
| b7fe45d800 | |||
| 7f5bbe0ee3 | |||
| 40632589a2 | |||
| e6e063138e | |||
| 605af8d60e | |||
| 8747e3a2d3 | |||
| 1712a2732a | |||
| 46bc76bae3 | |||
| 8c6dda125f | |||
| f6047aafe8 | |||
| dce5715982 | |||
| ea910b8e7d | |||
| e51af66d95 | |||
| f93b287949 | |||
| 627fbd2e86 | |||
| e4c056a57a | |||
| 23291398ec | |||
| 79fc352a5a | |||
| 8b6b3cddea | |||
| d1ca468c1e | |||
| ce28ad771c | |||
| ba951b01de | |||
| 670ab16ea1 | |||
| 4680535ecd | |||
| f96e63460e | |||
| 2df79c0404 | |||
| acef9630d5 | |||
| 12c3b2e0cd | |||
| 577707ae50 | |||
| 03325e9750 | |||
| a7ef8f9c12 | |||
| 40284d9f95 | |||
| 5efe8b8bd7 | |||
| 8dc6d736ee | |||
| 5316372772 | |||
| 4d1499ef75 | |||
| 0438285277 | |||
| 4879ea5cd5 | |||
| 2a1761ac06 | |||
| c29245c1cb | |||
| 5069694bba | |||
| d1a80a85c0 | |||
| 5c93d74dec | |||
| e52dbd49be | |||
| ccc8a5f278 | |||
| cfb5b9dfea | |||
| 73d95245f8 | |||
| fb91984fcb | |||
| 29cb1fa12e | |||
| 78240ed199 | |||
| 8f8707fd77 | |||
| ed3db06154 | |||
| 7c05a68876 | |||
| 6cfc0dd8e1 | |||
| 81baeae5c4 | |||
| a3010bdc0b | |||
| 8133e550ed | |||
| 2bb0eab636 | |||
| 5311b5d00d | |||
| 9b02ccdd12 | |||
| 231783eebe | |||
| 756606f478 | |||
| 6651c1c5da | |||
| 61e257b2a8 | |||
| 3ac4caf735 | |||
| 268ae1751d | |||
| 015cbf850b | |||
| 873e13c2fb | |||
| 688bf7e7a1 | |||
| a6ffff3b39 | |||
| 023fc55bd5 | |||
| 351b909a53 | |||
| 6bec4f65c9 | |||
| 74f87ce152 | |||
| 92c472ccc7 | |||
| b92b8becd1 | |||
| 23d0d6a65d | |||
| 1660067d6e | |||
| 0642475b85 | |||
| 8cb634c9bc | |||
| 768b41c3cf | |||
| ca88516d54 | |||
| 871a2a149f | |||
| 60e381eff0 | |||
| 768b3eb6f9 | |||
| 2f88da4a6d | |||
| a8cdf6964c | |||
| 985c3db4fd | |||
| 9636472db7 | |||
| 0ad268aa7d | |||
| a4ea33167d | |||
| 0f13aabea8 | |||
| 1e76ef5ccb | |||
| e6e3229d17 | |||
| dccf8e723a | |||
| c41ba7d627 | |||
| a6e9316de3 | |||
| 559d326cbd | |||
| abedf2506f | |||
| d01428b5bc | |||
| 0de1f17e5c | |||
| 17d07a5a43 | |||
| 3bdbea99a3 | |||
| b7683aedb1 | |||
| 515036e758 | |||
| 22b382527f | |||
| 2cfe4b5b86 | |||
| 6876c8041c | |||
| 7de45584ce | |||
| 5572d7c7e8 | |||
| db0a2fe52e | |||
| f0ae8d6167 | |||
| 2514e181ba | |||
| be2e6e9a14 | |||
| 875e2eac1b | |||
| c3c73ceb1f | |||
| 6318bf0a2a | |||
| 5e1f252046 | |||
| df3b960505 | |||
| 26bc108bf1 | |||
| a5cff32743 | |||
| d418dd8eec | |||
| 61702fe346 | |||
| 43f0c780c3 | |||
| 30ebf2bfa9 | |||
| 7e3027b5f7 | |||
| b3acf83090 | |||
| 36c3d6e48a | |||
| f782ac6b3c | |||
| feef2dd1fa | |||
| a716d8789d | |||
| 6816f89189 | |||
| bfcac64a9d | |||
| 664eb601a2 | |||
| 8e5cc4e0aa | |||
| 9f28575903 | |||
| 4b9a26a5e6 | |||
| 7b85adf1cc | |||
| c964708ebe | |||
| 883eb498c0 | |||
| 4d3738d225 | |||
| dd0dee739d | |||
| 4d19914fcb | |||
| 887c7710e9 | |||
| 7a722773c7 | |||
| a763aff58b | |||
| c1011f4e5c | |||
| f7afa103a5 |
@ -4,12 +4,6 @@ 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,
|
||||
@ -32,12 +26,7 @@ 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,
|
||||
@ -59,13 +48,10 @@ __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",
|
||||
@ -73,7 +59,6 @@ __all__ = [
|
||||
"migrate_data_for_plugin",
|
||||
"migrate_knowledge_vector_database",
|
||||
"migrate_oss",
|
||||
"migration_data_wizard",
|
||||
"old_metadata_migration",
|
||||
"remove_orphaned_files_on_storage",
|
||||
"reset_email",
|
||||
|
||||
@ -1,754 +0,0 @@
|
||||
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)
|
||||
@ -30,7 +30,7 @@ def vdb_migrate(scope: str):
|
||||
|
||||
def migrate_annotation_vector_database():
|
||||
"""
|
||||
Migrate annotation data to target vector database.
|
||||
Migrate annotation datas 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 data to target vector database.
|
||||
Migrate vector database datas to target vector database .
|
||||
"""
|
||||
click.echo(click.style("Starting vector database migration.", fg="green"))
|
||||
create_count = 0
|
||||
|
||||
@ -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, case, cast, func, literal, or_, select
|
||||
from sqlalchemy import String, cast, func, or_, select
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
|
||||
@ -169,17 +169,12 @@ 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
|
||||
# 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),
|
||||
)
|
||||
# Guard with jsonb_typeof to avoid "cannot extract elements from a scalar" error
|
||||
# when keywords is null or a non-array JSON value.
|
||||
keywords_condition = func.array_to_string(
|
||||
func.array(
|
||||
select(func.jsonb_array_elements_text(keywords_array))
|
||||
select(func.jsonb_array_elements_text(cast(DocumentSegment.keywords, JSONB)))
|
||||
.where(func.jsonb_typeof(cast(DocumentSegment.keywords, JSONB)) == "array")
|
||||
.correlate(DocumentSegment)
|
||||
.scalar_subquery()
|
||||
),
|
||||
|
||||
@ -863,7 +863,7 @@ class ToolManager:
|
||||
return controller
|
||||
|
||||
@classmethod
|
||||
def user_get_api_provider(cls, provider: str, tenant_id: str, mask: bool = True):
|
||||
def user_get_api_provider(cls, provider: str, tenant_id: str):
|
||||
"""
|
||||
get api provider
|
||||
"""
|
||||
@ -902,10 +902,8 @@ class ToolManager:
|
||||
tenant_id=tenant_id,
|
||||
controller=controller,
|
||||
)
|
||||
if mask:
|
||||
masked_credentials = encrypter.mask_plugin_credentials(encrypter.decrypt(credentials))
|
||||
else:
|
||||
masked_credentials = encrypter.decrypt(credentials)
|
||||
|
||||
masked_credentials = encrypter.mask_plugin_credentials(encrypter.decrypt(credentials))
|
||||
|
||||
try:
|
||||
icon = emoji_icon_adapter.validate_json(provider_obj.icon)
|
||||
|
||||
@ -6,7 +6,7 @@ from json.decoder import JSONDecodeError
|
||||
from typing import Any, TypedDict
|
||||
|
||||
import httpx
|
||||
from flask import has_request_context, request
|
||||
from flask import 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") if has_request_context() else None
|
||||
request_env = request.headers.get("X-Request-Env")
|
||||
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
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
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
|
||||
@ -39,14 +38,6 @@ 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(
|
||||
|
||||
@ -15,18 +15,14 @@ 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,
|
||||
@ -74,10 +70,6 @@ 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)
|
||||
|
||||
@ -97,7 +97,6 @@ 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())
|
||||
@ -263,7 +262,6 @@ 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)
|
||||
@ -387,7 +385,6 @@ 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", {})
|
||||
@ -420,7 +417,7 @@ class AppDslService:
|
||||
|
||||
# Create new app
|
||||
app = App()
|
||||
app.id = import_app_id or str(uuid4())
|
||||
app.id = str(uuid4())
|
||||
app.tenant_id = account.current_tenant_id
|
||||
app.mode = app_mode
|
||||
app.name = name or app_data.get("name", "")
|
||||
|
||||
@ -1,17 +0,0 @@
|
||||
from services.data_migration.entities import (
|
||||
ConflictStrategy,
|
||||
ExportSelection,
|
||||
IdStrategy,
|
||||
ImportOptions,
|
||||
MigrationDataError,
|
||||
MigrationPackage,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ConflictStrategy",
|
||||
"ExportSelection",
|
||||
"IdStrategy",
|
||||
"ImportOptions",
|
||||
"MigrationDataError",
|
||||
"MigrationPackage",
|
||||
]
|
||||
@ -1,92 +0,0 @@
|
||||
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
|
||||
@ -1,241 +0,0 @@
|
||||
"""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)
|
||||
@ -1,492 +0,0 @@
|
||||
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}"
|
||||
@ -1,938 +0,0 @@
|
||||
"""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
|
||||
@ -1,71 +0,0 @@
|
||||
"""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)
|
||||
@ -1,76 +0,0 @@
|
||||
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 "))
|
||||
@ -41,7 +41,6 @@ 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
|
||||
@ -93,8 +92,7 @@ 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:
|
||||
|
||||
@ -1,71 +0,0 @@
|
||||
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
|
||||
@ -1,384 +0,0 @@
|
||||
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
|
||||
@ -1036,48 +1036,6 @@ 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()
|
||||
|
||||
@ -1,42 +0,0 @@
|
||||
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
|
||||
@ -1,115 +0,0 @@
|
||||
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"),
|
||||
]
|
||||
@ -1,71 +0,0 @@
|
||||
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"]})
|
||||
@ -1,201 +0,0 @@
|
||||
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",
|
||||
}
|
||||
@ -1,973 +0,0 @@
|
||||
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"),
|
||||
]
|
||||
@ -1,53 +0,0 @@
|
||||
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)
|
||||
@ -1,111 +0,0 @@
|
||||
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
|
||||
@ -1,286 +0,0 @@
|
||||
# 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.
|
||||
@ -147,6 +147,11 @@
|
||||
"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
|
||||
@ -243,6 +248,11 @@
|
||||
"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
|
||||
@ -3162,6 +3172,16 @@
|
||||
"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
|
||||
@ -3332,6 +3352,11 @@
|
||||
"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
|
||||
@ -5215,6 +5240,11 @@
|
||||
"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
|
||||
@ -5480,6 +5510,11 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/service/use-snippet-workflows.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/service/use-tools.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 751 B |
@ -0,0 +1,5 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 763 B |
@ -0,0 +1,3 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 526 B |
@ -0,0 +1,3 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 563 B |
@ -513,12 +513,27 @@
|
||||
"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>"
|
||||
},
|
||||
@ -1025,6 +1040,11 @@
|
||||
"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>"
|
||||
},
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"prefix": "custom-vender",
|
||||
"name": "Dify Custom Vender",
|
||||
"total": 277,
|
||||
"total": 281,
|
||||
"version": "0.0.0-private",
|
||||
"author": {
|
||||
"name": "LangGenius, Inc.",
|
||||
|
||||
@ -340,16 +340,11 @@ describe('App List Browsing Flow', () => {
|
||||
|
||||
// -- Tab navigation --
|
||||
describe('Tab Navigation', () => {
|
||||
it('should render all category tabs', () => {
|
||||
it('should render the app type dropdown trigger', () => {
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
renderList()
|
||||
|
||||
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()
|
||||
expect(screen.getByText('app.studio.filters.types')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -380,21 +375,19 @@ describe('App List Browsing Flow', () => {
|
||||
|
||||
// -- "Created by me" filter --
|
||||
describe('Created By Me Filter', () => {
|
||||
it('should render the "created by me" checkbox', () => {
|
||||
it('should not render a standalone "created by me" checkbox in the current header layout', () => {
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
|
||||
expect(screen.queryByText('app.showMyCreatedAppsOnly')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should toggle the "created by me" filter on click', () => {
|
||||
it('should keep the current layout stable without a "created by me" control', () => {
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
renderList()
|
||||
|
||||
const checkbox = screen.getByText('app.showMyCreatedAppsOnly')
|
||||
fireEvent.click(checkbox)
|
||||
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.studio.filters.types')).toBeInTheDocument()
|
||||
expect(screen.queryByText('app.showMyCreatedAppsOnly')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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', '/explore', '/tools'] as const
|
||||
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/snippets', '/explore', '/tools'] as const
|
||||
|
||||
const isPathUnderRoute = (pathname: string, route: string) => pathname === route || pathname.startsWith(`${route}/`)
|
||||
|
||||
|
||||
@ -0,0 +1,11 @@
|
||||
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
|
||||
21
web/app/(commonLayout)/snippets/[snippetId]/page.spec.ts
Normal file
21
web/app/(commonLayout)/snippets/[snippetId]/page.spec.ts
Normal file
@ -0,0 +1,21 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
11
web/app/(commonLayout)/snippets/[snippetId]/page.tsx
Normal file
11
web/app/(commonLayout)/snippets/[snippetId]/page.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
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
|
||||
7
web/app/(commonLayout)/snippets/page.tsx
Normal file
7
web/app/(commonLayout)/snippets/page.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import SnippetList from '@/app/components/snippet-list'
|
||||
|
||||
const SnippetsPage = () => {
|
||||
return <SnippetList />
|
||||
}
|
||||
|
||||
export default SnippetsPage
|
||||
@ -168,6 +168,21 @@ 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', () => {
|
||||
|
||||
@ -28,12 +28,16 @@ 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) => {
|
||||
@ -112,18 +116,20 @@ const AppDetailNav = ({
|
||||
expand ? 'p-2' : 'p-1',
|
||||
)}
|
||||
>
|
||||
{iconType === 'app' && (
|
||||
appInfoActions
|
||||
? (
|
||||
<AppInfoView
|
||||
expand={expand}
|
||||
actions={appInfoActions}
|
||||
renderDetail={false}
|
||||
/>
|
||||
)
|
||||
: <AppInfo expand={expand} />
|
||||
)}
|
||||
{iconType !== 'app' && (
|
||||
{renderHeader
|
||||
? renderHeader(appSidebarExpand)
|
||||
: iconType === 'app' && (
|
||||
appInfoActions
|
||||
? (
|
||||
<AppInfoView
|
||||
expand={expand}
|
||||
actions={appInfoActions}
|
||||
renderDetail={false}
|
||||
/>
|
||||
)
|
||||
: <AppInfo expand={expand} />
|
||||
)}
|
||||
{!renderHeader && iconType !== 'app' && (
|
||||
<DatasetInfo expand={expand} />
|
||||
)}
|
||||
</div>
|
||||
@ -152,18 +158,20 @@ const AppDetailNav = ({
|
||||
expand ? 'px-3 py-2' : 'p-3',
|
||||
)}
|
||||
>
|
||||
{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}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{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}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
{iconType !== 'app' && extraInfo && extraInfo(appSidebarExpand)}
|
||||
</div>
|
||||
|
||||
@ -262,4 +262,20 @@ 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -14,13 +14,15 @@ 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 = ({
|
||||
@ -29,6 +31,8 @@ const NavLink = ({
|
||||
iconMap,
|
||||
mode = 'expand',
|
||||
disabled = false,
|
||||
active,
|
||||
onClick,
|
||||
}: NavLinkProps) => {
|
||||
const segment = useSelectedLayoutSegment()
|
||||
const formattedSegment = (() => {
|
||||
@ -39,8 +43,11 @@ const NavLink = ({
|
||||
|
||||
return res
|
||||
})()
|
||||
const isActive = href.toLowerCase().split('/')?.pop() === formattedSegment
|
||||
const isActive = active ?? (href ? href.toLowerCase().split('/')?.pop() === formattedSegment : false)
|
||||
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')}>
|
||||
@ -70,13 +77,32 @@ 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={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')}
|
||||
className={linkClassName}
|
||||
title={mode === 'collapse' ? name : ''}
|
||||
>
|
||||
{renderIcon()}
|
||||
|
||||
@ -0,0 +1,270 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,60 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
177
web/app/components/app-sidebar/snippet-info/dropdown.tsx
Normal file
177
web/app/components/app-sidebar/snippet-info/dropdown.tsx
Normal file
@ -0,0 +1,177 @@
|
||||
'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)
|
||||
46
web/app/components/app-sidebar/snippet-info/index.tsx
Normal file
46
web/app/components/app-sidebar/snippet-info/index.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
'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)
|
||||
@ -1,7 +1,6 @@
|
||||
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 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 {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
@ -21,9 +20,15 @@ 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?: ModelAndParameter | PublishWorkflowParams, features?: any) => Promise<any> | any
|
||||
publishedConfig?: any
|
||||
onPublish?: (params?: AppPublisherPublishParams, features?: Features) => Promise<unknown> | unknown
|
||||
publishedConfig: {
|
||||
modelConfig: PublishedModelConfig
|
||||
}
|
||||
resetAppConfig?: () => void
|
||||
}
|
||||
|
||||
@ -71,7 +76,7 @@ const FeaturesWrappedAppPublisher = (props: Props) => {
|
||||
setRestoreConfirmOpen(false)
|
||||
}, [featuresStore, props])
|
||||
|
||||
const handlePublish = useCallback((params?: ModelAndParameter | PublishWorkflowParams) => {
|
||||
const handlePublish = useCallback((params?: AppPublisherPublishParams) => {
|
||||
return props.onPublish?.(params, features)
|
||||
}, [features, props])
|
||||
|
||||
|
||||
@ -85,8 +85,10 @@ export type AppPublisherProps = {
|
||||
|
||||
const PUBLISH_SHORTCUT = ['Mod', 'Shift', 'P']
|
||||
|
||||
export type AppPublisherPublishParams = ModelAndParameter | PublishWorkflowParams
|
||||
|
||||
type AppPublisherPublishHandler
|
||||
= | ((params?: ModelAndParameter | PublishWorkflowParams) => Promise<unknown> | unknown)
|
||||
= | ((params?: AppPublisherPublishParams) => Promise<unknown> | unknown)
|
||||
| ((params?: unknown) => Promise<unknown> | unknown)
|
||||
|
||||
type AppPublisherRestoreHandler = () => Promise<unknown> | unknown
|
||||
|
||||
@ -211,6 +211,12 @@ 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,
|
||||
|
||||
@ -49,6 +49,7 @@ type ConfigModalFormFieldsProps = {
|
||||
onVarNameChange: (event: ChangeEvent<HTMLInputElement>) => void
|
||||
options?: string[]
|
||||
selectOptions: SelectOptionItem[]
|
||||
showHiddenField?: boolean
|
||||
tempPayload: InputVar
|
||||
t: Translate
|
||||
}
|
||||
@ -67,6 +68,7 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
|
||||
onVarNameChange,
|
||||
options,
|
||||
selectOptions,
|
||||
showHiddenField = true,
|
||||
tempPayload,
|
||||
t,
|
||||
}) => {
|
||||
@ -242,7 +244,7 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
|
||||
<span className="system-sm-semibold text-text-secondary">{t('variableConfig.required', { ns: 'appDebug' })}</span>
|
||||
</label>
|
||||
|
||||
{!isFileInput && (
|
||||
{showHiddenField && !isFileInput && (
|
||||
<div className="mt-5! flex h-6 items-center gap-2">
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
|
||||
@ -33,6 +33,7 @@ type IConfigModalProps = {
|
||||
onClose: () => void
|
||||
onConfirm: (newValue: InputVar, moreInfo?: MoreInfo) => void
|
||||
supportFile?: boolean
|
||||
showHiddenField?: boolean
|
||||
}
|
||||
|
||||
const ConfigModal: FC<IConfigModalProps> = ({
|
||||
@ -41,6 +42,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
|
||||
isShow,
|
||||
onClose,
|
||||
onConfirm,
|
||||
showHiddenField,
|
||||
supportFile,
|
||||
}) => {
|
||||
const { modelConfig } = useContext(ConfigContext)
|
||||
@ -173,6 +175,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
|
||||
onVarNameChange={handleVarNameChange}
|
||||
options={options}
|
||||
selectOptions={selectOptions}
|
||||
showHiddenField={showHiddenField}
|
||||
tempPayload={tempPayload}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
@ -96,7 +96,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
|
||||
const [model, setModel] = React.useState<Model>(localModel || {
|
||||
name: '',
|
||||
provider: '',
|
||||
mode: mode as unknown as ModelModeType.chat,
|
||||
mode: mode as unknown as ModelModeType,
|
||||
completion_params: {} as CompletionParams,
|
||||
})
|
||||
const {
|
||||
|
||||
@ -78,7 +78,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
|
||||
const [model, setModel] = React.useState<Model>(localModel || {
|
||||
name: '',
|
||||
provider: '',
|
||||
mode: mode as unknown as ModelModeType.chat,
|
||||
mode: mode as unknown as ModelModeType,
|
||||
completion_params: defaultCompletionParams,
|
||||
})
|
||||
const {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
'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'
|
||||
@ -21,7 +22,6 @@ 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?: ModelAndParameter | PublishWorkflowParams, features?: FeaturesData) => {
|
||||
const onPublish = useCallback(async (params?: AppPublisherPublishParams, features?: FeaturesData) => {
|
||||
const modelAndParameter = params && 'model' in params && 'provider' in params && 'parameters' in params
|
||||
? params
|
||||
: undefined
|
||||
|
||||
@ -346,29 +346,40 @@ function AppTypeCard({ icon, title, description, active, onClick }: AppTypeCardP
|
||||
|
||||
function AppPreview({ mode }: { mode: AppModeEnum }) {
|
||||
const { t } = useTranslation()
|
||||
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]
|
||||
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' }),
|
||||
}
|
||||
}
|
||||
})()
|
||||
return (
|
||||
<div className="px-8 py-4">
|
||||
<h4 className="system-sm-semibold-uppercase text-text-secondary">{previewInfo.title}</h4>
|
||||
|
||||
@ -2,6 +2,8 @@ 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()
|
||||
@ -9,32 +11,32 @@ describe('Empty', () => {
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Empty />)
|
||||
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
|
||||
render(<Empty message={defaultMessage} />)
|
||||
expect(screen.getByText(defaultMessage)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render 36 placeholder cards', () => {
|
||||
const { container } = render(<Empty />)
|
||||
const { container } = render(<Empty message={defaultMessage} />)
|
||||
const placeholderCards = container.querySelectorAll('.bg-background-default-lighter')
|
||||
expect(placeholderCards).toHaveLength(36)
|
||||
})
|
||||
|
||||
it('should display the no apps found message', () => {
|
||||
render(<Empty />)
|
||||
it('should display the provided message', () => {
|
||||
render(<Empty message="app.newApp.noAppsFound" />)
|
||||
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should have correct container styling for overlay', () => {
|
||||
const { container } = render(<Empty />)
|
||||
const { container } = render(<Empty message={defaultMessage} />)
|
||||
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 />)
|
||||
const { container } = render(<Empty message={defaultMessage} />)
|
||||
const card = container.querySelector('.bg-background-default-lighter')
|
||||
expect(card).toHaveClass('inline-flex', 'h-[160px]', 'rounded-xl')
|
||||
})
|
||||
@ -42,10 +44,10 @@ describe('Empty', () => {
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle multiple renders without issues', () => {
|
||||
const { rerender } = render(<Empty />)
|
||||
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
|
||||
const { rerender } = render(<Empty message={defaultMessage} />)
|
||||
expect(screen.getByText(defaultMessage)).toBeInTheDocument()
|
||||
|
||||
rerender(<Empty />)
|
||||
rerender(<Empty message="app.newApp.noAppsFound" />)
|
||||
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -45,18 +45,19 @@ vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor(),
|
||||
isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator(),
|
||||
userProfile: { id: 'creator-1' },
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockSetKeywords = vi.fn()
|
||||
const mockSetTagIDs = vi.fn()
|
||||
const mockSetIsCreatedByMe = vi.fn()
|
||||
const mockSetCreatorID = vi.fn()
|
||||
const mockSetCategory = vi.fn()
|
||||
const mockQueryState = {
|
||||
category: 'all',
|
||||
tagIDs: [] as string[],
|
||||
keywords: '',
|
||||
isCreatedByMe: false,
|
||||
creatorID: '',
|
||||
}
|
||||
vi.mock('../hooks/use-apps-query-state', () => ({
|
||||
isAppListCategory: (value: string) => value === 'all' || Object.values(AppModeEnum).includes(value as AppModeEnum),
|
||||
@ -65,7 +66,18 @@ vi.mock('../hooks/use-apps-query-state', () => ({
|
||||
setCategory: mockSetCategory,
|
||||
setKeywords: mockSetKeywords,
|
||||
setTagIDs: mockSetTagIDs,
|
||||
setIsCreatedByMe: mockSetIsCreatedByMe,
|
||||
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' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
@ -190,9 +202,9 @@ vi.mock('../app-card', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../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')
|
||||
}),
|
||||
default: ({ ref: _ref }: { ref?: React.Ref<HTMLDivElement> }) => {
|
||||
return React.createElement('div', { 'data-testid': 'new-app-card', 'role': 'button', 'ref': _ref }, 'New App Card')
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../empty', () => ({
|
||||
@ -229,11 +241,15 @@ beforeAll(() => {
|
||||
|
||||
// Render helper wrapping with shared nuqs testing helper plus a seeded
|
||||
// systemFeatures cache so List can resolve its useSuspenseQuery.
|
||||
const renderList = (searchParams = '') => {
|
||||
const renderList = (searchParams = '', pageType: 'apps' | 'snippets' = 'apps') => {
|
||||
const { wrapper: SystemFeaturesWrapper } = createSystemFeaturesWrapper({
|
||||
systemFeatures: { branding: { enabled: false } },
|
||||
})
|
||||
return renderWithNuqs(<SystemFeaturesWrapper><List /></SystemFeaturesWrapper>, { searchParams })
|
||||
return renderWithNuqs(<SystemFeaturesWrapper><List pageType={pageType} /></SystemFeaturesWrapper>, { searchParams })
|
||||
}
|
||||
|
||||
const openTypeFilter = () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /^app\.(studio\.filters\.types|types\.)/ }))
|
||||
}
|
||||
|
||||
type AppListInfiniteOptions = {
|
||||
@ -255,7 +271,7 @@ describe('List', () => {
|
||||
mockQueryState.category = 'all'
|
||||
mockQueryState.tagIDs = []
|
||||
mockQueryState.keywords = ''
|
||||
mockQueryState.isCreatedByMe = false
|
||||
mockQueryState.creatorID = ''
|
||||
mockUseWorkflowOnlineUsers.mockClear()
|
||||
intersectionCallback = null
|
||||
localStorage.clear()
|
||||
@ -264,11 +280,12 @@ describe('List', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
renderList()
|
||||
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.studio.filters.types'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tab slider with all app types', () => {
|
||||
it('should render app type dropdown with all app types', () => {
|
||||
renderList()
|
||||
openTypeFilter()
|
||||
|
||||
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.workflow'))!.toBeInTheDocument()
|
||||
@ -288,9 +305,21 @@ describe('List', () => {
|
||||
expect(screen.getByText('common.tag.placeholder'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render created by me checkbox', () => {
|
||||
it('should render creators filter', () => {
|
||||
renderList()
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly'))!.toBeInTheDocument()
|
||||
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()
|
||||
})
|
||||
|
||||
it('should render app cards when apps exist', () => {
|
||||
@ -325,20 +354,22 @@ describe('List', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tab Navigation', () => {
|
||||
it('should update category when workflow tab is clicked', () => {
|
||||
describe('Type Filter', () => {
|
||||
it('should update category when workflow type is selected', () => {
|
||||
renderList()
|
||||
openTypeFilter()
|
||||
|
||||
fireEvent.click(screen.getByText('app.types.workflow'))
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: 'app.types.workflow' }))
|
||||
|
||||
expect(mockSetCategory).toHaveBeenCalledWith(AppModeEnum.WORKFLOW)
|
||||
})
|
||||
|
||||
it('should update category when all tab is clicked', () => {
|
||||
it('should update category when all type is selected', () => {
|
||||
mockQueryState.category = AppModeEnum.WORKFLOW
|
||||
renderList()
|
||||
openTypeFilter()
|
||||
|
||||
fireEvent.click(screen.getByText('app.types.all'))
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: 'app.types.all' }))
|
||||
|
||||
expect(mockSetCategory).toHaveBeenCalledWith('all')
|
||||
})
|
||||
@ -364,10 +395,7 @@ describe('List', () => {
|
||||
|
||||
renderList()
|
||||
|
||||
const clearButton = document.querySelector('.group')
|
||||
expect(clearButton)!.toBeInTheDocument()
|
||||
if (clearButton)
|
||||
fireEvent.click(clearButton)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' }))
|
||||
|
||||
expect(mockSetKeywords).toHaveBeenCalledWith('')
|
||||
})
|
||||
@ -377,7 +405,7 @@ describe('List', () => {
|
||||
it('should build paged query input from active filters', () => {
|
||||
mockQueryState.tagIDs = ['tag-1']
|
||||
mockQueryState.keywords = 'sales'
|
||||
mockQueryState.isCreatedByMe = true
|
||||
mockQueryState.creatorID = 'creator-1'
|
||||
mockQueryState.category = AppModeEnum.WORKFLOW
|
||||
|
||||
renderList()
|
||||
@ -390,7 +418,7 @@ describe('List', () => {
|
||||
limit: 30,
|
||||
name: 'sales',
|
||||
tag_ids: ['tag-1'],
|
||||
is_created_by_me: true,
|
||||
creator_id: 'creator-1',
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
},
|
||||
})
|
||||
@ -406,19 +434,19 @@ describe('List', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Created By Me Filter', () => {
|
||||
it('should render checkbox with correct label', () => {
|
||||
describe('Creators Filter', () => {
|
||||
it('should render creators filter with correct label', () => {
|
||||
renderList()
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.studio.filters.allCreators'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle checkbox change', () => {
|
||||
it('should handle creator selection as a single creator filter', () => {
|
||||
renderList()
|
||||
|
||||
const checkbox = screen.getByRole('checkbox', { name: 'app.showMyCreatedAppsOnly' })
|
||||
fireEvent.click(checkbox)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'app.studio.filters.allCreators' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Bob/ }))
|
||||
|
||||
expect(mockSetIsCreatedByMe).toHaveBeenCalledWith(true)
|
||||
expect(mockSetCreatorID).toHaveBeenCalledWith('creator-2')
|
||||
})
|
||||
})
|
||||
|
||||
@ -464,11 +492,11 @@ describe('List', () => {
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle multiple renders without issues', () => {
|
||||
const { unmount } = renderList()
|
||||
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.studio.filters.types'))!.toBeInTheDocument()
|
||||
|
||||
unmount()
|
||||
renderList()
|
||||
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.studio.filters.types'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render app cards correctly', () => {
|
||||
@ -481,9 +509,10 @@ 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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -500,9 +529,10 @@ describe('List', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('App Type Tabs', () => {
|
||||
it('should render all app type tabs', () => {
|
||||
describe('App Type Dropdown', () => {
|
||||
it('should render all app type options', () => {
|
||||
renderList()
|
||||
openTypeFilter()
|
||||
|
||||
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.workflow'))!.toBeInTheDocument()
|
||||
@ -512,9 +542,7 @@ describe('List', () => {
|
||||
expect(screen.getByText('app.types.completion'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update category for each app type tab click', () => {
|
||||
renderList()
|
||||
|
||||
it('should update category for each app type option click', () => {
|
||||
const appTypeTexts = [
|
||||
{ mode: AppModeEnum.WORKFLOW, text: 'app.types.workflow' },
|
||||
{ mode: AppModeEnum.ADVANCED_CHAT, text: 'app.types.advanced' },
|
||||
@ -525,8 +553,11 @@ describe('List', () => {
|
||||
|
||||
for (const { mode, text } of appTypeTexts) {
|
||||
mockSetCategory.mockClear()
|
||||
fireEvent.click(screen.getByText(text))
|
||||
const { unmount } = renderList()
|
||||
openTypeFilter()
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: text }))
|
||||
expect(mockSetCategory).toHaveBeenCalledWith(mode)
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
16
web/app/components/apps/app-type-filter-shared.ts
Normal file
16
web/app/components/apps/app-type-filter-shared.ts
Normal file
@ -0,0 +1,16 @@
|
||||
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' })
|
||||
76
web/app/components/apps/app-type-filter.tsx
Normal file
76
web/app/components/apps/app-type-filter.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
230
web/app/components/apps/creators-filter.tsx
Normal file
230
web/app/components/apps/creators-filter.tsx
Normal file
@ -0,0 +1,230 @@
|
||||
'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
|
||||
@ -17,7 +17,11 @@ const DefaultCards = React.memo(() => {
|
||||
)
|
||||
})
|
||||
|
||||
const Empty = () => {
|
||||
type EmptyProps = {
|
||||
message?: string
|
||||
}
|
||||
|
||||
const Empty = ({ message }: EmptyProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
@ -25,7 +29,7 @@ const Empty = () => {
|
||||
<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">
|
||||
{t('newApp.noAppsFound', { ns: 'app' })}
|
||||
{message ?? t('newApp.noAppsFound', { ns: 'app' })}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@ -5,6 +5,7 @@ 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 })
|
||||
}
|
||||
|
||||
@ -20,24 +21,24 @@ describe('useAppsQueryState', () => {
|
||||
category: 'all',
|
||||
tagIDs: [],
|
||||
keywords: '',
|
||||
isCreatedByMe: false,
|
||||
creatorID: '',
|
||||
})
|
||||
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.setIsCreatedByMe).toBe('function')
|
||||
expect(typeof result.current.setCreatorID).toBe('function')
|
||||
})
|
||||
|
||||
it('should parse app list filters from URL', () => {
|
||||
const { result } = renderWithAdapter(
|
||||
'?category=workflow&tagIDs=tag1;tag2&keywords=search+term&isCreatedByMe=true',
|
||||
'?category=workflow&tagIDs=tag1;tag2&keywords=search+term&creatorID=creator-1',
|
||||
)
|
||||
|
||||
expect(result.current.query).toEqual({
|
||||
category: AppModeEnum.WORKFLOW,
|
||||
tagIDs: ['tag1', 'tag2'],
|
||||
keywords: 'search term',
|
||||
isCreatedByMe: true,
|
||||
creatorID: 'creator-1',
|
||||
})
|
||||
})
|
||||
|
||||
@ -144,30 +145,30 @@ describe('useAppsQueryState', () => {
|
||||
expect(update.searchParams.has('tagIDs')).toBe(false)
|
||||
})
|
||||
|
||||
it('should update created-by-me URL state', async () => {
|
||||
it('should update creator ID URL state', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.setIsCreatedByMe(true)
|
||||
result.current.setCreatorID('creator-1')
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls.at(-1)![0]
|
||||
expect(result.current.query.isCreatedByMe).toBe(true)
|
||||
expect(update.searchParams.get('isCreatedByMe')).toBe('true')
|
||||
expect(result.current.query.creatorID).toBe('creator-1')
|
||||
expect(update.searchParams.get('creatorID')).toBe('creator-1')
|
||||
expect(update.options.history).toBe('push')
|
||||
})
|
||||
|
||||
it('should remove isCreatedByMe from URL when disabled', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter('?isCreatedByMe=true')
|
||||
it('should remove creatorID from URL when cleared', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter('?creatorID=creator-1')
|
||||
|
||||
act(() => {
|
||||
result.current.setIsCreatedByMe(false)
|
||||
result.current.setCreatorID('')
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls.at(-1)![0]
|
||||
expect(result.current.query.isCreatedByMe).toBe(false)
|
||||
expect(update.searchParams.has('isCreatedByMe')).toBe(false)
|
||||
expect(result.current.query.creatorID).toBe('')
|
||||
expect(update.searchParams.has('creatorID')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,29 +1,19 @@
|
||||
import { debounce, parseAsArrayOf, parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryStates } from 'nuqs'
|
||||
import type { AppListCategory } from '../app-type-filter-shared'
|
||||
import { debounce, parseAsArrayOf, parseAsString, useQueryStates } from 'nuqs'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { AppModes } from '@/types/app'
|
||||
import { parseAsAppListCategory } from '../app-type-filter-shared'
|
||||
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: parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
|
||||
.withDefault('all')
|
||||
.withOptions({ history: 'push' }),
|
||||
category: parseAsAppListCategory,
|
||||
tagIDs: parseAsArrayOf(parseAsString, ';')
|
||||
.withDefault([])
|
||||
.withOptions({ history: 'push' }),
|
||||
keywords: parseAsString.withDefault('').withOptions({
|
||||
limitUrlUpdates: debounce(APP_LIST_SEARCH_DEBOUNCE_MS),
|
||||
}),
|
||||
isCreatedByMe: parseAsBoolean
|
||||
.withDefault(false)
|
||||
creatorID: parseAsString
|
||||
.withDefault('')
|
||||
.withOptions({ history: 'push' }),
|
||||
}
|
||||
|
||||
@ -42,8 +32,8 @@ export function useAppsQueryState() {
|
||||
setQuery({ tagIDs })
|
||||
}, [setQuery])
|
||||
|
||||
const setIsCreatedByMe = useCallback((isCreatedByMe: boolean) => {
|
||||
setQuery({ isCreatedByMe })
|
||||
const setCreatorID = useCallback((creatorID: string) => {
|
||||
setQuery({ creatorID })
|
||||
}, [setQuery])
|
||||
|
||||
return useMemo(() => ({
|
||||
@ -51,6 +41,6 @@ export function useAppsQueryState() {
|
||||
setCategory,
|
||||
setKeywords,
|
||||
setTagIDs,
|
||||
setIsCreatedByMe,
|
||||
}), [query, setCategory, setKeywords, setTagIDs, setIsCreatedByMe])
|
||||
setCreatorID,
|
||||
}), [query, setCategory, setKeywords, setTagIDs, setCreatorID])
|
||||
}
|
||||
|
||||
@ -15,19 +15,29 @@ 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 = () => {
|
||||
const Apps = ({
|
||||
pageType = 'apps',
|
||||
}: AppsProps) => {
|
||||
const { t } = useTranslation()
|
||||
const searchParams = useSearchParams()
|
||||
const { replace } = useRouter()
|
||||
const templateId = searchParams.get('template-id')
|
||||
const templateDismissedRef = useRef(false)
|
||||
|
||||
useDocumentTitle(t('menus.apps', { ns: 'common' }))
|
||||
useDocumentTitle(pageType === 'apps'
|
||||
? t('menus.apps', { ns: 'common' })
|
||||
: t('tabs.snippets', { ns: 'workflow' }))
|
||||
useEducationInit()
|
||||
|
||||
const [currentTryAppParams, setCurrentTryAppParams] = useState<TryAppSelection | undefined>(undefined)
|
||||
@ -165,7 +175,7 @@ const Apps = () => {
|
||||
}}
|
||||
>
|
||||
<div className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
|
||||
<List controlRefreshList={controlRefreshList} />
|
||||
<List controlRefreshList={controlRefreshList} pageType={pageType} />
|
||||
{isShowTryAppPanel && (
|
||||
<TryApp
|
||||
appId={currentTryAppParams?.appId || ''}
|
||||
|
||||
@ -1,29 +1,31 @@
|
||||
'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 { isAppListCategory, useAppsQueryState } from './hooks/use-apps-query-state'
|
||||
import { 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'
|
||||
@ -37,9 +39,11 @@ 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())
|
||||
@ -47,11 +51,11 @@ const List: FC<Props> = ({
|
||||
|
||||
// eslint-disable-next-line react/use-state -- custom URL query hook, not React.useState
|
||||
const {
|
||||
query: { category, tagIDs, keywords, isCreatedByMe },
|
||||
query: { category, tagIDs, keywords, creatorID },
|
||||
setCategory,
|
||||
setKeywords,
|
||||
setTagIDs,
|
||||
setIsCreatedByMe,
|
||||
setCreatorID,
|
||||
} = useAppsQueryState()
|
||||
const debouncedKeywords = useDebounce(keywords, { wait: APP_LIST_SEARCH_DEBOUNCE_MS })
|
||||
const newAppCardRef = useRef<HTMLDivElement>(null)
|
||||
@ -76,9 +80,9 @@ const List: FC<Props> = ({
|
||||
limit: 30,
|
||||
name: debouncedKeywords,
|
||||
...(tagIDs.length ? { tag_ids: tagIDs } : {}),
|
||||
...(isCreatedByMe ? { is_created_by_me: isCreatedByMe } : {}),
|
||||
...(creatorID ? { creator_id: creatorID } : {}),
|
||||
...(category !== 'all' ? { mode: category } : {}),
|
||||
}), [category, debouncedKeywords, isCreatedByMe, tagIDs])
|
||||
}), [category, creatorID, debouncedKeywords, tagIDs])
|
||||
|
||||
const {
|
||||
data,
|
||||
@ -112,14 +116,6 @@ 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') {
|
||||
@ -158,9 +154,9 @@ const List: FC<Props> = ({
|
||||
return () => observer?.disconnect()
|
||||
}, [isLoading, isFetchingNextPage, fetchNextPage, error, hasNextPage, isCurrentWorkspaceDatasetOperator])
|
||||
|
||||
const handleCreatedByMeChange = useCallback((checked: boolean) => {
|
||||
setIsCreatedByMe(checked)
|
||||
}, [setIsCreatedByMe])
|
||||
const handleCreatorsChange = useCallback((creatorIDs: string[]) => {
|
||||
setCreatorID(creatorIDs.at(-1) ?? '')
|
||||
}, [setCreatorID])
|
||||
|
||||
const pages = useMemo(() => data?.pages ?? [], [data?.pages])
|
||||
const apps = useMemo(() => pages.flatMap(({ data: pageApps }) => pageApps), [pages])
|
||||
@ -193,32 +189,45 @@ const List: FC<Props> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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)} />
|
||||
<Input
|
||||
showLeftIcon
|
||||
showClearIcon
|
||||
wrapperClassName="w-[200px]"
|
||||
value={keywords}
|
||||
onChange={e => setKeywords(e.target.value)}
|
||||
onClear={() => setKeywords('')}
|
||||
<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}
|
||||
/>
|
||||
<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>
|
||||
</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',
|
||||
@ -246,7 +255,7 @@ const List: FC<Props> = ({
|
||||
onOpenTagManagement={() => setShowTagManagementModal(true)}
|
||||
/>
|
||||
))
|
||||
: <Empty />}
|
||||
: <Empty message={pageType === 'snippets' ? t('tabs.noSnippetsFound', { ns: 'workflow' }) : undefined} />}
|
||||
{isFetchingNextPage && (
|
||||
<AppCardSkeleton count={3} />
|
||||
)}
|
||||
|
||||
@ -104,12 +104,23 @@ 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>
|
||||
@ -201,6 +212,15 @@ 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,
|
||||
|
||||
@ -103,8 +103,13 @@ const AppNav = () => {
|
||||
icon={<RiRobot2Line className="size-4" />}
|
||||
activeIcon={<RiRobot2Fill className="size-4" />}
|
||||
text={t('menus.apps', { ns: 'common' })}
|
||||
activeSegment={['apps', 'app']}
|
||||
activeSegment={['apps', 'app', 'snippets']}
|
||||
link="/apps"
|
||||
activeLink={{
|
||||
segment: 'snippets',
|
||||
text: t('tabs.snippets', { ns: 'workflow' }),
|
||||
link: '/snippets',
|
||||
}}
|
||||
curNav={appDetail}
|
||||
navigationItems={navItems}
|
||||
createText={t('menus.newApp', { ns: 'common' })}
|
||||
|
||||
@ -102,6 +102,13 @@ describe('DatasetNav', () => {
|
||||
icon_info: { icon: 'pipeline' },
|
||||
provider: 'vendor',
|
||||
},
|
||||
{
|
||||
id: 'dataset-5',
|
||||
name: 'Null Icon Dataset',
|
||||
runtime_mode: 'general',
|
||||
icon_info: null,
|
||||
provider: 'vendor',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@ -141,6 +148,16 @@ 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', () => {
|
||||
@ -154,6 +171,7 @@ 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', () => {
|
||||
|
||||
@ -1,11 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import type { NavItem } from '../nav/nav-selector'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import {
|
||||
RiBook2Fill,
|
||||
RiBook2Line,
|
||||
} from '@remixicon/react'
|
||||
import type { DataSet, IconInfo } from '@/models/datasets'
|
||||
import { flatten } from 'es-toolkit/compat'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -14,6 +10,24 @@ 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()
|
||||
@ -33,15 +47,16 @@ const DatasetNav = () => {
|
||||
const curNav = useMemo(() => {
|
||||
if (!currentDataset)
|
||||
return
|
||||
const iconInfo = normalizeDatasetIconInfo(currentDataset.icon_info)
|
||||
return {
|
||||
id: currentDataset.id,
|
||||
name: currentDataset.name,
|
||||
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,
|
||||
icon: iconInfo.icon,
|
||||
icon_type: iconInfo.icon_type,
|
||||
icon_background: iconInfo.icon_background ?? null,
|
||||
icon_url: iconInfo.icon_url ?? null,
|
||||
} as Omit<NavItem, 'link'>
|
||||
}, [currentDataset?.id, currentDataset?.name, currentDataset?.icon_info])
|
||||
}, [currentDataset])
|
||||
|
||||
const getDatasetLink = useCallback((dataset: DataSet) => {
|
||||
const isPipelineUnpublished = dataset.runtime_mode === 'rag_pipeline' && !dataset.is_published
|
||||
@ -56,14 +71,15 @@ 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: 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,
|
||||
icon: iconInfo.icon,
|
||||
icon_type: iconInfo.icon_type,
|
||||
icon_background: iconInfo.icon_background ?? null,
|
||||
icon_url: iconInfo.icon_url ?? null,
|
||||
}
|
||||
}) as NavItem[]
|
||||
}, [datasetItems, getDatasetLink])
|
||||
@ -84,8 +100,8 @@ const DatasetNav = () => {
|
||||
return (
|
||||
<Nav
|
||||
isApp={false}
|
||||
icon={<RiBook2Line className="size-4" />}
|
||||
activeIcon={<RiBook2Fill className="size-4" />}
|
||||
icon={<span className="i-ri-book-2-line size-4" />}
|
||||
activeIcon={<span className="i-ri-book-2-fill size-4" />}
|
||||
text={t('menus.datasets', { ns: 'common' })}
|
||||
activeSegment="datasets"
|
||||
link="/datasets"
|
||||
|
||||
@ -14,7 +14,7 @@ const HeaderWrapper = ({
|
||||
children,
|
||||
}: HeaderWrapperProps) => {
|
||||
const pathname = usePathname()
|
||||
const isBordered = ['/apps', '/datasets/create', '/tools'].includes(pathname)
|
||||
const isBordered = ['/apps', '/snippets', '/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')
|
||||
|
||||
@ -123,6 +123,27 @@ 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} />)
|
||||
@ -148,6 +169,14 @@ 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} />)
|
||||
@ -185,19 +214,20 @@ describe('Nav Component', () => {
|
||||
})
|
||||
|
||||
it('should navigate when an item is selected', async () => {
|
||||
render(<Nav {...defaultProps} curNav={curNav} />)
|
||||
vi.mocked(useSelectedLayoutSegment).mockReturnValue('snippets')
|
||||
render(<Nav {...defaultProps} activeSegment={['apps', 'app', 'snippets']} 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')
|
||||
})
|
||||
|
||||
|
||||
@ -5,7 +5,6 @@ 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'
|
||||
@ -16,6 +15,11 @@ type INavProps = {
|
||||
text: string
|
||||
activeSegment: string | string[]
|
||||
link: string
|
||||
activeLink?: {
|
||||
segment: string
|
||||
text: string
|
||||
link: string
|
||||
}
|
||||
isApp: boolean
|
||||
} & INavSelectorProps
|
||||
|
||||
@ -25,6 +29,7 @@ const Nav = ({
|
||||
text,
|
||||
activeSegment,
|
||||
link,
|
||||
activeLink,
|
||||
curNav,
|
||||
navigationItems,
|
||||
createText,
|
||||
@ -37,10 +42,11 @@ 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-[670px] shrink-0 items-center rounded-xl px-0.5 text-sm font-medium max-[1024px]:max-w-[400px]
|
||||
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
|
||||
${isActivated && 'bg-components-main-nav-nav-button-bg-active font-semibold shadow-md'}
|
||||
${!curNav && !isActivated && 'hover:bg-components-main-nav-nav-button-bg-hover'}
|
||||
`}
|
||||
@ -51,6 +57,8 @@ 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')}
|
||||
@ -60,7 +68,7 @@ const Nav = ({
|
||||
<div>
|
||||
{
|
||||
(hovered && curNav)
|
||||
? <ArrowNarrowLeft className="size-4" />
|
||||
? <span className="i-custom-vender-line-arrows-arrow-narrow-left size-4" />
|
||||
: isActivated
|
||||
? activeIcon
|
||||
: icon
|
||||
@ -87,6 +95,19 @@ 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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -465,14 +465,6 @@ 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()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
|
||||
@ -53,8 +53,7 @@ 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, from } = payload
|
||||
const badges = payload.badges ?? []
|
||||
const { category, type, name, org, label, brief, icon, icon_dark, verified, badges = [], from } = payload
|
||||
const { theme } = useTheme()
|
||||
const iconSrc = getPluginCardIconUrl(
|
||||
{ from, name, org, type },
|
||||
|
||||
@ -190,7 +190,7 @@ export type PluginManifestInMarket = {
|
||||
introduction: string
|
||||
verified: boolean
|
||||
install_count: number
|
||||
badges: string[] | null
|
||||
badges: string[]
|
||||
verification: {
|
||||
authorized_category: 'langgenius' | 'partner' | 'community'
|
||||
}
|
||||
@ -255,7 +255,7 @@ export type Plugin = {
|
||||
settings: CredentialFormSchemaBase[]
|
||||
}
|
||||
tags: { name: string }[]
|
||||
badges: string[] | null
|
||||
badges: string[]
|
||||
verification: {
|
||||
authorized_category: 'langgenius' | 'partner' | 'community'
|
||||
}
|
||||
|
||||
278
web/app/components/snippet-list/__tests__/index.spec.tsx
Normal file
278
web/app/components/snippet-list/__tests__/index.spec.tsx
Normal file
@ -0,0 +1,278 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,198 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,111 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
259
web/app/components/snippet-list/components/snippet-card.tsx
Normal file
259
web/app/components/snippet-list/components/snippet-card.tsx
Normal file
@ -0,0 +1,259 @@
|
||||
'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
|
||||
@ -0,0 +1,111 @@
|
||||
'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
|
||||
1
web/app/components/snippet-list/constants.ts
Normal file
1
web/app/components/snippet-list/constants.ts
Normal file
@ -0,0 +1 @@
|
||||
export const SNIPPET_LIST_SEARCH_DEBOUNCE_MS = 500
|
||||
@ -0,0 +1,38 @@
|
||||
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])
|
||||
}
|
||||
195
web/app/components/snippet-list/index.tsx
Normal file
195
web/app/components/snippet-list/index.tsx
Normal file
@ -0,0 +1,195 @@
|
||||
'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
|
||||
137
web/app/components/snippets/__tests__/index.spec.tsx
Normal file
137
web/app/components/snippets/__tests__/index.spec.tsx
Normal file
@ -0,0 +1,137 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,60 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,406 @@
|
||||
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',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,43 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,70 @@
|
||||
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),
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,119 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,34 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
59
web/app/components/snippets/components/snippet-children.tsx
Normal file
59
web/app/components/snippets/components/snippet-children.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
'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
|
||||
@ -0,0 +1,102 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,75 @@
|
||||
'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)
|
||||
200
web/app/components/snippets/components/snippet-header/index.tsx
Normal file
200
web/app/components/snippets/components/snippet-header/index.tsx
Normal file
@ -0,0 +1,200 @@
|
||||
'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)
|
||||
@ -0,0 +1,30 @@
|
||||
'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)
|
||||
@ -0,0 +1,78 @@
|
||||
'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
Reference in New Issue
Block a user