Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1236d2b063 | ||
|
|
0a80c3f8e3 | ||
|
|
c4bc9abc2c | ||
|
|
5ea884f799 | ||
|
|
2ec34b2cfb | ||
|
|
0c30918760 | ||
|
|
fa65f45bff | ||
|
|
63ca2b94b5 | ||
|
|
a7ef41a8f5 | ||
|
|
b3c7a706cd | ||
|
|
9554037fec | ||
|
|
a813e093d6 | ||
|
|
f7a3b9b283 |
+209
-63
@@ -1,6 +1,8 @@
|
||||
import datetime
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from typing import TypedDict
|
||||
|
||||
@@ -21,6 +23,7 @@ from tasks.remove_app_and_related_data_task import delete_draft_variables_batch
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_HEX_PREFIXES = tuple("0123456789abcdef")
|
||||
_TARGET_MONTH_PATTERN = re.compile(r"^\d{4}-(0[1-9]|1[0-2])$")
|
||||
|
||||
|
||||
class WorkflowRunArchivePlanRow(TypedDict):
|
||||
@@ -66,6 +69,7 @@ def _parse_tenant_prefixes(prefixes: str | None) -> list[str]:
|
||||
|
||||
|
||||
def _parse_comma_separated_ids(raw_ids: str | None, *, param_name: str) -> list[str] | None:
|
||||
"""Keep an omitted scope unset while rejecting an explicitly empty scope."""
|
||||
if raw_ids is None:
|
||||
return None
|
||||
parsed = sorted({raw_id.strip() for raw_id in raw_ids.split(",") if raw_id.strip()})
|
||||
@@ -74,6 +78,27 @@ def _parse_comma_separated_ids(raw_ids: str | None, *, param_name: str) -> list[
|
||||
return parsed
|
||||
|
||||
|
||||
def _parse_archive_target_month(target_month: str) -> tuple[int, int]:
|
||||
"""Validate the V2 catalog month selector and return its numeric components."""
|
||||
if not _TARGET_MONTH_PATTERN.fullmatch(target_month):
|
||||
raise click.BadParameter("target-month must use YYYY-MM format", param_hint="--target-month")
|
||||
year_text, month_text = target_month.split("-", maxsplit=1)
|
||||
return int(year_text), int(month_text)
|
||||
|
||||
|
||||
def _parse_archive_catalog_cursor(after_catalog_id: str | None) -> str | None:
|
||||
"""Normalize the exclusive V2 catalog keyset cursor when one is provided."""
|
||||
if after_catalog_id is None:
|
||||
return None
|
||||
try:
|
||||
return str(uuid.UUID(after_catalog_id))
|
||||
except ValueError as exc:
|
||||
raise click.BadParameter(
|
||||
"after-catalog-id must be a UUID returned by the same V2 operation and scope",
|
||||
param_hint="--after-catalog-id",
|
||||
) from exc
|
||||
|
||||
|
||||
def _get_archive_candidate_tenant_ids_by_prefix(
|
||||
session: Session,
|
||||
prefix: str,
|
||||
@@ -810,9 +835,11 @@ def backfill_workflow_run_archive_bundles(
|
||||
click.echo(click.style(f" ... and {len(summary.errors) - 10} more failures", fg="red"))
|
||||
|
||||
|
||||
def _echo_bundle_archive_operation_summary(summary) -> None:
|
||||
def _echo_bundle_archive_operation_summary(summary, *, dry_run: bool) -> None:
|
||||
status = "completed successfully" if summary.bundles_failed == 0 else "completed with failures"
|
||||
fg = "green" if summary.bundles_failed == 0 else "red"
|
||||
cursor_label = "preview_next_catalog_id" if dry_run else "next_catalog_id"
|
||||
cursor_value = summary.preview_next_catalog_id if dry_run else summary.next_catalog_id
|
||||
click.echo(
|
||||
click.style(
|
||||
f"{summary.operation} {status}. "
|
||||
@@ -821,10 +848,12 @@ def _echo_bundle_archive_operation_summary(summary) -> None:
|
||||
f"archive_bytes={summary.archive_bytes} duration={summary.elapsed_time:.2f}s "
|
||||
f"validation_time={summary.validation_time:.2f}s "
|
||||
f"runs_per_second={summary.runs_per_second:.2f} rows_per_second={summary.rows_per_second:.2f} "
|
||||
f"bytes_per_second={summary.bytes_per_second:.2f}",
|
||||
f"bytes_per_second={summary.bytes_per_second:.2f} {cursor_label}={cursor_value or 'none'}",
|
||||
fg=fg,
|
||||
)
|
||||
)
|
||||
if dry_run:
|
||||
click.echo(click.style("Dry-run cursor is preview-only; do not persist it for a destructive run.", fg="yellow"))
|
||||
click.echo(click.style("table,row_count", fg="white"))
|
||||
for table_name in [
|
||||
"workflow_runs",
|
||||
@@ -842,7 +871,8 @@ def _echo_bundle_archive_operation_summary(summary) -> None:
|
||||
click.style(
|
||||
f" bundle={result.bundle_id} tenant={result.tenant_id} runs={result.run_count} "
|
||||
f"rows={result.row_count} archive_bytes={result.archive_bytes} "
|
||||
f"time={result.elapsed_time:.2f}s validation={result.validation_time:.2f}s",
|
||||
f"catalog_id={result.catalog_id} time={result.elapsed_time:.2f}s "
|
||||
f"validation={result.validation_time:.2f}s",
|
||||
fg="white",
|
||||
)
|
||||
)
|
||||
@@ -850,7 +880,7 @@ def _echo_bundle_archive_operation_summary(summary) -> None:
|
||||
click.echo(
|
||||
click.style(
|
||||
f" failed bundle={result.bundle_id} tenant={result.tenant_id} "
|
||||
f"object_prefix={result.object_prefix} error={result.error}",
|
||||
f"catalog_id={result.catalog_id} object_prefix={result.object_prefix} error={result.error}",
|
||||
fg="red",
|
||||
)
|
||||
)
|
||||
@@ -867,25 +897,24 @@ def _echo_bundle_archive_operation_summary(summary) -> None:
|
||||
)
|
||||
@click.option("--run-id", required=False, help="Workflow run ID to restore.")
|
||||
@click.option(
|
||||
"--start-from",
|
||||
type=click.DateTime(formats=["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"]),
|
||||
"--target-month",
|
||||
metavar="YYYY-MM",
|
||||
default=None,
|
||||
help="Optional lower bound (inclusive) for created_at; must be paired with --end-before.",
|
||||
help="V2 catalog month to restore; required unless --run-id is used.",
|
||||
)
|
||||
@click.option(
|
||||
"--end-before",
|
||||
type=click.DateTime(formats=["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"]),
|
||||
"--after-catalog-id",
|
||||
default=None,
|
||||
help="Optional upper bound (exclusive) for created_at; must be paired with --start-from.",
|
||||
help="Exclusive V2 cursor from the same restore month and tenant scope.",
|
||||
)
|
||||
@click.option("--workers", default=1, show_default=True, type=int, help="V1 --run-id compatibility only.")
|
||||
@click.option("--limit", type=int, default=100, show_default=True, help="Maximum number of V2 bundles to restore.")
|
||||
@click.option("--limit", type=click.IntRange(min=1), default=100, show_default=True, help="Maximum V2 catalog rows.")
|
||||
@click.option("--dry-run", is_flag=True, help="Preview without restoring.")
|
||||
def restore_workflow_runs(
|
||||
tenant_ids: str | None,
|
||||
run_id: str | None,
|
||||
start_from: datetime.datetime | None,
|
||||
end_before: datetime.datetime | None,
|
||||
target_month: str | None,
|
||||
after_catalog_id: str | None,
|
||||
workers: int,
|
||||
limit: int,
|
||||
dry_run: bool,
|
||||
@@ -905,23 +934,20 @@ def restore_workflow_runs(
|
||||
from services.retention.workflow_run.bundle_archive_maintenance import WorkflowRunBundleArchiveMaintenance
|
||||
from services.retention.workflow_run.restore_archived_workflow_run import WorkflowRunRestore
|
||||
|
||||
parsed_tenant_ids = None
|
||||
if tenant_ids:
|
||||
parsed_tenant_ids = [tid.strip() for tid in tenant_ids.split(",") if tid.strip()]
|
||||
if not parsed_tenant_ids:
|
||||
raise click.BadParameter("tenant-ids must not be empty")
|
||||
parsed_tenant_ids = _parse_comma_separated_ids(tenant_ids, param_name="tenant-ids")
|
||||
|
||||
if (start_from is None) ^ (end_before is None):
|
||||
raise click.UsageError("--start-from and --end-before must be provided together.")
|
||||
if run_id is None and (start_from is None or end_before is None):
|
||||
raise click.UsageError("--start-from and --end-before are required for batch restore.")
|
||||
if workers < 1:
|
||||
raise click.BadParameter("workers must be at least 1")
|
||||
if run_id is not None and (target_month is not None or after_catalog_id is not None):
|
||||
raise click.UsageError("--target-month and --after-catalog-id are only valid for V2 batch restore.")
|
||||
if run_id is None and target_month is None:
|
||||
raise click.UsageError("--target-month is required for V2 batch restore.")
|
||||
|
||||
start_time = datetime.datetime.now(datetime.UTC)
|
||||
target_desc = f"workflow run {run_id}" if run_id else f"workflow archive catalog month {target_month}"
|
||||
click.echo(
|
||||
click.style(
|
||||
f"Starting restore of workflow run {run_id} at {start_time.isoformat()}.",
|
||||
f"Starting restore of {target_desc} at {start_time.isoformat()}.",
|
||||
fg="white",
|
||||
)
|
||||
)
|
||||
@@ -955,17 +981,20 @@ def restore_workflow_runs(
|
||||
click.echo(
|
||||
click.style("--workers is ignored for V2 bundle restore; bundles are processed serially.", fg="yellow")
|
||||
)
|
||||
assert start_from is not None
|
||||
assert end_before is not None
|
||||
assert target_month is not None
|
||||
target_year, target_month_number = _parse_archive_target_month(target_month)
|
||||
catalog_cursor = _parse_archive_catalog_cursor(after_catalog_id)
|
||||
bundle_restorer = WorkflowRunBundleArchiveMaintenance(dry_run=dry_run, strict_content_validation=True)
|
||||
summary = bundle_restorer.restore_batch(
|
||||
tenant_ids=parsed_tenant_ids,
|
||||
start_date=start_from,
|
||||
end_date=end_before,
|
||||
target_year=target_year,
|
||||
target_month=target_month_number,
|
||||
after_catalog_id=catalog_cursor,
|
||||
limit=limit,
|
||||
)
|
||||
_echo_bundle_archive_operation_summary(summary)
|
||||
return
|
||||
_echo_bundle_archive_operation_summary(summary, dry_run=dry_run)
|
||||
if summary.bundles_failed:
|
||||
raise click.exceptions.Exit(1)
|
||||
|
||||
|
||||
@click.command(
|
||||
@@ -979,23 +1008,41 @@ def restore_workflow_runs(
|
||||
)
|
||||
@click.option("--run-id", required=False, help="Workflow run ID to delete.")
|
||||
@click.option(
|
||||
"--start-from",
|
||||
type=click.DateTime(formats=["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"]),
|
||||
"--target-month",
|
||||
metavar="YYYY-MM",
|
||||
default=None,
|
||||
help="Optional lower bound (inclusive) for created_at; must be paired with --end-before.",
|
||||
help="V2 catalog month to delete; required unless --run-id is used.",
|
||||
)
|
||||
@click.option(
|
||||
"--end-before",
|
||||
type=click.DateTime(formats=["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"]),
|
||||
"--after-catalog-id",
|
||||
default=None,
|
||||
help="Optional upper bound (exclusive) for created_at; must be paired with --start-from.",
|
||||
help="Exclusive V2 cursor from the same delete month and tenant scope.",
|
||||
)
|
||||
@click.option(
|
||||
"--run-shard-index",
|
||||
default=None,
|
||||
type=click.IntRange(min=0),
|
||||
help="Zero-based archive shard index. Must be paired with --run-shard-total.",
|
||||
)
|
||||
@click.option(
|
||||
"--run-shard-total",
|
||||
default=None,
|
||||
type=click.IntRange(min=1, max=16),
|
||||
help="Total archive shard count. Must be paired with --run-shard-index.",
|
||||
)
|
||||
@click.option("--all-pages", is_flag=True, help="Process catalog pages until an empty page is reached.")
|
||||
@click.option(
|
||||
"--limit",
|
||||
type=click.IntRange(min=1),
|
||||
default=100,
|
||||
show_default=True,
|
||||
help="Maximum V2 catalog rows per page.",
|
||||
)
|
||||
@click.option("--limit", type=int, default=100, show_default=True, help="Maximum number of V2 bundles to delete.")
|
||||
@click.option("--dry-run", is_flag=True, help="Preview without deleting.")
|
||||
@click.option(
|
||||
"--skip-bad-archives",
|
||||
is_flag=True,
|
||||
help="Continue batch deletion when one archive object fails validation.",
|
||||
help="V1 --run-id only: continue when one archive object fails validation.",
|
||||
)
|
||||
@click.option(
|
||||
"--restore-sample-interval",
|
||||
@@ -1007,8 +1054,11 @@ def restore_workflow_runs(
|
||||
def delete_archived_workflow_runs(
|
||||
tenant_ids: str | None,
|
||||
run_id: str | None,
|
||||
start_from: datetime.datetime | None,
|
||||
end_before: datetime.datetime | None,
|
||||
target_month: str | None,
|
||||
after_catalog_id: str | None,
|
||||
run_shard_index: int | None,
|
||||
run_shard_total: int | None,
|
||||
all_pages: bool,
|
||||
limit: int,
|
||||
dry_run: bool,
|
||||
skip_bad_archives: bool,
|
||||
@@ -1018,26 +1068,38 @@ def delete_archived_workflow_runs(
|
||||
Delete archived workflow runs from the database.
|
||||
|
||||
Batch delete uses V2 bundle metadata and validates object existence, manifest schema, object size, checksum, row
|
||||
counts, and source/archive content checksums before deleting source rows. `--run-id` keeps the V1 per-run path.
|
||||
counts, and source/archive content checksums before deleting source rows. Parallel workers may select one exact
|
||||
archive shard; all-pages mode keeps only the current bounded page in memory. `--run-id` keeps the V1 per-run path.
|
||||
"""
|
||||
from services.retention.workflow_run.bundle_archive_maintenance import WorkflowRunBundleArchiveMaintenance
|
||||
from services.retention.workflow_run.delete_archived_workflow_run import ArchivedWorkflowRunDeletion
|
||||
|
||||
parsed_tenant_ids = None
|
||||
if tenant_ids:
|
||||
parsed_tenant_ids = [tid.strip() for tid in tenant_ids.split(",") if tid.strip()]
|
||||
if not parsed_tenant_ids:
|
||||
raise click.BadParameter("tenant-ids must not be empty")
|
||||
parsed_tenant_ids = _parse_comma_separated_ids(tenant_ids, param_name="tenant-ids")
|
||||
|
||||
if (start_from is None) ^ (end_before is None):
|
||||
raise click.UsageError("--start-from and --end-before must be provided together.")
|
||||
if run_id is None and (start_from is None or end_before is None):
|
||||
raise click.UsageError("--start-from and --end-before are required for batch delete.")
|
||||
if restore_sample_interval < 0:
|
||||
raise click.BadParameter("restore-sample-interval must be >= 0")
|
||||
if run_id is not None and (
|
||||
target_month is not None
|
||||
or after_catalog_id is not None
|
||||
or run_shard_index is not None
|
||||
or run_shard_total is not None
|
||||
or all_pages
|
||||
):
|
||||
raise click.UsageError(
|
||||
"--target-month, --after-catalog-id, --run-shard-index, --run-shard-total, and --all-pages "
|
||||
"are only valid for V2 batch delete."
|
||||
)
|
||||
if run_id is None and target_month is None:
|
||||
raise click.UsageError("--target-month is required for V2 batch delete.")
|
||||
if run_id is None and skip_bad_archives:
|
||||
raise click.UsageError("--skip-bad-archives is not supported for V2 catalog batches; they fail fast.")
|
||||
if (run_shard_index is None) ^ (run_shard_total is None):
|
||||
raise click.UsageError("--run-shard-index and --run-shard-total must be provided together.")
|
||||
if run_shard_index is not None and run_shard_total is not None and run_shard_index >= run_shard_total:
|
||||
raise click.UsageError("--run-shard-index must be less than --run-shard-total.")
|
||||
|
||||
start_time = datetime.datetime.now(datetime.UTC)
|
||||
target_desc = f"workflow run {run_id}" if run_id else "workflow runs"
|
||||
target_desc = f"workflow run {run_id}" if run_id else f"workflow archive catalog month {target_month}"
|
||||
click.echo(
|
||||
click.style(
|
||||
f"Starting delete of {target_desc} at {start_time.isoformat()}.",
|
||||
@@ -1110,20 +1172,104 @@ def delete_archived_workflow_runs(
|
||||
|
||||
if restore_sample_interval:
|
||||
click.echo(click.style("--restore-sample-interval is ignored for V2 bundle delete.", fg="yellow"))
|
||||
assert start_from is not None
|
||||
assert end_before is not None
|
||||
bundle_deleter = WorkflowRunBundleArchiveMaintenance(
|
||||
dry_run=dry_run,
|
||||
strict_content_validation=True,
|
||||
stop_on_error=not skip_bad_archives,
|
||||
assert target_month is not None
|
||||
target_year, target_month_number = _parse_archive_target_month(target_month)
|
||||
catalog_cursor = _parse_archive_catalog_cursor(after_catalog_id)
|
||||
shard = (
|
||||
f"{run_shard_index:02d}-of-{run_shard_total:02d}"
|
||||
if run_shard_index is not None and run_shard_total is not None
|
||||
else None
|
||||
)
|
||||
summary = bundle_deleter.delete_batch(
|
||||
tenant_ids=parsed_tenant_ids,
|
||||
start_date=start_from,
|
||||
end_date=end_before,
|
||||
limit=limit,
|
||||
)
|
||||
_echo_bundle_archive_operation_summary(summary)
|
||||
bundle_deleter = WorkflowRunBundleArchiveMaintenance(dry_run=dry_run, strict_content_validation=True)
|
||||
if run_shard_total is not None:
|
||||
try:
|
||||
bundle_deleter.validate_catalog_shards(
|
||||
target_year=target_year,
|
||||
target_month=target_month_number,
|
||||
shard_total=run_shard_total,
|
||||
tenant_ids=parsed_tenant_ids,
|
||||
)
|
||||
except ValueError as exc:
|
||||
logger.exception(
|
||||
"Archive catalog shard preflight failed: target_month=%s shard=%s",
|
||||
target_month,
|
||||
shard,
|
||||
)
|
||||
raise click.ClickException(
|
||||
f"Archive catalog shard preflight failed for target_month={target_month} shard={shard}: {exc}"
|
||||
) from exc
|
||||
|
||||
initial_catalog_cursor = catalog_cursor
|
||||
pages_processed = 0
|
||||
bundles_succeeded = 0
|
||||
runs_processed = 0
|
||||
rows_processed = 0
|
||||
archive_bytes = 0
|
||||
while True:
|
||||
summary = bundle_deleter.delete_batch(
|
||||
tenant_ids=parsed_tenant_ids,
|
||||
target_year=target_year,
|
||||
target_month=target_month_number,
|
||||
after_catalog_id=catalog_cursor,
|
||||
limit=limit,
|
||||
shard=shard,
|
||||
)
|
||||
_echo_bundle_archive_operation_summary(summary, dry_run=dry_run)
|
||||
if summary.bundles_failed:
|
||||
failed_result = next((result for result in summary.results if not result.success), None)
|
||||
failed_catalog_id = failed_result.catalog_id if failed_result is not None else "unknown"
|
||||
page_resume_cursor = summary.preview_next_catalog_id if dry_run else summary.next_catalog_id
|
||||
resume_cursor = page_resume_cursor or catalog_cursor
|
||||
if dry_run:
|
||||
cursor_details = (
|
||||
f"preview_after_catalog_id={resume_cursor or 'none'} "
|
||||
f"destructive_retry_after_catalog_id={initial_catalog_cursor or 'none'}"
|
||||
)
|
||||
else:
|
||||
cursor_details = f"resume_after_catalog_id={resume_cursor or 'none'}"
|
||||
click.echo(
|
||||
click.style(
|
||||
f"Delete stopped: target_month={target_month} shard={shard or 'all'} "
|
||||
f"failed_catalog_id={failed_catalog_id} "
|
||||
f"{cursor_details}",
|
||||
fg="red",
|
||||
)
|
||||
)
|
||||
raise click.exceptions.Exit(1)
|
||||
|
||||
if not all_pages:
|
||||
break
|
||||
if summary.bundles_processed == 0:
|
||||
break
|
||||
|
||||
pages_processed += 1
|
||||
bundles_succeeded += summary.bundles_succeeded
|
||||
runs_processed += summary.runs_processed
|
||||
rows_processed += summary.rows_processed
|
||||
archive_bytes += summary.archive_bytes
|
||||
next_catalog_id = summary.preview_next_catalog_id if dry_run else summary.next_catalog_id
|
||||
if next_catalog_id is None or (catalog_cursor is not None and next_catalog_id <= catalog_cursor):
|
||||
click.echo(
|
||||
click.style(
|
||||
f"Delete cursor did not advance: target_month={target_month} shard={shard or 'all'} "
|
||||
f"after_catalog_id={catalog_cursor or 'none'} next_catalog_id={next_catalog_id or 'none'}",
|
||||
fg="red",
|
||||
)
|
||||
)
|
||||
raise click.exceptions.Exit(1)
|
||||
catalog_cursor = next_catalog_id
|
||||
|
||||
if all_pages:
|
||||
final_cursor_label = "preview_final_catalog_id" if dry_run else "final_catalog_id"
|
||||
click.echo(
|
||||
click.style(
|
||||
f"Delete all-pages completed successfully. target_month={target_month} shard={shard or 'all'} "
|
||||
f"pages={pages_processed} bundles_success={bundles_succeeded} runs={runs_processed} "
|
||||
f"rows={rows_processed} archive_bytes={archive_bytes} "
|
||||
f"{final_cursor_label}={catalog_cursor or 'none'}",
|
||||
fg="green",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _find_orphaned_draft_variables(batch_size: int = 1000) -> list[str]:
|
||||
|
||||
+33
-12
@@ -16,12 +16,19 @@ from urllib.parse import quote
|
||||
import boto3
|
||||
import orjson
|
||||
from botocore.client import Config
|
||||
from botocore.exceptions import ClientError
|
||||
from botocore.exceptions import BotoCoreError, ClientError
|
||||
|
||||
from configs import dify_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_OBJECT_NOT_FOUND_ERROR_CODES = frozenset({"404", "NoSuchKey", "NotFound"})
|
||||
|
||||
|
||||
def _is_object_not_found_error(error: ClientError) -> bool:
|
||||
error_code = str(error.response.get("Error", {}).get("Code", ""))
|
||||
return error_code in _OBJECT_NOT_FOUND_ERROR_CODES
|
||||
|
||||
|
||||
class ArchiveStorageError(Exception):
|
||||
"""Base exception for archive storage operations."""
|
||||
@@ -138,10 +145,11 @@ class ArchiveStorage:
|
||||
response = self.client.get_object(Bucket=self.bucket, Key=key)
|
||||
return response["Body"].read()
|
||||
except ClientError as e:
|
||||
error_code = e.response.get("Error", {}).get("Code")
|
||||
if error_code == "NoSuchKey":
|
||||
raise FileNotFoundError(f"Archive object not found: {key}")
|
||||
raise ArchiveStorageError(f"Failed to download object '{key}': {e}")
|
||||
if _is_object_not_found_error(e):
|
||||
raise FileNotFoundError(f"Archive object not found: {key}") from e
|
||||
raise ArchiveStorageError(f"Failed to download object '{key}': {e}") from e
|
||||
except BotoCoreError as e:
|
||||
raise ArchiveStorageError(f"Failed to download object '{key}': {e}") from e
|
||||
|
||||
def get_object_stream(self, key: str) -> Generator[bytes, None, None]:
|
||||
"""
|
||||
@@ -161,10 +169,11 @@ class ArchiveStorage:
|
||||
response = self.client.get_object(Bucket=self.bucket, Key=key)
|
||||
yield from response["Body"].iter_chunks()
|
||||
except ClientError as e:
|
||||
error_code = e.response.get("Error", {}).get("Code")
|
||||
if error_code == "NoSuchKey":
|
||||
raise FileNotFoundError(f"Archive object not found: {key}")
|
||||
raise ArchiveStorageError(f"Failed to stream object '{key}': {e}")
|
||||
if _is_object_not_found_error(e):
|
||||
raise FileNotFoundError(f"Archive object not found: {key}") from e
|
||||
raise ArchiveStorageError(f"Failed to stream object '{key}': {e}") from e
|
||||
except BotoCoreError as e:
|
||||
raise ArchiveStorageError(f"Failed to stream object '{key}': {e}") from e
|
||||
|
||||
def object_exists(self, key: str) -> bool:
|
||||
"""
|
||||
@@ -175,12 +184,19 @@ class ArchiveStorage:
|
||||
|
||||
Returns:
|
||||
True if object exists, False otherwise
|
||||
|
||||
Raises:
|
||||
ArchiveStorageError: If storage cannot authoritatively determine object existence
|
||||
"""
|
||||
try:
|
||||
self.client.head_object(Bucket=self.bucket, Key=key)
|
||||
return True
|
||||
except ClientError:
|
||||
return False
|
||||
except ClientError as e:
|
||||
if _is_object_not_found_error(e):
|
||||
return False
|
||||
raise ArchiveStorageError(f"Failed to check archive object '{key}': {e}") from e
|
||||
except BotoCoreError as e:
|
||||
raise ArchiveStorageError(f"Failed to check archive object '{key}': {e}") from e
|
||||
|
||||
def delete_object(self, key: str) -> None:
|
||||
"""
|
||||
@@ -196,7 +212,12 @@ class ArchiveStorage:
|
||||
self.client.delete_object(Bucket=self.bucket, Key=key)
|
||||
logger.debug("Deleted object: %s", key)
|
||||
except ClientError as e:
|
||||
raise ArchiveStorageError(f"Failed to delete object '{key}': {e}")
|
||||
if _is_object_not_found_error(e):
|
||||
logger.debug("Archive object was already absent: %s", key)
|
||||
return
|
||||
raise ArchiveStorageError(f"Failed to delete object '{key}': {e}") from e
|
||||
except BotoCoreError as e:
|
||||
raise ArchiveStorageError(f"Failed to delete object '{key}': {e}") from e
|
||||
|
||||
def generate_presigned_url(
|
||||
self,
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
"""add workflow-run archive bundle monthly cursor index
|
||||
|
||||
Revision ID: 3c9f8e2a1d7b
|
||||
Revises: 7a1c2d9e4b60
|
||||
Create Date: 2026-07-15 16:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "3c9f8e2a1d7b"
|
||||
down_revision = "7a1c2d9e4b60"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_INDEX_NAME = "workflow_run_archive_bundle_month_id_idx"
|
||||
_TABLE_NAME = "workflow_run_archive_bundles"
|
||||
|
||||
|
||||
def _is_postgresql() -> bool:
|
||||
return op.get_bind().dialect.name == "postgresql"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if _is_postgresql():
|
||||
with op.get_context().autocommit_block():
|
||||
op.create_index(
|
||||
_INDEX_NAME,
|
||||
_TABLE_NAME,
|
||||
["year", "month", "id"],
|
||||
postgresql_concurrently=True,
|
||||
)
|
||||
return
|
||||
op.create_index(_INDEX_NAME, _TABLE_NAME, ["year", "month", "id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _is_postgresql():
|
||||
with op.get_context().autocommit_block():
|
||||
op.drop_index(_INDEX_NAME, table_name=_TABLE_NAME, postgresql_concurrently=True)
|
||||
return
|
||||
op.drop_index(_INDEX_NAME, table_name=_TABLE_NAME)
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
"""add workflow-run archive bundle shard cursor index
|
||||
|
||||
Revision ID: 9b2d7e4f6a81
|
||||
Revises: 3c9f8e2a1d7b
|
||||
Create Date: 2026-07-20 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "9b2d7e4f6a81"
|
||||
down_revision = "3c9f8e2a1d7b"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_INDEX_NAME = "workflow_run_archive_bundle_month_shard_id_idx"
|
||||
_TABLE_NAME = "workflow_run_archive_bundles"
|
||||
|
||||
|
||||
def _is_postgresql() -> bool:
|
||||
return op.get_bind().dialect.name == "postgresql"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if _is_postgresql():
|
||||
with op.get_context().autocommit_block():
|
||||
op.create_index(
|
||||
_INDEX_NAME,
|
||||
_TABLE_NAME,
|
||||
["year", "month", "shard", "id"],
|
||||
postgresql_concurrently=True,
|
||||
)
|
||||
return
|
||||
op.create_index(_INDEX_NAME, _TABLE_NAME, ["year", "month", "shard", "id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _is_postgresql():
|
||||
with op.get_context().autocommit_block():
|
||||
op.drop_index(_INDEX_NAME, table_name=_TABLE_NAME, postgresql_concurrently=True)
|
||||
return
|
||||
op.drop_index(_INDEX_NAME, table_name=_TABLE_NAME)
|
||||
@@ -1476,6 +1476,8 @@ class WorkflowRunArchiveBundle(DefaultFieldsDCMixin, TypeBase):
|
||||
name="workflow_run_archive_bundle_identity_uq",
|
||||
),
|
||||
sa.Index("workflow_run_archive_bundle_tenant_month_idx", "tenant_id", "year", "month"),
|
||||
sa.Index("workflow_run_archive_bundle_month_id_idx", "year", "month", "id"),
|
||||
sa.Index("workflow_run_archive_bundle_month_shard_id_idx", "year", "month", "shard", "id"),
|
||||
)
|
||||
|
||||
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
|
||||
@@ -739,8 +739,13 @@ class DatasetService:
|
||||
dataset.id, external_knowledge_id, external_knowledge_api_id, session
|
||||
)
|
||||
|
||||
# Commit changes to database
|
||||
session.commit()
|
||||
# Flush changes to the database without closing the caller-managed
|
||||
# transaction. This helper receives a session opened by the caller
|
||||
# (`with Session(...) as session`); calling commit() here closed that
|
||||
# context manager early and raised
|
||||
# sqlalchemy.exc.InvalidRequestError: Can't operate on closed transaction
|
||||
# (#39191).
|
||||
session.flush()
|
||||
|
||||
return dataset
|
||||
|
||||
@@ -810,9 +815,15 @@ class DatasetService:
|
||||
if data.get("icon_info"):
|
||||
filtered_data["icon_info"] = data.get("icon_info")
|
||||
|
||||
# Update dataset in database
|
||||
# Update dataset in database. Use flush() rather than commit() so the
|
||||
# caller-managed transaction (opened with `with Session(...) as session`)
|
||||
# stays open for subsequent operations — _update_pipeline_knowledge_base
|
||||
# node data and any caller follow-ups run on the same session. Calling
|
||||
# commit() here closed the context manager early and raised
|
||||
# sqlalchemy.exc.InvalidRequestError: Can't operate on closed transaction
|
||||
# (#39191).
|
||||
session.execute(update(Dataset).where(Dataset.id == dataset.id).values(**filtered_data))
|
||||
session.commit()
|
||||
session.flush()
|
||||
|
||||
# Reload dataset to get updated values
|
||||
session.refresh(dataset)
|
||||
|
||||
@@ -6,7 +6,10 @@ This service archives workflow run logs for paid plan users older than the confi
|
||||
|
||||
Archive V2 writes bundle-level Parquet objects. A bundle contains many workflow runs and their related table rows.
|
||||
Bundle metadata lives in the object-store manifest as the recoverable source of truth. Completed bundles are also
|
||||
mirrored into a small database index so console listing and download jobs do not list object storage online.
|
||||
published into a small database catalog so console listing, download, and maintenance jobs do not list object storage
|
||||
online. Archive success requires that catalog publication to commit. A retry reconciles only its known manifest key
|
||||
against an already-published shard index; a missing shard index fails closed rather than rebuilding it by scanning
|
||||
historical manifests.
|
||||
|
||||
Archive campaigns should use fixed absolute UTC windows for every tenant-prefix/shard execution. Relative windows are
|
||||
evaluated at process start and are not safe for multi-day rollout because each command would scan a different window.
|
||||
@@ -639,7 +642,6 @@ class WorkflowRunArchiver:
|
||||
if storage is None:
|
||||
raise ArchiveStorageNotConfiguredError("Archive storage not configured")
|
||||
if storage.object_exists(self._get_manifest_object_key(identity)):
|
||||
self._write_bundle_index(storage, identity)
|
||||
self._sync_existing_bundle_index(session, storage, identity)
|
||||
result.success = True
|
||||
result.skipped = True
|
||||
@@ -651,6 +653,8 @@ class WorkflowRunArchiver:
|
||||
runs = [run for run in runs if run.id not in archived_run_ids]
|
||||
result.skipped_run_count = original_run_count - len(runs)
|
||||
if not runs:
|
||||
# Historical catalog rows are a rollout precondition. New bundles commit their catalog row before
|
||||
# publishing the shard index, so this idempotency path never needs to rescan prior manifests.
|
||||
result.run_count = 0
|
||||
result.success = True
|
||||
result.skipped = True
|
||||
@@ -663,7 +667,6 @@ class WorkflowRunArchiver:
|
||||
result.object_prefix = identity.object_prefix
|
||||
result.run_count = len(runs)
|
||||
if storage.object_exists(self._get_manifest_object_key(identity)):
|
||||
self._write_bundle_index(storage, identity)
|
||||
self._sync_existing_bundle_index(session, storage, identity)
|
||||
result.success = True
|
||||
result.skipped = True
|
||||
@@ -695,10 +698,10 @@ class WorkflowRunArchiver:
|
||||
for table_name, payload in table_payloads.items():
|
||||
storage.put_object(self._get_table_object_key(identity, table_name), payload)
|
||||
storage.put_object(self._get_manifest_object_key(identity), manifest_data)
|
||||
self._merge_bundle_manifest_into_index(storage, identity, [run.id for run in runs])
|
||||
manifest = decode_archive_bundle_manifest(manifest_data)
|
||||
upsert_archive_bundle_index_from_manifest(session, manifest, len(manifest_data))
|
||||
session.commit()
|
||||
self._merge_bundle_manifest_into_index(storage, identity, [run.id for run in runs])
|
||||
|
||||
logger.info(
|
||||
"Archived workflow run bundle %s: tenant=%s runs=%s tables=%s object_prefix=%s",
|
||||
@@ -730,16 +733,18 @@ class WorkflowRunArchiver:
|
||||
storage: ArchiveStorage,
|
||||
identity: ArchiveBundleIdentity,
|
||||
) -> None:
|
||||
"""Best-effort DB index sync for a bundle whose manifest already exists in archive storage."""
|
||||
"""Publish a known manifest to the DB catalog and reconcile its existing shard index."""
|
||||
manifest_key = self._get_manifest_object_key(identity)
|
||||
try:
|
||||
manifest_data = storage.get_object(manifest_key)
|
||||
manifest = decode_archive_bundle_manifest(manifest_data)
|
||||
upsert_archive_bundle_index_from_manifest(session, manifest, len(manifest_data))
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
logger.warning("Failed to sync workflow archive bundle index for %s", manifest_key, exc_info=True)
|
||||
manifest_data = storage.get_object(manifest_key)
|
||||
manifest = decode_archive_bundle_manifest(manifest_data)
|
||||
upsert_archive_bundle_index_from_manifest(session, manifest, len(manifest_data))
|
||||
session.commit()
|
||||
self._merge_bundle_manifest_into_index(
|
||||
storage,
|
||||
identity,
|
||||
manifest["run_ids"],
|
||||
require_existing_index=True,
|
||||
)
|
||||
|
||||
def _lock_runs_for_archive(
|
||||
self,
|
||||
@@ -1029,10 +1034,20 @@ class WorkflowRunArchiver:
|
||||
storage: ArchiveStorage,
|
||||
identity: ArchiveBundleIdentity,
|
||||
run_ids: Sequence[str],
|
||||
*,
|
||||
require_existing_index: bool = False,
|
||||
) -> ArchiveBundleIndexDict:
|
||||
"""
|
||||
Merge one bundle into its shard index.
|
||||
|
||||
Retries for a known manifest set ``require_existing_index`` so they never create a partial index by scanning
|
||||
or overwriting a shard whose historical entries cannot be proven from this one manifest.
|
||||
"""
|
||||
index_key = self._get_index_object_key(identity)
|
||||
if storage.object_exists(index_key):
|
||||
index = self._load_bundle_index(storage, identity)
|
||||
elif require_existing_index:
|
||||
raise RuntimeError(f"archive shard index missing while reconciling known manifest: {index_key}")
|
||||
else:
|
||||
index = self._build_bundle_index(storage, identity)
|
||||
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
"""
|
||||
Maintain V2 workflow-run archive bundles.
|
||||
|
||||
Archive V2 keeps object-store manifests as the recoverable bundle source of truth. This maintenance module still
|
||||
discovers delete/restore targets by listing `manifest.json` objects and uses object-store marker files for
|
||||
delete/restore state. The separate database bundle index is intended for console listing and download jobs, not as the
|
||||
source of truth for destructive maintenance.
|
||||
Archive V2 keeps object-store manifests as the recoverable bundle source of truth. Delete and restore discover a
|
||||
bounded page of candidates from `workflow_run_archive_bundles`, optionally restricted to one exact archive shard, then
|
||||
construct each immutable manifest key from the catalog identity. They never list the object-store namespace.
|
||||
Object-store marker files keep delete/restore idempotent, while the caller persists a non-dry-run cursor only after a
|
||||
candidate succeeds.
|
||||
|
||||
Each bundle is processed in its own database transaction. A failed bundle leaves source rows unchanged unless the
|
||||
transaction has already committed; marker handling makes the next run able to reconcile the common committed-but-marker
|
||||
not-updated case.
|
||||
not-updated case. Restore never skips a bundle with a missing deleted marker when deletion started or source rows have
|
||||
drifted, so an external cursor cannot pass an interrupted delete.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
@@ -38,6 +40,7 @@ from models.workflow import (
|
||||
WorkflowPause,
|
||||
WorkflowPauseReason,
|
||||
WorkflowRun,
|
||||
WorkflowRunArchiveBundle,
|
||||
)
|
||||
from services.retention.workflow_run.constants import (
|
||||
ARCHIVE_BUNDLE_DELETE_STARTED_MARKER_NAME,
|
||||
@@ -51,7 +54,6 @@ from services.retention.workflow_run.constants import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ARCHIVE_ROOT_PREFIX = "workflow-runs/v2/"
|
||||
_CHUNK_SIZE = 5_000
|
||||
|
||||
|
||||
@@ -84,11 +86,28 @@ class BundleManifest(TypedDict):
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BundleReference:
|
||||
"""Object-store reference for one V2 archive bundle."""
|
||||
class ArchiveBundleCatalogEntry:
|
||||
"""Immutable catalog identity and manifest-derived metrics for one V2 archive bundle."""
|
||||
|
||||
catalog_id: str
|
||||
tenant_id: str
|
||||
year: int
|
||||
month: int
|
||||
shard: str
|
||||
bundle_id: str
|
||||
workflow_run_count: int
|
||||
row_count: int
|
||||
archive_bytes: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BundleReference:
|
||||
"""Verified object-store reference for one catalog candidate."""
|
||||
|
||||
catalog: ArchiveBundleCatalogEntry
|
||||
object_prefix: str
|
||||
manifest_key: str
|
||||
manifest_size_bytes: int
|
||||
manifest: BundleManifest
|
||||
|
||||
|
||||
@@ -96,6 +115,7 @@ class BundleReference:
|
||||
class BundleOperationResult:
|
||||
"""Result for one V2 bundle delete or restore operation."""
|
||||
|
||||
catalog_id: str
|
||||
bundle_id: str
|
||||
tenant_id: str
|
||||
object_prefix: str
|
||||
@@ -128,6 +148,8 @@ class BundleOperationSummary:
|
||||
archive_bytes: int = 0
|
||||
elapsed_time: float = 0.0
|
||||
validation_time: float = 0.0
|
||||
next_catalog_id: str | None = None
|
||||
preview_next_catalog_id: str | None = None
|
||||
table_counts: dict[str, int] = field(default_factory=dict)
|
||||
results: list[BundleOperationResult] = field(default_factory=list)
|
||||
|
||||
@@ -180,162 +202,334 @@ RESTORE_ORDER = [
|
||||
"workflow_trigger_logs",
|
||||
]
|
||||
|
||||
DELETE_ORDER = [
|
||||
"workflow_pause_reasons",
|
||||
"workflow_node_execution_offload",
|
||||
"workflow_trigger_logs",
|
||||
"workflow_app_logs",
|
||||
"workflow_node_executions",
|
||||
"workflow_pauses",
|
||||
"workflow_runs",
|
||||
]
|
||||
|
||||
|
||||
class WorkflowRunBundleArchiveMaintenance:
|
||||
"""
|
||||
Delete and restore V2 workflow-run archive bundles.
|
||||
|
||||
Delete accepts already-missing source rows only when every remaining row in the bundle scope is an unchanged
|
||||
archive subset. It then removes only those verified primary keys, so unrelated or unarchived rows fail closed.
|
||||
Non-dry-run delete and restore serialize on the existing archive catalog row before checking markers or changing
|
||||
source rows.
|
||||
|
||||
Args:
|
||||
dry_run: Validate and report counts without changing source rows or object-store markers.
|
||||
strict_content_validation: Compare source-table content checksums against Parquet content before destructive
|
||||
delete and after restore. Keep enabled for real maintenance.
|
||||
stop_on_error: Stop batch processing after the first failed bundle.
|
||||
strict_content_validation: Compare restored source-table content checksums against Parquet content. Delete
|
||||
always validates that every remaining live row belongs to and matches the archive before removing it.
|
||||
storage: Optional archive storage implementation. Tests may provide an in-memory implementation.
|
||||
session_factory: Optional session factory. Each candidate is processed in its own transaction.
|
||||
|
||||
Batches stop at the first error so a returned cursor cannot pass an unhandled candidate.
|
||||
"""
|
||||
|
||||
dry_run: bool
|
||||
strict_content_validation: bool
|
||||
stop_on_error: bool
|
||||
storage: ArchiveStorage | None
|
||||
session_factory: sessionmaker[Session]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
dry_run: bool = False,
|
||||
strict_content_validation: bool = True,
|
||||
stop_on_error: bool = True,
|
||||
storage: ArchiveStorage | None = None,
|
||||
session_factory: sessionmaker[Session] | None = None,
|
||||
) -> None:
|
||||
self.dry_run = dry_run
|
||||
self.strict_content_validation = strict_content_validation
|
||||
self.stop_on_error = stop_on_error
|
||||
self.storage = storage
|
||||
self.session_factory = session_factory or sessionmaker(bind=db.engine, expire_on_commit=False)
|
||||
|
||||
def delete_batch(
|
||||
self,
|
||||
*,
|
||||
tenant_ids: Sequence[str] | None,
|
||||
start_date: datetime.datetime,
|
||||
end_date: datetime.datetime,
|
||||
target_year: int,
|
||||
target_month: int,
|
||||
after_catalog_id: str | None,
|
||||
limit: int,
|
||||
shard: str | None = None,
|
||||
) -> BundleOperationSummary:
|
||||
"""Validate and delete source rows for archived V2 bundles in the requested created_at window."""
|
||||
"""Validate and delete one keyset page, optionally scoped to an exact archive shard."""
|
||||
return self._process_batch(
|
||||
operation="delete",
|
||||
tenant_ids=tenant_ids,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
target_year=target_year,
|
||||
target_month=target_month,
|
||||
after_catalog_id=after_catalog_id,
|
||||
limit=limit,
|
||||
shard=shard,
|
||||
)
|
||||
|
||||
def restore_batch(
|
||||
self,
|
||||
*,
|
||||
tenant_ids: Sequence[str] | None,
|
||||
start_date: datetime.datetime,
|
||||
end_date: datetime.datetime,
|
||||
target_year: int,
|
||||
target_month: int,
|
||||
after_catalog_id: str | None,
|
||||
limit: int,
|
||||
) -> BundleOperationSummary:
|
||||
"""Restore source rows for deleted V2 bundles in the requested created_at window."""
|
||||
"""Restore source rows for a keyset page of deleted V2 bundles in one calendar month."""
|
||||
return self._process_batch(
|
||||
operation="restore",
|
||||
tenant_ids=tenant_ids,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
target_year=target_year,
|
||||
target_month=target_month,
|
||||
after_catalog_id=after_catalog_id,
|
||||
limit=limit,
|
||||
shard=None,
|
||||
)
|
||||
|
||||
def validate_catalog_shards(
|
||||
self,
|
||||
*,
|
||||
target_year: int,
|
||||
target_month: int,
|
||||
shard_total: int,
|
||||
tenant_ids: Sequence[str] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Fail before a parallel delete when the requested closed-month scope contains a different shard layout.
|
||||
|
||||
A subset of the expected shards is valid because an archive shard may legitimately contain no bundles. Any
|
||||
other shard name indicates a historical or mixed-layout month that must be handled by the serial delete path.
|
||||
"""
|
||||
if not 1 <= shard_total <= 16:
|
||||
raise ValueError("shard_total must be between 1 and 16")
|
||||
expected_shards = tuple(f"{index:02d}-of-{shard_total:02d}" for index in range(shard_total))
|
||||
conditions = [
|
||||
WorkflowRunArchiveBundle.year == target_year,
|
||||
WorkflowRunArchiveBundle.month == target_month,
|
||||
]
|
||||
if tenant_ids is not None:
|
||||
conditions.append(WorkflowRunArchiveBundle.tenant_id.in_(tenant_ids))
|
||||
statement = (
|
||||
select(WorkflowRunArchiveBundle.shard)
|
||||
.where(
|
||||
*conditions,
|
||||
WorkflowRunArchiveBundle.shard.not_in(expected_shards),
|
||||
)
|
||||
.distinct()
|
||||
.order_by(WorkflowRunArchiveBundle.shard.asc())
|
||||
)
|
||||
with self.session_factory() as session:
|
||||
unexpected_shards = list(session.scalars(statement))
|
||||
if unexpected_shards:
|
||||
raise ValueError(
|
||||
"archive catalog month contains unexpected shards for "
|
||||
f"{shard_total}-way delete: {', '.join(unexpected_shards)}"
|
||||
)
|
||||
|
||||
def _process_batch(
|
||||
self,
|
||||
*,
|
||||
operation: str,
|
||||
tenant_ids: Sequence[str] | None,
|
||||
start_date: datetime.datetime,
|
||||
end_date: datetime.datetime,
|
||||
target_year: int,
|
||||
target_month: int,
|
||||
after_catalog_id: str | None,
|
||||
limit: int,
|
||||
shard: str | None,
|
||||
) -> BundleOperationSummary:
|
||||
start_time = time.time()
|
||||
summary = BundleOperationSummary(operation=operation)
|
||||
if tenant_ids is not None and not tenant_ids:
|
||||
return summary
|
||||
|
||||
storage = self._get_archive_storage()
|
||||
bundle_refs = self._list_bundle_refs(
|
||||
storage,
|
||||
operation=operation,
|
||||
storage = self.storage or self._get_archive_storage()
|
||||
catalog_entries = self._list_catalog_entries(
|
||||
tenant_ids=tenant_ids,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
target_year=target_year,
|
||||
target_month=target_month,
|
||||
after_catalog_id=after_catalog_id,
|
||||
limit=limit,
|
||||
shard=shard,
|
||||
)
|
||||
|
||||
logger.info("Found %s V2 archive bundles for %s", len(bundle_refs), operation)
|
||||
session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
|
||||
for bundle_ref in bundle_refs:
|
||||
with session_maker() as session:
|
||||
if operation == "delete":
|
||||
result = self._delete_bundle(session, storage, bundle_ref)
|
||||
elif operation == "restore":
|
||||
result = self._restore_bundle(session, storage, bundle_ref)
|
||||
else:
|
||||
raise ValueError(f"Unsupported operation: {operation}")
|
||||
logger.info(
|
||||
"Found %s V2 archive catalog candidates for %s: year=%s month=%s shard=%s after_catalog_id=%s",
|
||||
len(catalog_entries),
|
||||
operation,
|
||||
target_year,
|
||||
target_month,
|
||||
shard,
|
||||
after_catalog_id,
|
||||
)
|
||||
for catalog_entry in catalog_entries:
|
||||
try:
|
||||
bundle_ref = self._build_bundle_reference(storage, catalog_entry)
|
||||
with self.session_factory() as session:
|
||||
if operation == "delete":
|
||||
result = self._delete_bundle(session, storage, bundle_ref)
|
||||
elif operation == "restore":
|
||||
result = self._restore_bundle(session, storage, bundle_ref)
|
||||
else:
|
||||
raise ValueError(f"Unsupported operation: {operation}")
|
||||
except Exception as exc:
|
||||
result = self._new_result_from_catalog_entry(catalog_entry)
|
||||
result.error = str(exc)
|
||||
logger.exception(
|
||||
"Failed to prepare V2 archive bundle %s from catalog %s",
|
||||
catalog_entry.bundle_id,
|
||||
catalog_entry.catalog_id,
|
||||
)
|
||||
|
||||
self._merge_result(summary, result)
|
||||
if not result.success and self.stop_on_error:
|
||||
if result.success:
|
||||
if self.dry_run:
|
||||
summary.preview_next_catalog_id = catalog_entry.catalog_id
|
||||
else:
|
||||
summary.next_catalog_id = catalog_entry.catalog_id
|
||||
else:
|
||||
logger.error("Stopping V2 bundle %s after failure: %s", operation, result.error)
|
||||
break
|
||||
|
||||
summary.elapsed_time = time.time() - start_time
|
||||
return summary
|
||||
|
||||
def _list_bundle_refs(
|
||||
def _list_catalog_entries(
|
||||
self,
|
||||
storage: ArchiveStorage,
|
||||
*,
|
||||
operation: str,
|
||||
tenant_ids: Sequence[str] | None,
|
||||
start_date: datetime.datetime,
|
||||
end_date: datetime.datetime,
|
||||
target_year: int,
|
||||
target_month: int,
|
||||
after_catalog_id: str | None,
|
||||
limit: int,
|
||||
) -> list[BundleReference]:
|
||||
start_date = self._to_naive_utc(start_date)
|
||||
end_date = self._to_naive_utc(end_date)
|
||||
manifest_keys = self._list_manifest_keys(storage, tenant_ids)
|
||||
refs: list[BundleReference] = []
|
||||
for manifest_key in manifest_keys:
|
||||
manifest_data = self._get_checked_object(storage, manifest_key)
|
||||
object_prefix = manifest_key.removesuffix(f"/{ARCHIVE_BUNDLE_MANIFEST_NAME}")
|
||||
manifest = self._load_and_validate_manifest(manifest_data, object_prefix=object_prefix)
|
||||
min_created_at = self._parse_manifest_datetime(manifest["min_created_at"])
|
||||
max_created_at = self._parse_manifest_datetime(manifest["max_created_at"])
|
||||
if max_created_at < start_date or min_created_at >= end_date:
|
||||
continue
|
||||
if tenant_ids and manifest["tenant_id"] not in tenant_ids:
|
||||
continue
|
||||
if operation == "delete" and self._is_deleted(storage, object_prefix):
|
||||
continue
|
||||
if operation == "restore" and not self._is_deleted(storage, object_prefix):
|
||||
continue
|
||||
refs.append(BundleReference(object_prefix=object_prefix, manifest_key=manifest_key, manifest=manifest))
|
||||
shard: str | None = None,
|
||||
) -> list[ArchiveBundleCatalogEntry]:
|
||||
"""Read one bounded, stable-order candidate page from the database catalog."""
|
||||
conditions = [
|
||||
WorkflowRunArchiveBundle.year == target_year,
|
||||
WorkflowRunArchiveBundle.month == target_month,
|
||||
]
|
||||
if tenant_ids:
|
||||
conditions.append(WorkflowRunArchiveBundle.tenant_id.in_(tenant_ids))
|
||||
if shard is not None:
|
||||
conditions.append(WorkflowRunArchiveBundle.shard == shard)
|
||||
if after_catalog_id:
|
||||
conditions.append(WorkflowRunArchiveBundle.id > after_catalog_id)
|
||||
|
||||
refs.sort(
|
||||
key=lambda ref: (
|
||||
self._parse_manifest_datetime(ref.manifest["min_created_at"]),
|
||||
ref.manifest["tenant_id"],
|
||||
ref.manifest["bundle_id"],
|
||||
)
|
||||
statement = (
|
||||
select(WorkflowRunArchiveBundle).where(*conditions).order_by(WorkflowRunArchiveBundle.id.asc()).limit(limit)
|
||||
)
|
||||
return refs[:limit]
|
||||
with self.session_factory() as session:
|
||||
if after_catalog_id:
|
||||
cursor_bundle = session.get(WorkflowRunArchiveBundle, after_catalog_id)
|
||||
self._validate_catalog_cursor_scope(
|
||||
cursor_bundle,
|
||||
tenant_ids=tenant_ids,
|
||||
target_year=target_year,
|
||||
target_month=target_month,
|
||||
shard=shard,
|
||||
)
|
||||
bundles = list(session.scalars(statement))
|
||||
return [
|
||||
ArchiveBundleCatalogEntry(
|
||||
catalog_id=bundle.id,
|
||||
tenant_id=bundle.tenant_id,
|
||||
year=bundle.year,
|
||||
month=bundle.month,
|
||||
shard=bundle.shard,
|
||||
bundle_id=bundle.bundle_id,
|
||||
workflow_run_count=bundle.workflow_run_count,
|
||||
row_count=bundle.row_count,
|
||||
archive_bytes=bundle.archive_bytes,
|
||||
)
|
||||
for bundle in bundles
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _list_manifest_keys(storage: ArchiveStorage, tenant_ids: Sequence[str] | None) -> list[str]:
|
||||
keys: list[str] = []
|
||||
if tenant_ids:
|
||||
prefixes = [
|
||||
f"{_ARCHIVE_ROOT_PREFIX}tenant_prefix={tenant_id[0].lower()}/tenant_id={tenant_id}/"
|
||||
for tenant_id in tenant_ids
|
||||
]
|
||||
else:
|
||||
prefixes = [_ARCHIVE_ROOT_PREFIX]
|
||||
for prefix in prefixes:
|
||||
keys.extend(storage.list_objects(prefix))
|
||||
return sorted(key for key in keys if key.endswith(f"/{ARCHIVE_BUNDLE_MANIFEST_NAME}"))
|
||||
def _validate_catalog_cursor_scope(
|
||||
cursor_bundle: WorkflowRunArchiveBundle | None,
|
||||
*,
|
||||
tenant_ids: Sequence[str] | None,
|
||||
target_year: int,
|
||||
target_month: int,
|
||||
shard: str | None,
|
||||
) -> None:
|
||||
"""Reject a keyset cursor that cannot safely represent the requested catalog scope."""
|
||||
if cursor_bundle is None:
|
||||
raise ValueError("after_catalog_id does not exist in the workflow run archive bundle catalog")
|
||||
if cursor_bundle.year != target_year or cursor_bundle.month != target_month:
|
||||
raise ValueError("after_catalog_id is outside the requested archive month")
|
||||
if tenant_ids is not None and cursor_bundle.tenant_id not in tenant_ids:
|
||||
raise ValueError("after_catalog_id is outside the requested tenant scope")
|
||||
if shard is not None and cursor_bundle.shard != shard:
|
||||
raise ValueError("after_catalog_id is outside the requested archive shard")
|
||||
|
||||
def _build_bundle_reference(
|
||||
self,
|
||||
storage: ArchiveStorage,
|
||||
catalog_entry: ArchiveBundleCatalogEntry,
|
||||
) -> BundleReference:
|
||||
"""Load and bind one manifest to the catalog row that selected it."""
|
||||
object_prefix = self._catalog_object_prefix(catalog_entry)
|
||||
manifest_key = f"{object_prefix}/{ARCHIVE_BUNDLE_MANIFEST_NAME}"
|
||||
manifest_data = storage.get_object(manifest_key)
|
||||
manifest = self._load_and_validate_manifest(manifest_data, object_prefix=object_prefix)
|
||||
self._validate_manifest_catalog_identity(manifest, catalog_entry)
|
||||
return BundleReference(
|
||||
catalog=catalog_entry,
|
||||
object_prefix=object_prefix,
|
||||
manifest_key=manifest_key,
|
||||
manifest_size_bytes=len(manifest_data),
|
||||
manifest=manifest,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _catalog_object_prefix(catalog_entry: ArchiveBundleCatalogEntry) -> str:
|
||||
"""Construct the immutable V2 bundle prefix from its database catalog identity."""
|
||||
if not catalog_entry.tenant_id:
|
||||
raise ValueError("archive catalog tenant_id must not be empty")
|
||||
if not 1 <= catalog_entry.month <= 12:
|
||||
raise ValueError(f"archive catalog month is invalid: {catalog_entry.month}")
|
||||
return (
|
||||
f"workflow-runs/v2/tenant_prefix={catalog_entry.tenant_id[0].lower()}/"
|
||||
f"tenant_id={catalog_entry.tenant_id}/year={catalog_entry.year:04d}/"
|
||||
f"month={catalog_entry.month:02d}/shard={catalog_entry.shard}/bundle={catalog_entry.bundle_id}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_manifest_catalog_identity(
|
||||
manifest: BundleManifest,
|
||||
catalog_entry: ArchiveBundleCatalogEntry,
|
||||
) -> None:
|
||||
"""Fail closed when the catalog locator and manifest identify different immutable bundles."""
|
||||
expected_identity = (
|
||||
catalog_entry.tenant_id,
|
||||
catalog_entry.year,
|
||||
catalog_entry.month,
|
||||
catalog_entry.shard,
|
||||
catalog_entry.bundle_id,
|
||||
)
|
||||
manifest_identity = (
|
||||
manifest["tenant_id"],
|
||||
manifest["year"],
|
||||
manifest["month"],
|
||||
manifest["shard"],
|
||||
manifest["bundle_id"],
|
||||
)
|
||||
if manifest_identity != expected_identity:
|
||||
raise ValueError(
|
||||
f"archive manifest identity does not match catalog: expected={expected_identity}, "
|
||||
f"actual={manifest_identity}"
|
||||
)
|
||||
if manifest["workflow_run_count"] != catalog_entry.workflow_run_count:
|
||||
raise ValueError("archive manifest workflow_run_count does not match catalog")
|
||||
manifest_row_count = sum(table["row_count"] for table in manifest["tables"].values())
|
||||
if manifest_row_count != catalog_entry.row_count:
|
||||
raise ValueError("archive manifest row_count does not match catalog")
|
||||
|
||||
def _delete_bundle(
|
||||
self,
|
||||
@@ -344,37 +538,61 @@ class WorkflowRunBundleArchiveMaintenance:
|
||||
bundle_ref: BundleReference,
|
||||
) -> BundleOperationResult:
|
||||
start_time = time.time()
|
||||
result = self._new_result(bundle_ref.manifest)
|
||||
result = self._new_result(bundle_ref.manifest, bundle_ref.catalog.catalog_id)
|
||||
try:
|
||||
validation_start = time.time()
|
||||
if not self.dry_run:
|
||||
self._lock_catalog_entry(session, bundle_ref.catalog)
|
||||
if self._is_restore_started(storage, bundle_ref.object_prefix):
|
||||
raise ValueError("restore started marker exists; reconcile restore before delete")
|
||||
|
||||
deleted_marker_exists = self._is_deleted(storage, bundle_ref.object_prefix)
|
||||
manifest, table_records, archive_bytes = self._validate_archive_object(storage, bundle_ref)
|
||||
result.table_counts = self._manifest_table_counts(manifest)
|
||||
result.archive_bytes = archive_bytes
|
||||
|
||||
self._lock_workflow_runs(session, manifest["run_ids"])
|
||||
if self._is_delete_started(storage, bundle_ref.object_prefix) and self._live_counts_match(
|
||||
session, manifest, expected_present=False
|
||||
):
|
||||
live_records = self._load_live_bundle_records(
|
||||
session,
|
||||
manifest,
|
||||
table_records,
|
||||
lock=not self.dry_run,
|
||||
)
|
||||
if deleted_marker_exists:
|
||||
live_counts = {table_name: len(live_records[table_name]) for table_name in ARCHIVED_TABLES}
|
||||
if any(live_counts.values()):
|
||||
raise ValueError(f"Live rows exist for bundle with deleted marker: {live_counts}")
|
||||
if not self.dry_run:
|
||||
self._delete_marker(storage, bundle_ref.object_prefix, ARCHIVE_BUNDLE_DELETE_STARTED_MARKER_NAME)
|
||||
self._delete_marker(storage, bundle_ref.object_prefix, ARCHIVE_BUNDLE_RESTORED_MARKER_NAME)
|
||||
result.validation_time = time.time() - validation_start
|
||||
result.success = True
|
||||
result.elapsed_time = time.time() - start_time
|
||||
return result
|
||||
|
||||
self._validate_live_archive_subset(manifest, table_records, live_records)
|
||||
result.validation_time = time.time() - validation_start
|
||||
|
||||
if not any(live_records[table_name] for table_name in ARCHIVED_TABLES):
|
||||
if not self.dry_run:
|
||||
self._mark_deleted(storage, bundle_ref.object_prefix)
|
||||
self._delete_marker(storage, bundle_ref.object_prefix, ARCHIVE_BUNDLE_DELETE_STARTED_MARKER_NAME)
|
||||
self._delete_marker(storage, bundle_ref.object_prefix, ARCHIVE_BUNDLE_RESTORED_MARKER_NAME)
|
||||
result.success = True
|
||||
result.elapsed_time = time.time() - start_time
|
||||
return result
|
||||
|
||||
self._validate_live_counts(session, manifest, expected_present=True)
|
||||
if self.strict_content_validation:
|
||||
self._validate_live_content(session, table_records)
|
||||
result.validation_time = time.time() - validation_start
|
||||
|
||||
if not self.dry_run:
|
||||
self._put_marker(storage, bundle_ref.object_prefix, ARCHIVE_BUNDLE_DELETE_STARTED_MARKER_NAME)
|
||||
deleted_counts = self._delete_bundle_rows(session, table_records)
|
||||
if deleted_counts != result.table_counts:
|
||||
expected_deleted_counts = {table_name: len(live_records[table_name]) for table_name in ARCHIVED_TABLES}
|
||||
deleted_counts = self._delete_bundle_rows(session, live_records)
|
||||
if deleted_counts != expected_deleted_counts:
|
||||
raise ValueError(
|
||||
f"Deleted row count mismatch: expected={result.table_counts}, actual={deleted_counts}"
|
||||
f"Deleted row count mismatch: expected={expected_deleted_counts}, actual={deleted_counts}"
|
||||
)
|
||||
self._validate_live_counts(session, manifest, expected_present=False)
|
||||
remaining_records = self._load_live_bundle_records(session, manifest, table_records, lock=True)
|
||||
remaining_counts = {table_name: len(remaining_records[table_name]) for table_name in ARCHIVED_TABLES}
|
||||
if any(remaining_counts.values()):
|
||||
raise ValueError(f"Live rows remain after bundle delete: {remaining_counts}")
|
||||
session.commit()
|
||||
self._mark_deleted(storage, bundle_ref.object_prefix)
|
||||
self._delete_marker(storage, bundle_ref.object_prefix, ARCHIVE_BUNDLE_DELETE_STARTED_MARKER_NAME)
|
||||
@@ -394,9 +612,25 @@ class WorkflowRunBundleArchiveMaintenance:
|
||||
bundle_ref: BundleReference,
|
||||
) -> BundleOperationResult:
|
||||
start_time = time.time()
|
||||
result = self._new_result(bundle_ref.manifest)
|
||||
result = self._new_result(bundle_ref.manifest, bundle_ref.catalog.catalog_id)
|
||||
try:
|
||||
validation_start = time.time()
|
||||
if not self.dry_run:
|
||||
self._lock_catalog_entry(session, bundle_ref.catalog)
|
||||
if not self._is_deleted(storage, bundle_ref.object_prefix):
|
||||
# A committed delete may be interrupted before `.deleted` is written. Do not let restore advance its
|
||||
# cursor over that state: retry delete to reconcile it, or investigate source-row drift first.
|
||||
if self._is_delete_started(storage, bundle_ref.object_prefix):
|
||||
raise ValueError("delete started marker exists without a deleted marker; reconcile delete first")
|
||||
restore_started = self._is_restore_started(storage, bundle_ref.object_prefix)
|
||||
self._validate_live_counts(session, bundle_ref.manifest, expected_present=True)
|
||||
result.validation_time = time.time() - validation_start
|
||||
if restore_started and not self.dry_run:
|
||||
self._mark_restored(storage, bundle_ref.object_prefix)
|
||||
result.success = True
|
||||
result.elapsed_time = time.time() - start_time
|
||||
return result
|
||||
|
||||
manifest, table_records, archive_bytes = self._validate_archive_object(storage, bundle_ref)
|
||||
result.table_counts = self._manifest_table_counts(manifest)
|
||||
result.archive_bytes = archive_bytes
|
||||
@@ -408,6 +642,7 @@ class WorkflowRunBundleArchiveMaintenance:
|
||||
if not self.dry_run:
|
||||
self._mark_restored(storage, bundle_ref.object_prefix)
|
||||
result.success = True
|
||||
result.elapsed_time = time.time() - start_time
|
||||
return result
|
||||
|
||||
self._validate_live_counts(session, manifest, expected_present=False)
|
||||
@@ -432,13 +667,40 @@ class WorkflowRunBundleArchiveMaintenance:
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _new_result(manifest: BundleManifest) -> BundleOperationResult:
|
||||
def _new_result(manifest: BundleManifest, catalog_id: str) -> BundleOperationResult:
|
||||
return BundleOperationResult(
|
||||
catalog_id=catalog_id,
|
||||
bundle_id=manifest["bundle_id"],
|
||||
tenant_id=manifest["tenant_id"],
|
||||
object_prefix=manifest["object_prefix"],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _new_result_from_catalog_entry(catalog_entry: ArchiveBundleCatalogEntry) -> BundleOperationResult:
|
||||
try:
|
||||
object_prefix = WorkflowRunBundleArchiveMaintenance._catalog_object_prefix(catalog_entry)
|
||||
except ValueError:
|
||||
object_prefix = "<invalid archive catalog identity>"
|
||||
return BundleOperationResult(
|
||||
catalog_id=catalog_entry.catalog_id,
|
||||
bundle_id=catalog_entry.bundle_id,
|
||||
tenant_id=catalog_entry.tenant_id,
|
||||
object_prefix=object_prefix,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _lock_catalog_entry(session: Session, catalog_entry: ArchiveBundleCatalogEntry) -> None:
|
||||
locked_catalog_id = session.scalar(
|
||||
select(WorkflowRunArchiveBundle.id)
|
||||
.where(
|
||||
WorkflowRunArchiveBundle.id == catalog_entry.catalog_id,
|
||||
WorkflowRunArchiveBundle.tenant_id == catalog_entry.tenant_id,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
if locked_catalog_id is None:
|
||||
raise ValueError("archive catalog row disappeared before bundle maintenance")
|
||||
|
||||
def _validate_archive_object(
|
||||
self,
|
||||
storage: ArchiveStorage,
|
||||
@@ -446,7 +708,7 @@ class WorkflowRunBundleArchiveMaintenance:
|
||||
) -> tuple[BundleManifest, dict[str, list[dict[str, Any]]], int]:
|
||||
manifest = bundle_ref.manifest
|
||||
table_records: dict[str, list[dict[str, Any]]] = {}
|
||||
total_size = len(storage.get_object(bundle_ref.manifest_key))
|
||||
total_size = bundle_ref.manifest_size_bytes
|
||||
for table_name in ARCHIVED_TABLES:
|
||||
info = manifest["tables"][table_name]
|
||||
payload = self._get_checked_object(storage, info["object_key"])
|
||||
@@ -469,12 +731,14 @@ class WorkflowRunBundleArchiveMaintenance:
|
||||
f"expected={info['row_count']}, actual={len(records)}"
|
||||
)
|
||||
table_records[table_name] = records
|
||||
if total_size != bundle_ref.catalog.archive_bytes:
|
||||
raise ValueError(
|
||||
f"Archive object total size mismatch: expected={bundle_ref.catalog.archive_bytes}, actual={total_size}"
|
||||
)
|
||||
return manifest, table_records, total_size
|
||||
|
||||
@staticmethod
|
||||
def _get_checked_object(storage: ArchiveStorage, object_key: str) -> bytes:
|
||||
if not storage.object_exists(object_key):
|
||||
raise FileNotFoundError(f"Archive object not found: {object_key}")
|
||||
return storage.get_object(object_key)
|
||||
|
||||
@staticmethod
|
||||
@@ -539,6 +803,110 @@ class WorkflowRunBundleArchiveMaintenance:
|
||||
table = pq.read_table(io.BytesIO(payload))
|
||||
return table.to_pylist()
|
||||
|
||||
def _load_live_bundle_records(
|
||||
self,
|
||||
session: Session,
|
||||
manifest: BundleManifest,
|
||||
table_records: dict[str, list[dict[str, Any]]],
|
||||
*,
|
||||
lock: bool,
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""Load the complete live scope for a bundle, including archived rows whose relationship fields drifted."""
|
||||
run_ids = manifest["run_ids"]
|
||||
archive_ids = {
|
||||
table_name: [str(record["id"]) for record in table_records[table_name]] for table_name in ARCHIVED_TABLES
|
||||
}
|
||||
live_node_ids = self._select_ids_by_run_ids(session, WorkflowNodeExecutionModel, run_ids)
|
||||
live_pause_ids = self._select_ids_by_run_ids(session, WorkflowPause, run_ids)
|
||||
node_ids = sorted(set(archive_ids["workflow_node_executions"]) | set(live_node_ids))
|
||||
pause_ids = sorted(set(archive_ids["workflow_pauses"]) | set(live_pause_ids))
|
||||
|
||||
def load_scope(
|
||||
table_name: str,
|
||||
model: Any,
|
||||
scope_column: Any,
|
||||
scope_ids: Sequence[str],
|
||||
) -> list[dict[str, Any]]:
|
||||
return self._merge_records_by_id(
|
||||
self._load_records_by_column(session, model, scope_column, scope_ids, lock=lock),
|
||||
self._load_records_by_column(session, model, model.id, archive_ids[table_name], lock=lock),
|
||||
)
|
||||
|
||||
return {
|
||||
"workflow_pause_reasons": load_scope(
|
||||
"workflow_pause_reasons", WorkflowPauseReason, WorkflowPauseReason.pause_id, pause_ids
|
||||
),
|
||||
"workflow_node_execution_offload": load_scope(
|
||||
"workflow_node_execution_offload",
|
||||
WorkflowNodeExecutionOffload,
|
||||
WorkflowNodeExecutionOffload.node_execution_id,
|
||||
node_ids,
|
||||
),
|
||||
"workflow_trigger_logs": load_scope(
|
||||
"workflow_trigger_logs", WorkflowTriggerLog, WorkflowTriggerLog.workflow_run_id, run_ids
|
||||
),
|
||||
"workflow_app_logs": load_scope(
|
||||
"workflow_app_logs", WorkflowAppLog, WorkflowAppLog.workflow_run_id, run_ids
|
||||
),
|
||||
"workflow_node_executions": load_scope(
|
||||
"workflow_node_executions",
|
||||
WorkflowNodeExecutionModel,
|
||||
WorkflowNodeExecutionModel.workflow_run_id,
|
||||
run_ids,
|
||||
),
|
||||
"workflow_pauses": load_scope("workflow_pauses", WorkflowPause, WorkflowPause.workflow_run_id, run_ids),
|
||||
"workflow_runs": self._load_records_by_column(
|
||||
session,
|
||||
WorkflowRun,
|
||||
WorkflowRun.id,
|
||||
sorted(set(run_ids) | set(archive_ids["workflow_runs"])),
|
||||
lock=lock,
|
||||
),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _validate_live_archive_subset(
|
||||
cls,
|
||||
manifest: BundleManifest,
|
||||
table_records: dict[str, list[dict[str, Any]]],
|
||||
live_records: dict[str, list[dict[str, Any]]],
|
||||
) -> None:
|
||||
"""Require every live row in the bundle scope to exist unchanged in the validated archive."""
|
||||
manifest_run_ids = {str(run_id) for run_id in manifest["run_ids"]}
|
||||
if len(manifest_run_ids) != len(manifest["run_ids"]):
|
||||
raise ValueError("archive manifest contains duplicate workflow run IDs")
|
||||
|
||||
archive_records_by_id: dict[str, dict[str, dict[str, Any]]] = {}
|
||||
for table_name in ARCHIVED_TABLES:
|
||||
records_by_id = {str(record["id"]): record for record in table_records[table_name]}
|
||||
if len(records_by_id) != len(table_records[table_name]):
|
||||
raise ValueError(f"archive contains duplicate row IDs for {table_name}")
|
||||
archive_records_by_id[table_name] = records_by_id
|
||||
|
||||
if set(archive_records_by_id["workflow_runs"]) != manifest_run_ids:
|
||||
raise ValueError("archive workflow run IDs do not match manifest run_ids")
|
||||
|
||||
for table_name in ARCHIVED_TABLES:
|
||||
live_ids = [str(record["id"]) for record in live_records[table_name]]
|
||||
if len(set(live_ids)) != len(live_ids):
|
||||
raise ValueError(f"live scope contains duplicate row IDs for {table_name}")
|
||||
|
||||
archive_by_id = archive_records_by_id[table_name]
|
||||
extra_ids = sorted(set(live_ids) - set(archive_by_id))
|
||||
if extra_ids:
|
||||
raise ValueError(
|
||||
f"Live bundle scope contains rows missing from archive for {table_name}: {extra_ids[:10]}"
|
||||
)
|
||||
|
||||
archive_subset = [archive_by_id[row_id] for row_id in live_ids]
|
||||
live_checksum = cls._records_checksum(live_records[table_name])
|
||||
archive_checksum = cls._records_checksum(archive_subset)
|
||||
if live_checksum != archive_checksum:
|
||||
raise ValueError(
|
||||
f"Live/archive subset content checksum mismatch for {table_name}: "
|
||||
f"expected={archive_checksum}, actual={live_checksum}"
|
||||
)
|
||||
|
||||
def _validate_live_counts(
|
||||
self,
|
||||
session: Session,
|
||||
@@ -617,26 +985,13 @@ class WorkflowRunBundleArchiveMaintenance:
|
||||
def _delete_bundle_rows(
|
||||
self,
|
||||
session: Session,
|
||||
table_records: dict[str, list[dict[str, Any]]],
|
||||
live_records: dict[str, list[dict[str, Any]]],
|
||||
) -> dict[str, int]:
|
||||
run_ids = [str(record["id"]) for record in table_records["workflow_runs"]]
|
||||
node_ids = [str(record["id"]) for record in table_records["workflow_node_executions"]]
|
||||
pause_ids = [str(record["id"]) for record in table_records["workflow_pauses"]]
|
||||
|
||||
deleted_counts = dict.fromkeys(ARCHIVED_TABLES, 0)
|
||||
deleted_counts["workflow_pause_reasons"] = self._delete_by_column(
|
||||
session, WorkflowPauseReason, WorkflowPauseReason.pause_id, pause_ids
|
||||
)
|
||||
deleted_counts["workflow_node_execution_offload"] = self._delete_by_column(
|
||||
session, WorkflowNodeExecutionOffload, WorkflowNodeExecutionOffload.node_execution_id, node_ids
|
||||
)
|
||||
deleted_counts["workflow_trigger_logs"] = self._delete_by_run_ids(session, WorkflowTriggerLog, run_ids)
|
||||
deleted_counts["workflow_app_logs"] = self._delete_by_run_ids(session, WorkflowAppLog, run_ids)
|
||||
deleted_counts["workflow_node_executions"] = self._delete_by_run_ids(
|
||||
session, WorkflowNodeExecutionModel, run_ids
|
||||
)
|
||||
deleted_counts["workflow_pauses"] = self._delete_by_run_ids(session, WorkflowPause, run_ids)
|
||||
deleted_counts["workflow_runs"] = self._delete_by_run_ids(session, WorkflowRun, run_ids)
|
||||
for table_name in DELETE_ORDER:
|
||||
model = TABLE_MODELS[table_name]
|
||||
row_ids = [str(record["id"]) for record in live_records[table_name]]
|
||||
deleted_counts[table_name] = self._delete_by_column(session, model, model.id, row_ids)
|
||||
return deleted_counts
|
||||
|
||||
def _restore_bundle_rows(
|
||||
@@ -708,11 +1063,6 @@ class WorkflowRunBundleArchiveMaintenance:
|
||||
payload = json.dumps(normalized, sort_keys=True, default=str, ensure_ascii=False, separators=(",", ":"))
|
||||
return ArchiveStorage.compute_checksum(payload.encode("utf-8"))
|
||||
|
||||
@staticmethod
|
||||
def _lock_workflow_runs(session: Session, run_ids: Sequence[str]) -> None:
|
||||
for chunk in WorkflowRunBundleArchiveMaintenance._chunks(run_ids, _CHUNK_SIZE):
|
||||
list(session.scalars(select(WorkflowRun.id).where(WorkflowRun.id.in_(chunk)).with_for_update()))
|
||||
|
||||
@staticmethod
|
||||
def _select_ids_by_run_ids(
|
||||
session: Session,
|
||||
@@ -757,8 +1107,16 @@ class WorkflowRunBundleArchiveMaintenance:
|
||||
session: Session,
|
||||
model: Any,
|
||||
run_ids: Sequence[str],
|
||||
*,
|
||||
lock: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
return self._load_records_by_column(session, model, self._run_id_column(model), run_ids)
|
||||
return self._load_records_by_column(
|
||||
session,
|
||||
model,
|
||||
self._run_id_column(model),
|
||||
run_ids,
|
||||
lock=lock,
|
||||
)
|
||||
|
||||
def _load_records_by_column(
|
||||
self,
|
||||
@@ -766,23 +1124,23 @@ class WorkflowRunBundleArchiveMaintenance:
|
||||
model: Any,
|
||||
column: Any,
|
||||
values: Sequence[str],
|
||||
*,
|
||||
lock: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
if not values:
|
||||
return []
|
||||
rows: list[Any] = []
|
||||
for chunk in self._chunks(values, _CHUNK_SIZE):
|
||||
rows.extend(session.scalars(select(model).where(column.in_(chunk))))
|
||||
statement = select(model).where(column.in_(chunk)).order_by(model.id.asc())
|
||||
if lock:
|
||||
statement = statement.with_for_update()
|
||||
rows.extend(session.scalars(statement))
|
||||
return [self._row_to_dict(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
def _delete_by_run_ids(
|
||||
session: Session,
|
||||
model: Any,
|
||||
run_ids: Sequence[str],
|
||||
) -> int:
|
||||
return WorkflowRunBundleArchiveMaintenance._delete_by_column(
|
||||
session, model, WorkflowRunBundleArchiveMaintenance._run_id_column(model), run_ids
|
||||
)
|
||||
def _merge_records_by_id(*record_groups: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
records_by_id = {str(record["id"]): record for record_group in record_groups for record in record_group}
|
||||
return [records_by_id[row_id] for row_id in sorted(records_by_id)]
|
||||
|
||||
@staticmethod
|
||||
def _run_id_column(model: Any) -> Any:
|
||||
@@ -813,6 +1171,10 @@ class WorkflowRunBundleArchiveMaintenance:
|
||||
def _is_delete_started(storage: ArchiveStorage, object_prefix: str) -> bool:
|
||||
return storage.object_exists(f"{object_prefix}/{ARCHIVE_BUNDLE_DELETE_STARTED_MARKER_NAME}")
|
||||
|
||||
@staticmethod
|
||||
def _is_restore_started(storage: ArchiveStorage, object_prefix: str) -> bool:
|
||||
return storage.object_exists(f"{object_prefix}/{ARCHIVE_BUNDLE_RESTORE_STARTED_MARKER_NAME}")
|
||||
|
||||
@staticmethod
|
||||
def _mark_deleted(storage: ArchiveStorage, object_prefix: str) -> None:
|
||||
WorkflowRunBundleArchiveMaintenance._put_marker(storage, object_prefix, ARCHIVE_BUNDLE_DELETED_MARKER_NAME)
|
||||
@@ -820,10 +1182,13 @@ class WorkflowRunBundleArchiveMaintenance:
|
||||
@staticmethod
|
||||
def _mark_restored(storage: ArchiveStorage, object_prefix: str) -> None:
|
||||
WorkflowRunBundleArchiveMaintenance._delete_marker(storage, object_prefix, ARCHIVE_BUNDLE_DELETED_MARKER_NAME)
|
||||
WorkflowRunBundleArchiveMaintenance._put_marker(storage, object_prefix, ARCHIVE_BUNDLE_RESTORED_MARKER_NAME)
|
||||
WorkflowRunBundleArchiveMaintenance._delete_marker(
|
||||
storage, object_prefix, ARCHIVE_BUNDLE_DELETE_STARTED_MARKER_NAME
|
||||
)
|
||||
WorkflowRunBundleArchiveMaintenance._delete_marker(
|
||||
storage, object_prefix, ARCHIVE_BUNDLE_RESTORE_STARTED_MARKER_NAME
|
||||
)
|
||||
WorkflowRunBundleArchiveMaintenance._put_marker(storage, object_prefix, ARCHIVE_BUNDLE_RESTORED_MARKER_NAME)
|
||||
|
||||
@staticmethod
|
||||
def _put_marker(storage: ArchiveStorage, object_prefix: str, marker_name: str) -> None:
|
||||
@@ -836,16 +1201,6 @@ class WorkflowRunBundleArchiveMaintenance:
|
||||
if storage.object_exists(marker_key):
|
||||
storage.delete_object(marker_key)
|
||||
|
||||
@staticmethod
|
||||
def _parse_manifest_datetime(value: str) -> datetime.datetime:
|
||||
return WorkflowRunBundleArchiveMaintenance._to_naive_utc(datetime.datetime.fromisoformat(value))
|
||||
|
||||
@staticmethod
|
||||
def _to_naive_utc(value: datetime.datetime) -> datetime.datetime:
|
||||
if value.tzinfo is None:
|
||||
return value
|
||||
return value.astimezone(datetime.UTC).replace(tzinfo=None)
|
||||
|
||||
@staticmethod
|
||||
def _chunks(values: Sequence[Any], size: int) -> list[Sequence[Any]]:
|
||||
return [values[index : index + size] for index in range(0, len(values), size)]
|
||||
|
||||
@@ -513,11 +513,94 @@ class TestArchiveRunIdempotency:
|
||||
storage = MagicMock()
|
||||
storage.object_exists.return_value = True
|
||||
|
||||
result = archiver._archive_bundle(MagicMock(), storage, [run])
|
||||
with patch.object(archiver, "_sync_existing_bundle_index") as sync_existing_bundle_index:
|
||||
result = archiver._archive_bundle(MagicMock(), storage, [run])
|
||||
|
||||
assert result.success is True
|
||||
assert result.skipped is True
|
||||
assert result.error == "bundle already archived"
|
||||
sync_existing_bundle_index.assert_called_once()
|
||||
|
||||
def test_existing_bundle_catalog_publication_failure_is_not_success(self):
|
||||
archiver = WorkflowRunArchiver(days=90)
|
||||
run = _run()
|
||||
session = MagicMock()
|
||||
storage = MagicMock()
|
||||
storage.object_exists.return_value = True
|
||||
|
||||
with patch.object(archiver, "_sync_existing_bundle_index", side_effect=RuntimeError("catalog unavailable")):
|
||||
result = archiver._archive_bundle(session, storage, [run])
|
||||
|
||||
assert result.success is False
|
||||
assert result.error == "catalog unavailable"
|
||||
session.rollback.assert_called_once()
|
||||
|
||||
def test_retry_repairs_index_after_catalog_commit_then_index_write_failure(self):
|
||||
archiver = WorkflowRunArchiver(days=90)
|
||||
run = _run()
|
||||
identity = archiver._build_bundle_identity([run])
|
||||
index_key = archiver._get_index_object_key(identity)
|
||||
manifest_key = archiver._get_manifest_object_key(identity)
|
||||
storage = FakeArchiveStorage()
|
||||
original_put_object = storage.put_object
|
||||
index_write_count = 0
|
||||
|
||||
def put_object(key: str, data: bytes) -> str:
|
||||
nonlocal index_write_count
|
||||
if key == index_key:
|
||||
index_write_count += 1
|
||||
if index_write_count == 2:
|
||||
raise RuntimeError("index write failed")
|
||||
return original_put_object(key, data)
|
||||
|
||||
storage.put_object = MagicMock(side_effect=put_object)
|
||||
first_session = MagicMock()
|
||||
first_session.scalar.return_value = None
|
||||
table_data = {"workflow_runs": [{"id": run.id, "tenant_id": run.tenant_id}]}
|
||||
|
||||
with (
|
||||
patch.object(archiver, "_lock_runs_for_archive", return_value=[run]),
|
||||
patch.object(archiver, "_extract_bundle_data", return_value=table_data),
|
||||
):
|
||||
first_result = archiver._archive_bundle(first_session, storage, [run])
|
||||
|
||||
assert first_result.success is False
|
||||
assert first_result.error == "index write failed"
|
||||
assert manifest_key in storage.objects
|
||||
assert json.loads(storage.objects[index_key])["run_ids"] == []
|
||||
|
||||
storage.list_objects = MagicMock(wraps=storage.list_objects)
|
||||
retry_session = MagicMock()
|
||||
retry_session.scalar.return_value = None
|
||||
|
||||
retry_result = archiver._archive_bundle(retry_session, storage, [run])
|
||||
|
||||
assert retry_result.success is True
|
||||
assert retry_result.skipped is True
|
||||
assert json.loads(storage.objects[index_key])["manifest_keys"] == [manifest_key]
|
||||
assert json.loads(storage.objects[index_key])["run_ids"] == [run.id]
|
||||
storage.list_objects.assert_not_called()
|
||||
|
||||
def test_existing_manifest_with_missing_index_fails_without_partial_rebuild(self):
|
||||
archiver = WorkflowRunArchiver(days=90)
|
||||
run = _run()
|
||||
identity = archiver._build_bundle_identity([run])
|
||||
_, _, manifest_data = archiver._build_archive_payload(
|
||||
identity,
|
||||
[run],
|
||||
{"workflow_runs": [{"id": run.id, "tenant_id": run.tenant_id}]},
|
||||
)
|
||||
manifest_key = archiver._get_manifest_object_key(identity)
|
||||
index_key = archiver._get_index_object_key(identity)
|
||||
storage = FakeArchiveStorage({manifest_key: manifest_data})
|
||||
storage.list_objects = MagicMock(wraps=storage.list_objects)
|
||||
|
||||
result = archiver._archive_bundle(MagicMock(), storage, [run])
|
||||
|
||||
assert result.success is False
|
||||
assert "archive shard index missing" in (result.error or "")
|
||||
assert index_key not in storage.objects
|
||||
storage.list_objects.assert_not_called()
|
||||
|
||||
def test_successful_bundle_persists_archive_index(self):
|
||||
archiver = WorkflowRunArchiver(days=90)
|
||||
@@ -550,6 +633,28 @@ class TestArchiveRunIdempotency:
|
||||
assert archived_bundle.row_count == 2
|
||||
session.commit.assert_called_once()
|
||||
|
||||
def test_new_bundle_catalog_commit_failure_is_not_success(self):
|
||||
archiver = WorkflowRunArchiver(days=90)
|
||||
run = _run(str(uuid.uuid4()))
|
||||
run.tenant_id = str(uuid.uuid4())
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = None
|
||||
session.commit.side_effect = RuntimeError("catalog commit failed")
|
||||
storage = MagicMock()
|
||||
storage.object_exists.return_value = False
|
||||
storage.list_objects.return_value = []
|
||||
table_data = {"workflow_runs": [{"id": run.id, "tenant_id": run.tenant_id}]}
|
||||
|
||||
with (
|
||||
patch.object(archiver, "_lock_runs_for_archive", return_value=[run]),
|
||||
patch.object(archiver, "_extract_bundle_data", return_value=table_data),
|
||||
):
|
||||
result = archiver._archive_bundle(session, storage, [run])
|
||||
|
||||
assert result.success is False
|
||||
assert result.error == "catalog commit failed"
|
||||
session.rollback.assert_called_once()
|
||||
|
||||
def test_index_skips_all_already_archived_runs(self):
|
||||
archiver = WorkflowRunArchiver(days=90)
|
||||
run = MagicMock()
|
||||
|
||||
@@ -3,9 +3,19 @@ from unittest.mock import MagicMock
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
from commands import retention
|
||||
from services.retention.workflow_run import bundle_archive_maintenance
|
||||
from services.retention.workflow_run.bundle_archive_maintenance import (
|
||||
BundleOperationResult,
|
||||
BundleOperationSummary,
|
||||
)
|
||||
|
||||
_CURSOR_0 = "00000000-0000-0000-0000-000000000000"
|
||||
_CURSOR_1 = "00000000-0000-0000-0000-000000000001"
|
||||
_CURSOR_2 = "00000000-0000-0000-0000-000000000002"
|
||||
|
||||
|
||||
def _db_disconnect_error() -> OperationalError:
|
||||
@@ -24,6 +34,58 @@ def _session_context(session):
|
||||
return context
|
||||
|
||||
|
||||
def _delete_summary(
|
||||
*,
|
||||
processed: int,
|
||||
succeeded: int = 0,
|
||||
failed: int = 0,
|
||||
next_catalog_id: str | None = None,
|
||||
preview_next_catalog_id: str | None = None,
|
||||
results: list[BundleOperationResult] | None = None,
|
||||
) -> BundleOperationSummary:
|
||||
return BundleOperationSummary(
|
||||
operation="delete",
|
||||
bundles_processed=processed,
|
||||
bundles_succeeded=succeeded,
|
||||
bundles_failed=failed,
|
||||
next_catalog_id=next_catalog_id,
|
||||
preview_next_catalog_id=preview_next_catalog_id,
|
||||
results=results or [],
|
||||
)
|
||||
|
||||
|
||||
def _patch_bundle_deleter(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
summaries: list[BundleOperationSummary],
|
||||
) -> MagicMock:
|
||||
deleter = MagicMock()
|
||||
deleter.delete_batch.side_effect = summaries
|
||||
monkeypatch.setattr(
|
||||
bundle_archive_maintenance,
|
||||
"WorkflowRunBundleArchiveMaintenance",
|
||||
MagicMock(return_value=deleter),
|
||||
)
|
||||
return deleter
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command",
|
||||
[retention.restore_workflow_runs, retention.delete_archived_workflow_runs],
|
||||
)
|
||||
def test_v2_archive_maintenance_rejects_explicitly_empty_tenant_ids(command):
|
||||
result = CliRunner().invoke(
|
||||
command,
|
||||
["--tenant-ids", "", "--target-month", "2025-03"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert "tenant-ids must not be empty" in result.output
|
||||
|
||||
|
||||
def test_archive_tenant_id_parser_keeps_omitted_scope_unset():
|
||||
assert retention._parse_comma_separated_ids(None, param_name="tenant-ids") is None
|
||||
|
||||
|
||||
def test_resolve_archive_tenant_ids_from_plan_uses_explicit_sessions(monkeypatch):
|
||||
end_before = datetime.datetime(2025, 4, 1, tzinfo=datetime.UTC)
|
||||
sessions = [MagicMock(name="session-a"), MagicMock(name="session-b")]
|
||||
@@ -137,3 +199,265 @@ def test_archive_workflow_runs_raises_click_exception_when_tenant_plan_fails(mon
|
||||
dry_run=True,
|
||||
delete_after_archive=False,
|
||||
)
|
||||
|
||||
|
||||
def test_delete_archived_workflow_runs_keeps_single_page_behavior_without_all_pages(monkeypatch):
|
||||
deleter = _patch_bundle_deleter(
|
||||
monkeypatch,
|
||||
[_delete_summary(processed=2, succeeded=2, next_catalog_id=_CURSOR_1)],
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(
|
||||
retention.delete_archived_workflow_runs,
|
||||
["--target-month", "2025-03", "--limit", "2"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
deleter.delete_batch.assert_called_once()
|
||||
assert deleter.delete_batch.call_args.kwargs["target_year"] == 2025
|
||||
assert deleter.delete_batch.call_args.kwargs["target_month"] == 3
|
||||
assert deleter.delete_batch.call_args.kwargs["after_catalog_id"] is None
|
||||
assert deleter.delete_batch.call_args.kwargs["limit"] == 2
|
||||
|
||||
|
||||
def test_delete_archived_workflow_runs_all_pages_continues_until_empty_page(monkeypatch):
|
||||
deleter = _patch_bundle_deleter(
|
||||
monkeypatch,
|
||||
[
|
||||
_delete_summary(processed=2, succeeded=2, next_catalog_id=_CURSOR_1),
|
||||
_delete_summary(processed=1, succeeded=1, next_catalog_id=_CURSOR_2),
|
||||
_delete_summary(processed=0),
|
||||
],
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(
|
||||
retention.delete_archived_workflow_runs,
|
||||
["--target-month", "2025-03", "--all-pages", "--limit", "2"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert [call.kwargs["after_catalog_id"] for call in deleter.delete_batch.call_args_list] == [
|
||||
None,
|
||||
_CURSOR_1,
|
||||
_CURSOR_2,
|
||||
]
|
||||
|
||||
|
||||
def test_delete_archived_workflow_runs_all_pages_fetches_empty_page_after_exact_full_page(monkeypatch):
|
||||
deleter = _patch_bundle_deleter(
|
||||
monkeypatch,
|
||||
[
|
||||
_delete_summary(processed=2, succeeded=2, next_catalog_id=_CURSOR_1),
|
||||
_delete_summary(processed=0),
|
||||
],
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(
|
||||
retention.delete_archived_workflow_runs,
|
||||
["--target-month", "2025-03", "--all-pages", "--limit", "2"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert deleter.delete_batch.call_count == 2
|
||||
assert deleter.delete_batch.call_args_list[1].kwargs["after_catalog_id"] == _CURSOR_1
|
||||
|
||||
|
||||
def test_delete_archived_workflow_runs_all_pages_stops_at_first_failed_page(monkeypatch):
|
||||
failed_result = BundleOperationResult(
|
||||
catalog_id=_CURSOR_2,
|
||||
bundle_id="bundle-failed",
|
||||
tenant_id="tenant-1",
|
||||
object_prefix="workflow-runs/v2/tenant-1/2025/03/00-of-16/bundle-failed",
|
||||
error="archive checksum mismatch",
|
||||
)
|
||||
deleter = _patch_bundle_deleter(
|
||||
monkeypatch,
|
||||
[
|
||||
_delete_summary(processed=1, succeeded=1, next_catalog_id=_CURSOR_1),
|
||||
_delete_summary(processed=1, failed=1, results=[failed_result]),
|
||||
_delete_summary(processed=0),
|
||||
],
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(
|
||||
retention.delete_archived_workflow_runs,
|
||||
["--target-month", "2025-03", "--all-pages"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert deleter.delete_batch.call_count == 2
|
||||
assert "target_month=2025-03" in result.output
|
||||
assert f"failed_catalog_id={_CURSOR_2}" in result.output
|
||||
assert f"resume_after_catalog_id={_CURSOR_1}" in result.output
|
||||
|
||||
|
||||
def test_delete_archived_workflow_runs_all_pages_fails_when_cursor_does_not_advance(monkeypatch):
|
||||
deleter = _patch_bundle_deleter(
|
||||
monkeypatch,
|
||||
[_delete_summary(processed=1, succeeded=1, next_catalog_id=None)],
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(
|
||||
retention.delete_archived_workflow_runs,
|
||||
["--target-month", "2025-03", "--all-pages"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
deleter.delete_batch.assert_called_once()
|
||||
assert "cursor did not advance" in result.output.lower()
|
||||
|
||||
|
||||
def test_delete_archived_workflow_runs_all_pages_uses_preview_cursor_for_dry_run(monkeypatch):
|
||||
deleter = _patch_bundle_deleter(
|
||||
monkeypatch,
|
||||
[
|
||||
_delete_summary(processed=1, succeeded=1, preview_next_catalog_id=_CURSOR_1),
|
||||
_delete_summary(processed=0),
|
||||
],
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(
|
||||
retention.delete_archived_workflow_runs,
|
||||
["--target-month", "2025-03", "--all-pages", "--dry-run"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert [call.kwargs["after_catalog_id"] for call in deleter.delete_batch.call_args_list] == [
|
||||
None,
|
||||
_CURSOR_1,
|
||||
]
|
||||
|
||||
|
||||
def test_delete_archived_workflow_runs_dry_run_failure_separates_preview_and_destructive_cursors(monkeypatch):
|
||||
failed_result = BundleOperationResult(
|
||||
catalog_id=_CURSOR_2,
|
||||
bundle_id="bundle-failed",
|
||||
tenant_id="tenant-1",
|
||||
object_prefix="workflow-runs/v2/tenant-1/2025/03/00-of-16/bundle-failed",
|
||||
error="archive checksum mismatch",
|
||||
)
|
||||
deleter = _patch_bundle_deleter(
|
||||
monkeypatch,
|
||||
[
|
||||
_delete_summary(
|
||||
processed=1,
|
||||
succeeded=1,
|
||||
preview_next_catalog_id=_CURSOR_1,
|
||||
),
|
||||
_delete_summary(
|
||||
processed=1,
|
||||
failed=1,
|
||||
results=[failed_result],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(
|
||||
retention.delete_archived_workflow_runs,
|
||||
[
|
||||
"--target-month",
|
||||
"2025-03",
|
||||
"--after-catalog-id",
|
||||
_CURSOR_0,
|
||||
"--all-pages",
|
||||
"--dry-run",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert deleter.delete_batch.call_count == 2
|
||||
assert f"failed_catalog_id={_CURSOR_2}" in result.output
|
||||
assert f"preview_after_catalog_id={_CURSOR_1}" in result.output
|
||||
assert f"destructive_retry_after_catalog_id={_CURSOR_0}" in result.output
|
||||
|
||||
|
||||
def test_delete_archived_workflow_runs_all_pages_starts_after_explicit_cursor(monkeypatch):
|
||||
deleter = _patch_bundle_deleter(monkeypatch, [_delete_summary(processed=0)])
|
||||
|
||||
result = CliRunner().invoke(
|
||||
retention.delete_archived_workflow_runs,
|
||||
[
|
||||
"--target-month",
|
||||
"2025-03",
|
||||
"--after-catalog-id",
|
||||
_CURSOR_0,
|
||||
"--all-pages",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
deleter.delete_batch.assert_called_once()
|
||||
assert deleter.delete_batch.call_args.kwargs["after_catalog_id"] == _CURSOR_0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"shard_args",
|
||||
[
|
||||
["--run-shard-index", "0"],
|
||||
["--run-shard-total", "16"],
|
||||
["--run-shard-index", "16", "--run-shard-total", "16"],
|
||||
["--run-shard-index", "-1", "--run-shard-total", "16"],
|
||||
["--run-shard-index", "0", "--run-shard-total", "0"],
|
||||
["--run-shard-index", "0", "--run-shard-total", "17"],
|
||||
],
|
||||
)
|
||||
def test_delete_archived_workflow_runs_rejects_invalid_run_shard_options(monkeypatch, shard_args):
|
||||
deleter = _patch_bundle_deleter(monkeypatch, [_delete_summary(processed=0)])
|
||||
|
||||
result = CliRunner().invoke(
|
||||
retention.delete_archived_workflow_runs,
|
||||
["--target-month", "2025-03", *shard_args],
|
||||
)
|
||||
|
||||
assert result.exit_code == 2
|
||||
deleter.delete_batch.assert_not_called()
|
||||
|
||||
|
||||
def test_delete_archived_workflow_runs_passes_formatted_run_shard_to_service(monkeypatch):
|
||||
deleter = _patch_bundle_deleter(monkeypatch, [_delete_summary(processed=0)])
|
||||
|
||||
result = CliRunner().invoke(
|
||||
retention.delete_archived_workflow_runs,
|
||||
[
|
||||
"--target-month",
|
||||
"2025-03",
|
||||
"--tenant-ids",
|
||||
"tenant-1",
|
||||
"--run-shard-index",
|
||||
"3",
|
||||
"--run-shard-total",
|
||||
"16",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
deleter.validate_catalog_shards.assert_called_once_with(
|
||||
target_year=2025,
|
||||
target_month=3,
|
||||
shard_total=16,
|
||||
tenant_ids=["tenant-1"],
|
||||
)
|
||||
deleter.delete_batch.assert_called_once()
|
||||
assert deleter.delete_batch.call_args.kwargs["shard"] == "03-of-16"
|
||||
|
||||
|
||||
def test_delete_archived_workflow_runs_rejects_mixed_catalog_shards_before_delete(monkeypatch):
|
||||
deleter = _patch_bundle_deleter(monkeypatch, [_delete_summary(processed=0)])
|
||||
deleter.validate_catalog_shards.side_effect = ValueError("unexpected shards: 00-of-01")
|
||||
|
||||
result = CliRunner().invoke(
|
||||
retention.delete_archived_workflow_runs,
|
||||
[
|
||||
"--target-month",
|
||||
"2025-03",
|
||||
"--run-shard-index",
|
||||
"3",
|
||||
"--run-shard-total",
|
||||
"16",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "shard preflight failed" in result.output.lower()
|
||||
assert "00-of-01" in result.output
|
||||
deleter.delete_batch.assert_not_called()
|
||||
|
||||
@@ -4,7 +4,7 @@ from datetime import datetime
|
||||
from unittest.mock import ANY, MagicMock
|
||||
|
||||
import pytest
|
||||
from botocore.exceptions import ClientError
|
||||
from botocore.exceptions import ClientError, EndpointConnectionError
|
||||
|
||||
from libs import archive_storage as storage_module
|
||||
from libs.archive_storage import (
|
||||
@@ -34,6 +34,10 @@ def _client_error(code: str) -> ClientError:
|
||||
return ClientError({"Error": {"Code": code}}, "Operation")
|
||||
|
||||
|
||||
def _network_error() -> EndpointConnectionError:
|
||||
return EndpointConnectionError(endpoint_url="https://storage.example.com")
|
||||
|
||||
|
||||
def _mock_client(monkeypatch: pytest.MonkeyPatch):
|
||||
client = MagicMock()
|
||||
client.head_bucket.return_value = None
|
||||
@@ -153,16 +157,38 @@ def test_get_object_returns_bytes(monkeypatch: pytest.MonkeyPatch):
|
||||
assert storage.get_object("key") == b"payload"
|
||||
|
||||
|
||||
def test_get_object_missing(monkeypatch: pytest.MonkeyPatch):
|
||||
@pytest.mark.parametrize("error_code", ["404", "NoSuchKey", "NotFound"])
|
||||
def test_get_object_missing(monkeypatch: pytest.MonkeyPatch, error_code: str):
|
||||
_configure_storage(monkeypatch)
|
||||
client, _ = _mock_client(monkeypatch)
|
||||
client.get_object.side_effect = _client_error("NoSuchKey")
|
||||
client.get_object.side_effect = _client_error(error_code)
|
||||
storage = ArchiveStorage(bucket=BUCKET_NAME)
|
||||
|
||||
with pytest.raises(FileNotFoundError, match="Archive object not found"):
|
||||
storage.get_object("missing")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("error_code", ["403", "429", "500", "SlowDown"])
|
||||
def test_get_object_non_missing_error_fails_closed(monkeypatch: pytest.MonkeyPatch, error_code: str):
|
||||
_configure_storage(monkeypatch)
|
||||
client, _ = _mock_client(monkeypatch)
|
||||
client.get_object.side_effect = _client_error(error_code)
|
||||
storage = ArchiveStorage(bucket=BUCKET_NAME)
|
||||
|
||||
with pytest.raises(ArchiveStorageError, match="Failed to download object"):
|
||||
storage.get_object("key")
|
||||
|
||||
|
||||
def test_get_object_network_error_fails_closed(monkeypatch: pytest.MonkeyPatch):
|
||||
_configure_storage(monkeypatch)
|
||||
client, _ = _mock_client(monkeypatch)
|
||||
client.get_object.side_effect = _network_error()
|
||||
storage = ArchiveStorage(bucket=BUCKET_NAME)
|
||||
|
||||
with pytest.raises(ArchiveStorageError, match="Failed to download object"):
|
||||
storage.get_object("key")
|
||||
|
||||
|
||||
def test_get_object_stream(monkeypatch: pytest.MonkeyPatch):
|
||||
_configure_storage(monkeypatch)
|
||||
client, _ = _mock_client(monkeypatch)
|
||||
@@ -174,30 +200,80 @@ def test_get_object_stream(monkeypatch: pytest.MonkeyPatch):
|
||||
assert list(storage.get_object_stream("key")) == [b"a", b"b"]
|
||||
|
||||
|
||||
def test_get_object_stream_missing(monkeypatch: pytest.MonkeyPatch):
|
||||
@pytest.mark.parametrize("error_code", ["404", "NoSuchKey", "NotFound"])
|
||||
def test_get_object_stream_missing(monkeypatch: pytest.MonkeyPatch, error_code: str):
|
||||
_configure_storage(monkeypatch)
|
||||
client, _ = _mock_client(monkeypatch)
|
||||
client.get_object.side_effect = _client_error("NoSuchKey")
|
||||
client.get_object.side_effect = _client_error(error_code)
|
||||
storage = ArchiveStorage(bucket=BUCKET_NAME)
|
||||
|
||||
with pytest.raises(FileNotFoundError, match="Archive object not found"):
|
||||
list(storage.get_object_stream("missing"))
|
||||
|
||||
|
||||
def test_object_exists(monkeypatch: pytest.MonkeyPatch):
|
||||
@pytest.mark.parametrize("error_code", ["404", "NoSuchKey", "NotFound"])
|
||||
def test_object_exists_returns_false_only_for_not_found(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
error_code: str,
|
||||
):
|
||||
_configure_storage(monkeypatch)
|
||||
client, _ = _mock_client(monkeypatch)
|
||||
storage = ArchiveStorage(bucket=BUCKET_NAME)
|
||||
|
||||
assert storage.object_exists("key") is True
|
||||
client.head_object.side_effect = _client_error("404")
|
||||
client.head_object.side_effect = _client_error(error_code)
|
||||
assert storage.object_exists("missing") is False
|
||||
|
||||
|
||||
def test_delete_object_error(monkeypatch: pytest.MonkeyPatch):
|
||||
@pytest.mark.parametrize("error_code", ["403", "429", "500", "SlowDown"])
|
||||
def test_object_exists_raises_when_existence_is_unknown(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
error_code: str,
|
||||
):
|
||||
_configure_storage(monkeypatch)
|
||||
client, _ = _mock_client(monkeypatch)
|
||||
client.delete_object.side_effect = _client_error("500")
|
||||
client.head_object.side_effect = _client_error(error_code)
|
||||
storage = ArchiveStorage(bucket=BUCKET_NAME)
|
||||
|
||||
with pytest.raises(ArchiveStorageError, match="Failed to check archive object"):
|
||||
storage.object_exists("key")
|
||||
|
||||
|
||||
def test_object_exists_network_error_fails_closed(monkeypatch: pytest.MonkeyPatch):
|
||||
_configure_storage(monkeypatch)
|
||||
client, _ = _mock_client(monkeypatch)
|
||||
client.head_object.side_effect = _network_error()
|
||||
storage = ArchiveStorage(bucket=BUCKET_NAME)
|
||||
|
||||
with pytest.raises(ArchiveStorageError, match="Failed to check archive object"):
|
||||
storage.object_exists("key")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("error_code", ["403", "429", "500", "SlowDown"])
|
||||
def test_delete_object_error(monkeypatch: pytest.MonkeyPatch, error_code: str):
|
||||
_configure_storage(monkeypatch)
|
||||
client, _ = _mock_client(monkeypatch)
|
||||
client.delete_object.side_effect = _client_error(error_code)
|
||||
storage = ArchiveStorage(bucket=BUCKET_NAME)
|
||||
|
||||
with pytest.raises(ArchiveStorageError, match="Failed to delete object"):
|
||||
storage.delete_object("key")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("error_code", ["404", "NoSuchKey", "NotFound"])
|
||||
def test_delete_object_missing_is_idempotent(monkeypatch: pytest.MonkeyPatch, error_code: str):
|
||||
_configure_storage(monkeypatch)
|
||||
client, _ = _mock_client(monkeypatch)
|
||||
client.delete_object.side_effect = _client_error(error_code)
|
||||
storage = ArchiveStorage(bucket=BUCKET_NAME)
|
||||
|
||||
storage.delete_object("missing")
|
||||
|
||||
|
||||
def test_delete_object_network_error_fails_closed(monkeypatch: pytest.MonkeyPatch):
|
||||
_configure_storage(monkeypatch)
|
||||
client, _ = _mock_client(monkeypatch)
|
||||
client.delete_object.side_effect = _network_error()
|
||||
storage = ArchiveStorage(bucket=BUCKET_NAME)
|
||||
|
||||
with pytest.raises(ArchiveStorageError, match="Failed to delete object"):
|
||||
|
||||
+766
@@ -0,0 +1,766 @@
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from services.retention.workflow_run.bundle_archive_maintenance import (
|
||||
ARCHIVED_TABLES,
|
||||
ArchiveBundleCatalogEntry,
|
||||
BundleManifest,
|
||||
BundleOperationResult,
|
||||
BundleReference,
|
||||
WorkflowRunBundleArchiveMaintenance,
|
||||
)
|
||||
from services.retention.workflow_run.constants import (
|
||||
ARCHIVE_BUNDLE_DELETE_STARTED_MARKER_NAME,
|
||||
ARCHIVE_BUNDLE_DELETED_MARKER_NAME,
|
||||
ARCHIVE_BUNDLE_FORMAT,
|
||||
ARCHIVE_BUNDLE_RESTORE_STARTED_MARKER_NAME,
|
||||
ARCHIVE_BUNDLE_RESTORED_MARKER_NAME,
|
||||
ARCHIVE_BUNDLE_SCHEMA_VERSION,
|
||||
)
|
||||
|
||||
TENANT_ID = "1251fe32-c0c7-4fe2-a7bd-a8105267faf5"
|
||||
CATALOG_ID = "019f63b7-5ca4-7681-9ce0-800283608f39"
|
||||
BUNDLE_ID = "bundle-a"
|
||||
|
||||
|
||||
def _table_records(
|
||||
**overrides: list[dict[str, Any]],
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
records = {table_name: [] for table_name in ARCHIVED_TABLES}
|
||||
records.update(overrides)
|
||||
return records
|
||||
|
||||
|
||||
def _catalog_entry(*, catalog_id: str = CATALOG_ID, shard: str = "00-of-01") -> ArchiveBundleCatalogEntry:
|
||||
return ArchiveBundleCatalogEntry(
|
||||
catalog_id=catalog_id,
|
||||
tenant_id=TENANT_ID,
|
||||
year=2025,
|
||||
month=3,
|
||||
shard=shard,
|
||||
bundle_id=BUNDLE_ID,
|
||||
workflow_run_count=0,
|
||||
row_count=0,
|
||||
archive_bytes=0,
|
||||
)
|
||||
|
||||
|
||||
def _manifest(
|
||||
entry: ArchiveBundleCatalogEntry,
|
||||
*,
|
||||
bundle_id: str = BUNDLE_ID,
|
||||
table_records: dict[str, list[dict[str, Any]]] | None = None,
|
||||
) -> bytes:
|
||||
object_prefix = WorkflowRunBundleArchiveMaintenance._catalog_object_prefix(entry)
|
||||
records = table_records or _table_records()
|
||||
tables = {
|
||||
table_name: {
|
||||
"row_count": len(records[table_name]),
|
||||
"checksum": "",
|
||||
"size_bytes": 0,
|
||||
"object_key": f"{object_prefix}/{table_name}.parquet",
|
||||
}
|
||||
for table_name in (
|
||||
"workflow_runs",
|
||||
"workflow_app_logs",
|
||||
"workflow_node_executions",
|
||||
"workflow_node_execution_offload",
|
||||
"workflow_pauses",
|
||||
"workflow_pause_reasons",
|
||||
"workflow_trigger_logs",
|
||||
)
|
||||
}
|
||||
return json.dumps(
|
||||
{
|
||||
"schema_version": ARCHIVE_BUNDLE_SCHEMA_VERSION,
|
||||
"archive_format": ARCHIVE_BUNDLE_FORMAT,
|
||||
"tenant_id": entry.tenant_id,
|
||||
"tenant_prefix": entry.tenant_id[0],
|
||||
"year": entry.year,
|
||||
"month": entry.month,
|
||||
"shard": entry.shard,
|
||||
"bundle_id": bundle_id,
|
||||
"object_prefix": object_prefix,
|
||||
"workflow_run_count": len(records["workflow_runs"]),
|
||||
"workflow_node_execution_count": len(records["workflow_node_executions"]),
|
||||
"tables": tables,
|
||||
"run_ids": [str(record["id"]) for record in records["workflow_runs"]],
|
||||
}
|
||||
).encode()
|
||||
|
||||
|
||||
def _session_factory(session: MagicMock) -> MagicMock:
|
||||
factory = MagicMock()
|
||||
factory.return_value.__enter__.return_value = session
|
||||
return factory
|
||||
|
||||
|
||||
def _bundle_reference(
|
||||
entry: ArchiveBundleCatalogEntry,
|
||||
*,
|
||||
table_records: dict[str, list[dict[str, Any]]] | None = None,
|
||||
) -> BundleReference:
|
||||
manifest = cast(BundleManifest, json.loads(_manifest(entry, table_records=table_records)))
|
||||
return BundleReference(
|
||||
catalog=entry,
|
||||
object_prefix=manifest["object_prefix"],
|
||||
manifest_key=f"{manifest['object_prefix']}/manifest.json",
|
||||
manifest_size_bytes=0,
|
||||
manifest=manifest,
|
||||
)
|
||||
|
||||
|
||||
def _sample_archive_records() -> dict[str, list[dict[str, Any]]]:
|
||||
return _table_records(
|
||||
workflow_runs=[
|
||||
{"id": "run-1", "status": "succeeded"},
|
||||
{"id": "run-2", "status": "failed"},
|
||||
],
|
||||
workflow_app_logs=[
|
||||
{"id": "app-log-1", "workflow_run_id": "run-1"},
|
||||
],
|
||||
workflow_node_executions=[
|
||||
{"id": "node-1", "workflow_run_id": "run-1"},
|
||||
],
|
||||
workflow_node_execution_offload=[
|
||||
{"id": "offload-1", "node_execution_id": "node-1"},
|
||||
],
|
||||
workflow_pauses=[
|
||||
{"id": "pause-1", "workflow_run_id": "run-1"},
|
||||
],
|
||||
workflow_pause_reasons=[
|
||||
{"id": "reason-1", "pause_id": "pause-1"},
|
||||
],
|
||||
workflow_trigger_logs=[
|
||||
{"id": "trigger-1", "workflow_run_id": "run-1"},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_catalog_discovery_is_ordered_and_limited_before_storage_io() -> None:
|
||||
entry = _catalog_entry()
|
||||
bundle = SimpleNamespace(
|
||||
id=entry.catalog_id,
|
||||
tenant_id=entry.tenant_id,
|
||||
year=entry.year,
|
||||
month=entry.month,
|
||||
shard=entry.shard,
|
||||
bundle_id=entry.bundle_id,
|
||||
workflow_run_count=entry.workflow_run_count,
|
||||
row_count=entry.row_count,
|
||||
archive_bytes=entry.archive_bytes,
|
||||
)
|
||||
session = MagicMock()
|
||||
session.get.return_value = bundle
|
||||
session.scalars.return_value = [bundle]
|
||||
storage = MagicMock()
|
||||
maintenance = WorkflowRunBundleArchiveMaintenance(
|
||||
storage=cast(MagicMock, storage),
|
||||
session_factory=cast(MagicMock, _session_factory(session)),
|
||||
)
|
||||
|
||||
entries = maintenance._list_catalog_entries(
|
||||
tenant_ids=[TENANT_ID],
|
||||
target_year=2025,
|
||||
target_month=3,
|
||||
after_catalog_id=CATALOG_ID,
|
||||
limit=2,
|
||||
)
|
||||
|
||||
statement = session.scalars.call_args.args[0]
|
||||
rendered = str(statement)
|
||||
assert "workflow_run_archive_bundles.year" in rendered
|
||||
assert "workflow_run_archive_bundles.month" in rendered
|
||||
assert "workflow_run_archive_bundles.id >" in rendered
|
||||
assert "ORDER BY workflow_run_archive_bundles.id ASC" in rendered
|
||||
assert "LIMIT" in rendered
|
||||
assert entries == [entry]
|
||||
storage.list_objects.assert_not_called()
|
||||
|
||||
|
||||
def test_catalog_discovery_filters_and_validates_the_requested_shard() -> None:
|
||||
entry = _catalog_entry(shard="03-of-16")
|
||||
bundle = SimpleNamespace(
|
||||
id=entry.catalog_id,
|
||||
tenant_id=entry.tenant_id,
|
||||
year=entry.year,
|
||||
month=entry.month,
|
||||
shard=entry.shard,
|
||||
bundle_id=entry.bundle_id,
|
||||
workflow_run_count=entry.workflow_run_count,
|
||||
row_count=entry.row_count,
|
||||
archive_bytes=entry.archive_bytes,
|
||||
)
|
||||
session = MagicMock()
|
||||
session.get.return_value = bundle
|
||||
session.scalars.return_value = [bundle]
|
||||
maintenance = WorkflowRunBundleArchiveMaintenance(
|
||||
storage=cast(MagicMock, MagicMock()),
|
||||
session_factory=cast(MagicMock, _session_factory(session)),
|
||||
)
|
||||
|
||||
entries = maintenance._list_catalog_entries(
|
||||
tenant_ids=None,
|
||||
target_year=2025,
|
||||
target_month=3,
|
||||
after_catalog_id=CATALOG_ID,
|
||||
limit=2,
|
||||
shard="03-of-16",
|
||||
)
|
||||
|
||||
statement = session.scalars.call_args.args[0]
|
||||
rendered = str(statement)
|
||||
assert "workflow_run_archive_bundles.shard =" in rendered
|
||||
assert entries == [entry]
|
||||
|
||||
session.get.return_value = SimpleNamespace(
|
||||
year=2025,
|
||||
month=3,
|
||||
tenant_id=TENANT_ID,
|
||||
shard="04-of-16",
|
||||
)
|
||||
with pytest.raises(ValueError, match="requested archive shard"):
|
||||
maintenance._list_catalog_entries(
|
||||
tenant_ids=None,
|
||||
target_year=2025,
|
||||
target_month=3,
|
||||
after_catalog_id=CATALOG_ID,
|
||||
limit=2,
|
||||
shard="03-of-16",
|
||||
)
|
||||
|
||||
|
||||
def test_catalog_shard_preflight_rejects_mixed_layout_before_delete() -> None:
|
||||
session = MagicMock()
|
||||
session.scalars.return_value = ["00-of-01"]
|
||||
maintenance = WorkflowRunBundleArchiveMaintenance(
|
||||
storage=cast(MagicMock, MagicMock()),
|
||||
session_factory=cast(MagicMock, _session_factory(session)),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match=r"unexpected shards.*00-of-01"):
|
||||
maintenance.validate_catalog_shards(
|
||||
target_year=2025,
|
||||
target_month=3,
|
||||
shard_total=16,
|
||||
)
|
||||
|
||||
statement = session.scalars.call_args.args[0]
|
||||
rendered = str(statement)
|
||||
assert "workflow_run_archive_bundles.year" in rendered
|
||||
assert "workflow_run_archive_bundles.month" in rendered
|
||||
assert "workflow_run_archive_bundles.shard NOT IN" in rendered
|
||||
|
||||
|
||||
def test_catalog_shard_preflight_accepts_an_expected_subset() -> None:
|
||||
session = MagicMock()
|
||||
session.scalars.return_value = []
|
||||
maintenance = WorkflowRunBundleArchiveMaintenance(
|
||||
storage=cast(MagicMock, MagicMock()),
|
||||
session_factory=cast(MagicMock, _session_factory(session)),
|
||||
)
|
||||
|
||||
maintenance.validate_catalog_shards(
|
||||
target_year=2025,
|
||||
target_month=3,
|
||||
shard_total=16,
|
||||
)
|
||||
|
||||
|
||||
def test_catalog_shard_preflight_uses_requested_tenant_scope() -> None:
|
||||
session = MagicMock()
|
||||
session.scalars.return_value = []
|
||||
maintenance = WorkflowRunBundleArchiveMaintenance(
|
||||
storage=cast(MagicMock, MagicMock()),
|
||||
session_factory=cast(MagicMock, _session_factory(session)),
|
||||
)
|
||||
|
||||
maintenance.validate_catalog_shards(
|
||||
target_year=2025,
|
||||
target_month=3,
|
||||
shard_total=16,
|
||||
tenant_ids=[TENANT_ID],
|
||||
)
|
||||
|
||||
statement = session.scalars.call_args.args[0]
|
||||
assert "workflow_run_archive_bundles.tenant_id IN" in str(statement)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("cursor_bundle", "tenant_ids", "error_message"),
|
||||
[
|
||||
(None, None, "does not exist"),
|
||||
(
|
||||
SimpleNamespace(year=2024, month=3, tenant_id=TENANT_ID),
|
||||
None,
|
||||
"requested archive month",
|
||||
),
|
||||
(
|
||||
SimpleNamespace(year=2025, month=3, tenant_id="other-tenant"),
|
||||
[TENANT_ID],
|
||||
"requested tenant scope",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_catalog_discovery_rejects_cursor_outside_requested_scope(
|
||||
cursor_bundle: SimpleNamespace | None,
|
||||
tenant_ids: list[str] | None,
|
||||
error_message: str,
|
||||
) -> None:
|
||||
session = MagicMock()
|
||||
session.get.return_value = cursor_bundle
|
||||
maintenance = WorkflowRunBundleArchiveMaintenance(
|
||||
storage=cast(MagicMock, MagicMock()),
|
||||
session_factory=cast(MagicMock, _session_factory(session)),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match=error_message):
|
||||
maintenance._list_catalog_entries(
|
||||
tenant_ids=tenant_ids,
|
||||
target_year=2025,
|
||||
target_month=3,
|
||||
after_catalog_id=CATALOG_ID,
|
||||
limit=1,
|
||||
)
|
||||
|
||||
session.scalars.assert_not_called()
|
||||
|
||||
|
||||
def test_catalog_manifest_identity_mismatch_fails_closed() -> None:
|
||||
entry = _catalog_entry()
|
||||
storage = MagicMock()
|
||||
storage.get_object.return_value = _manifest(entry, bundle_id="other-bundle")
|
||||
maintenance = WorkflowRunBundleArchiveMaintenance(
|
||||
storage=cast(MagicMock, storage),
|
||||
session_factory=cast(MagicMock, _session_factory(MagicMock())),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="identity does not match catalog"):
|
||||
maintenance._build_bundle_reference(cast(MagicMock, storage), entry)
|
||||
|
||||
|
||||
def test_bundle_maintenance_locks_the_existing_catalog_row() -> None:
|
||||
entry = _catalog_entry()
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = entry.catalog_id
|
||||
|
||||
WorkflowRunBundleArchiveMaintenance._lock_catalog_entry(session, entry)
|
||||
|
||||
statement = session.scalar.call_args.args[0]
|
||||
rendered = str(statement)
|
||||
assert "workflow_run_archive_bundles.id" in rendered
|
||||
assert "workflow_run_archive_bundles.tenant_id" in rendered
|
||||
assert "FOR UPDATE" in rendered
|
||||
|
||||
|
||||
def test_failure_and_dry_run_do_not_return_a_persistable_cursor() -> None:
|
||||
entry = _catalog_entry()
|
||||
session = MagicMock()
|
||||
storage = MagicMock()
|
||||
maintenance = WorkflowRunBundleArchiveMaintenance(
|
||||
storage=cast(MagicMock, storage),
|
||||
session_factory=cast(MagicMock, _session_factory(session)),
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(maintenance, "_list_catalog_entries", return_value=[entry]),
|
||||
patch.object(maintenance, "_build_bundle_reference", side_effect=RuntimeError("manifest unavailable")),
|
||||
):
|
||||
failed_summary = maintenance.delete_batch(
|
||||
tenant_ids=None,
|
||||
target_year=2025,
|
||||
target_month=3,
|
||||
after_catalog_id=None,
|
||||
limit=1,
|
||||
)
|
||||
|
||||
assert failed_summary.bundles_failed == 1
|
||||
assert failed_summary.next_catalog_id is None
|
||||
assert failed_summary.preview_next_catalog_id is None
|
||||
|
||||
dry_run = WorkflowRunBundleArchiveMaintenance(
|
||||
dry_run=True,
|
||||
storage=cast(MagicMock, storage),
|
||||
session_factory=cast(MagicMock, _session_factory(session)),
|
||||
)
|
||||
bundle_ref = BundleReference(
|
||||
catalog=entry,
|
||||
object_prefix="object-prefix",
|
||||
manifest_key="manifest.json",
|
||||
manifest_size_bytes=0,
|
||||
manifest=cast(BundleManifest, {}),
|
||||
)
|
||||
successful_result = BundleOperationResult(
|
||||
catalog_id=entry.catalog_id,
|
||||
bundle_id=entry.bundle_id,
|
||||
tenant_id=entry.tenant_id,
|
||||
object_prefix=bundle_ref.object_prefix,
|
||||
success=True,
|
||||
)
|
||||
with (
|
||||
patch.object(dry_run, "_list_catalog_entries", return_value=[entry]),
|
||||
patch.object(dry_run, "_build_bundle_reference", return_value=bundle_ref),
|
||||
patch.object(dry_run, "_delete_bundle", return_value=successful_result),
|
||||
):
|
||||
dry_run_summary = dry_run.delete_batch(
|
||||
tenant_ids=None,
|
||||
target_year=2025,
|
||||
target_month=3,
|
||||
after_catalog_id=None,
|
||||
limit=1,
|
||||
)
|
||||
|
||||
assert dry_run_summary.next_catalog_id is None
|
||||
assert dry_run_summary.preview_next_catalog_id == entry.catalog_id
|
||||
|
||||
|
||||
def test_live_archive_subset_accepts_full_partial_and_absent_live_data() -> None:
|
||||
archive_records = _sample_archive_records()
|
||||
manifest = _bundle_reference(_catalog_entry(), table_records=archive_records).manifest
|
||||
partial_records = _table_records(
|
||||
workflow_node_execution_offload=archive_records["workflow_node_execution_offload"],
|
||||
workflow_pause_reasons=archive_records["workflow_pause_reasons"],
|
||||
)
|
||||
|
||||
for live_records in (archive_records, partial_records, _table_records()):
|
||||
WorkflowRunBundleArchiveMaintenance._validate_live_archive_subset(
|
||||
manifest,
|
||||
archive_records,
|
||||
live_records,
|
||||
)
|
||||
|
||||
|
||||
def test_live_archive_subset_rejects_extra_rows() -> None:
|
||||
archive_records = _sample_archive_records()
|
||||
manifest = _bundle_reference(_catalog_entry(), table_records=archive_records).manifest
|
||||
live_records = _table_records(
|
||||
workflow_app_logs=[
|
||||
{"id": "extra-app-log", "workflow_run_id": "run-1"},
|
||||
]
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="rows missing from archive for workflow_app_logs"):
|
||||
WorkflowRunBundleArchiveMaintenance._validate_live_archive_subset(
|
||||
manifest,
|
||||
archive_records,
|
||||
live_records,
|
||||
)
|
||||
|
||||
|
||||
def test_live_archive_subset_rejects_content_mismatch() -> None:
|
||||
archive_records = _sample_archive_records()
|
||||
manifest = _bundle_reference(_catalog_entry(), table_records=archive_records).manifest
|
||||
live_records = _table_records(
|
||||
workflow_runs=[
|
||||
{"id": "run-1", "status": "failed"},
|
||||
]
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="subset content checksum mismatch for workflow_runs"):
|
||||
WorkflowRunBundleArchiveMaintenance._validate_live_archive_subset(
|
||||
manifest,
|
||||
archive_records,
|
||||
live_records,
|
||||
)
|
||||
|
||||
|
||||
def test_live_bundle_scope_includes_archived_ids_and_indirect_children() -> None:
|
||||
archive_records = _sample_archive_records()
|
||||
manifest = _bundle_reference(_catalog_entry(), table_records=archive_records).manifest
|
||||
session = MagicMock()
|
||||
maintenance = WorkflowRunBundleArchiveMaintenance(
|
||||
session_factory=cast(MagicMock, _session_factory(session)),
|
||||
)
|
||||
|
||||
def select_live_parent_ids(_session, model, _run_ids):
|
||||
if model.__tablename__ == "workflow_node_executions":
|
||||
return ["live-node"]
|
||||
if model.__tablename__ == "workflow_pauses":
|
||||
return ["live-pause"]
|
||||
raise AssertionError(f"unexpected model: {model}")
|
||||
|
||||
with (
|
||||
patch.object(maintenance, "_select_ids_by_run_ids", side_effect=select_live_parent_ids),
|
||||
patch.object(maintenance, "_load_records_by_column", return_value=[]) as load_records,
|
||||
):
|
||||
maintenance._load_live_bundle_records(
|
||||
session,
|
||||
manifest,
|
||||
archive_records,
|
||||
lock=True,
|
||||
)
|
||||
|
||||
queries = [
|
||||
(
|
||||
call.args[1].__tablename__,
|
||||
call.args[2].key,
|
||||
set(call.args[3]),
|
||||
call.kwargs["lock"],
|
||||
)
|
||||
for call in load_records.call_args_list
|
||||
]
|
||||
assert ("workflow_pause_reasons", "pause_id", {"pause-1", "live-pause"}, True) in queries
|
||||
assert ("workflow_pause_reasons", "id", {"reason-1"}, True) in queries
|
||||
assert ("workflow_node_execution_offload", "node_execution_id", {"node-1", "live-node"}, True) in queries
|
||||
assert ("workflow_node_execution_offload", "id", {"offload-1"}, True) in queries
|
||||
assert ("workflow_app_logs", "workflow_run_id", {"run-1", "run-2"}, True) in queries
|
||||
assert ("workflow_app_logs", "id", {"app-log-1"}, True) in queries
|
||||
|
||||
|
||||
def test_delete_bundle_accepts_matching_partial_rows_and_deletes_only_that_subset() -> None:
|
||||
entry = _catalog_entry()
|
||||
archive_records = _sample_archive_records()
|
||||
bundle_ref = _bundle_reference(entry, table_records=archive_records)
|
||||
partial_records = _table_records(
|
||||
workflow_node_execution_offload=archive_records["workflow_node_execution_offload"],
|
||||
workflow_pause_reasons=archive_records["workflow_pause_reasons"],
|
||||
)
|
||||
expected_deleted_counts = {table_name: len(partial_records[table_name]) for table_name in ARCHIVED_TABLES}
|
||||
session = MagicMock()
|
||||
storage = MagicMock()
|
||||
maintenance = WorkflowRunBundleArchiveMaintenance(
|
||||
session_factory=cast(MagicMock, _session_factory(session)),
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(maintenance, "_is_restore_started", return_value=False),
|
||||
patch.object(maintenance, "_is_deleted", return_value=False),
|
||||
patch.object(
|
||||
maintenance,
|
||||
"_validate_archive_object",
|
||||
return_value=(bundle_ref.manifest, archive_records, 123),
|
||||
),
|
||||
patch.object(
|
||||
maintenance,
|
||||
"_load_live_bundle_records",
|
||||
side_effect=[partial_records, _table_records()],
|
||||
),
|
||||
patch.object(
|
||||
maintenance,
|
||||
"_delete_bundle_rows",
|
||||
return_value=expected_deleted_counts,
|
||||
) as delete_bundle_rows,
|
||||
patch.object(maintenance, "_put_marker"),
|
||||
patch.object(maintenance, "_mark_deleted") as mark_deleted,
|
||||
patch.object(maintenance, "_delete_marker"),
|
||||
):
|
||||
result = maintenance._delete_bundle(session, storage, bundle_ref)
|
||||
|
||||
assert result.success
|
||||
delete_bundle_rows.assert_called_once_with(session, partial_records)
|
||||
session.commit.assert_called_once_with()
|
||||
session.rollback.assert_not_called()
|
||||
mark_deleted.assert_called_once_with(storage, bundle_ref.object_prefix)
|
||||
|
||||
|
||||
def test_delete_bundle_marks_an_already_absent_source_without_deleting_rows() -> None:
|
||||
entry = _catalog_entry()
|
||||
archive_records = _sample_archive_records()
|
||||
bundle_ref = _bundle_reference(entry, table_records=archive_records)
|
||||
session = MagicMock()
|
||||
storage = MagicMock()
|
||||
maintenance = WorkflowRunBundleArchiveMaintenance(
|
||||
session_factory=cast(MagicMock, _session_factory(session)),
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(maintenance, "_is_restore_started", return_value=False),
|
||||
patch.object(maintenance, "_is_deleted", return_value=False),
|
||||
patch.object(
|
||||
maintenance,
|
||||
"_validate_archive_object",
|
||||
return_value=(bundle_ref.manifest, archive_records, 123),
|
||||
),
|
||||
patch.object(maintenance, "_load_live_bundle_records", return_value=_table_records()),
|
||||
patch.object(maintenance, "_delete_bundle_rows") as delete_bundle_rows,
|
||||
patch.object(maintenance, "_mark_deleted") as mark_deleted,
|
||||
patch.object(maintenance, "_delete_marker") as delete_marker,
|
||||
):
|
||||
result = maintenance._delete_bundle(session, storage, bundle_ref)
|
||||
|
||||
assert result.success
|
||||
delete_bundle_rows.assert_not_called()
|
||||
session.commit.assert_not_called()
|
||||
session.rollback.assert_not_called()
|
||||
mark_deleted.assert_called_once_with(storage, bundle_ref.object_prefix)
|
||||
assert delete_marker.call_count == 2
|
||||
|
||||
|
||||
def test_delete_bundle_with_deleted_marker_rejects_remaining_orphan_children() -> None:
|
||||
entry = _catalog_entry()
|
||||
archive_records = _sample_archive_records()
|
||||
bundle_ref = _bundle_reference(entry, table_records=archive_records)
|
||||
live_records = _table_records(
|
||||
workflow_node_execution_offload=archive_records["workflow_node_execution_offload"],
|
||||
)
|
||||
session = MagicMock()
|
||||
storage = MagicMock()
|
||||
maintenance = WorkflowRunBundleArchiveMaintenance(
|
||||
session_factory=cast(MagicMock, _session_factory(session)),
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(maintenance, "_is_restore_started", return_value=False),
|
||||
patch.object(maintenance, "_is_deleted", return_value=True),
|
||||
patch.object(
|
||||
maintenance,
|
||||
"_validate_archive_object",
|
||||
return_value=(bundle_ref.manifest, archive_records, 123),
|
||||
),
|
||||
patch.object(maintenance, "_load_live_bundle_records", return_value=live_records),
|
||||
patch.object(maintenance, "_delete_bundle_rows") as delete_bundle_rows,
|
||||
):
|
||||
result = maintenance._delete_bundle(session, storage, bundle_ref)
|
||||
|
||||
assert not result.success
|
||||
assert "Live rows exist for bundle with deleted marker" in result.error
|
||||
delete_bundle_rows.assert_not_called()
|
||||
session.commit.assert_not_called()
|
||||
session.rollback.assert_called_once_with()
|
||||
|
||||
|
||||
def test_delete_bundle_rejects_an_in_progress_restore() -> None:
|
||||
entry = _catalog_entry()
|
||||
bundle_ref = _bundle_reference(entry)
|
||||
session = MagicMock()
|
||||
storage = MagicMock()
|
||||
maintenance = WorkflowRunBundleArchiveMaintenance(
|
||||
session_factory=cast(MagicMock, _session_factory(session)),
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(maintenance, "_is_restore_started", return_value=True),
|
||||
patch.object(maintenance, "_validate_archive_object") as validate_archive,
|
||||
):
|
||||
result = maintenance._delete_bundle(session, storage, bundle_ref)
|
||||
|
||||
assert not result.success
|
||||
assert "reconcile restore before delete" in result.error
|
||||
validate_archive.assert_not_called()
|
||||
session.commit.assert_not_called()
|
||||
session.rollback.assert_called_once_with()
|
||||
|
||||
|
||||
def test_delete_bundle_rows_use_only_verified_primary_keys() -> None:
|
||||
live_records = _sample_archive_records()
|
||||
session = MagicMock()
|
||||
maintenance = WorkflowRunBundleArchiveMaintenance(
|
||||
session_factory=cast(MagicMock, _session_factory(session)),
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
maintenance,
|
||||
"_delete_by_column",
|
||||
side_effect=lambda _session, _model, _column, values: len(values),
|
||||
) as delete_by_column:
|
||||
deleted_counts = maintenance._delete_bundle_rows(session, live_records)
|
||||
|
||||
expected_table_order = [
|
||||
"workflow_pause_reasons",
|
||||
"workflow_node_execution_offload",
|
||||
"workflow_trigger_logs",
|
||||
"workflow_app_logs",
|
||||
"workflow_node_executions",
|
||||
"workflow_pauses",
|
||||
"workflow_runs",
|
||||
]
|
||||
assert [call.args[1].__tablename__ for call in delete_by_column.call_args_list] == expected_table_order
|
||||
assert all(call.args[2].key == "id" for call in delete_by_column.call_args_list)
|
||||
assert deleted_counts == {table_name: len(live_records[table_name]) for table_name in ARCHIVED_TABLES}
|
||||
|
||||
|
||||
def test_restore_does_not_skip_an_interrupted_delete_without_deleted_marker() -> None:
|
||||
entry = _catalog_entry()
|
||||
session = MagicMock()
|
||||
maintenance = WorkflowRunBundleArchiveMaintenance(
|
||||
storage=cast(MagicMock, MagicMock()),
|
||||
session_factory=cast(MagicMock, _session_factory(session)),
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(maintenance, "_is_deleted", return_value=False),
|
||||
patch.object(maintenance, "_is_delete_started", return_value=True),
|
||||
patch.object(maintenance, "_validate_live_counts") as validate_live_counts,
|
||||
):
|
||||
result = maintenance._restore_bundle(session, MagicMock(), _bundle_reference(entry))
|
||||
|
||||
assert not result.success
|
||||
assert "reconcile delete first" in result.error
|
||||
validate_live_counts.assert_not_called()
|
||||
session.commit.assert_not_called()
|
||||
|
||||
|
||||
def test_restore_does_not_skip_missing_source_rows_without_deleted_marker() -> None:
|
||||
entry = _catalog_entry()
|
||||
session = MagicMock()
|
||||
maintenance = WorkflowRunBundleArchiveMaintenance(
|
||||
storage=cast(MagicMock, MagicMock()),
|
||||
session_factory=cast(MagicMock, _session_factory(session)),
|
||||
)
|
||||
bundle_ref = _bundle_reference(entry)
|
||||
|
||||
with (
|
||||
patch.object(maintenance, "_is_deleted", return_value=False),
|
||||
patch.object(maintenance, "_is_delete_started", return_value=False),
|
||||
patch.object(
|
||||
maintenance,
|
||||
"_validate_live_counts",
|
||||
side_effect=ValueError("source rows are missing"),
|
||||
) as validate_live_counts,
|
||||
):
|
||||
result = maintenance._restore_bundle(session, MagicMock(), bundle_ref)
|
||||
|
||||
assert not result.success
|
||||
assert "source rows are missing" in result.error
|
||||
validate_live_counts.assert_called_once_with(session, bundle_ref.manifest, expected_present=True)
|
||||
session.commit.assert_not_called()
|
||||
|
||||
|
||||
def test_restore_reconciles_a_started_marker_after_the_source_commit() -> None:
|
||||
entry = _catalog_entry()
|
||||
session = MagicMock()
|
||||
storage = MagicMock()
|
||||
maintenance = WorkflowRunBundleArchiveMaintenance(
|
||||
storage=cast(MagicMock, storage),
|
||||
session_factory=cast(MagicMock, _session_factory(session)),
|
||||
)
|
||||
bundle_ref = _bundle_reference(entry)
|
||||
|
||||
with (
|
||||
patch.object(maintenance, "_is_deleted", return_value=False),
|
||||
patch.object(maintenance, "_is_delete_started", return_value=False),
|
||||
patch.object(maintenance, "_is_restore_started", return_value=True),
|
||||
patch.object(maintenance, "_validate_live_counts") as validate_live_counts,
|
||||
patch.object(maintenance, "_mark_restored") as mark_restored,
|
||||
):
|
||||
result = maintenance._restore_bundle(session, storage, bundle_ref)
|
||||
|
||||
assert result.success
|
||||
validate_live_counts.assert_called_once_with(session, bundle_ref.manifest, expected_present=True)
|
||||
mark_restored.assert_called_once_with(storage, bundle_ref.object_prefix)
|
||||
session.commit.assert_not_called()
|
||||
|
||||
|
||||
def test_mark_restored_clears_stale_delete_marker_before_releasing_restore_fence() -> None:
|
||||
storage = MagicMock()
|
||||
object_prefix = "bundle-prefix"
|
||||
operations = MagicMock()
|
||||
|
||||
with (
|
||||
patch.object(WorkflowRunBundleArchiveMaintenance, "_delete_marker") as delete_marker,
|
||||
patch.object(WorkflowRunBundleArchiveMaintenance, "_put_marker") as put_marker,
|
||||
):
|
||||
operations.attach_mock(delete_marker, "delete")
|
||||
operations.attach_mock(put_marker, "put")
|
||||
WorkflowRunBundleArchiveMaintenance._mark_restored(storage, object_prefix)
|
||||
|
||||
assert operations.mock_calls == [
|
||||
call.delete(storage, object_prefix, ARCHIVE_BUNDLE_DELETED_MARKER_NAME),
|
||||
call.put(storage, object_prefix, ARCHIVE_BUNDLE_RESTORED_MARKER_NAME),
|
||||
call.delete(storage, object_prefix, ARCHIVE_BUNDLE_DELETE_STARTED_MARKER_NAME),
|
||||
call.delete(storage, object_prefix, ARCHIVE_BUNDLE_RESTORE_STARTED_MARKER_NAME),
|
||||
]
|
||||
@@ -631,7 +631,9 @@ class TestDatasetServiceCreationAndUpdate:
|
||||
get_external_knowledge_api.assert_called_once_with("api-1", dataset.tenant_id, session=session)
|
||||
update_binding.assert_called_once_with("dataset-1", "knowledge-1", "api-1", session)
|
||||
session.add.assert_called_once_with(dataset)
|
||||
session.commit.assert_called_once()
|
||||
# flush() (not commit()) preserves the caller-managed transaction (#39191)
|
||||
session.flush.assert_called_once()
|
||||
session.commit.assert_not_called()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("payload", "message"),
|
||||
@@ -728,7 +730,9 @@ class TestDatasetServiceCreationAndUpdate:
|
||||
assert "external_knowledge_api_id" not in updated_values
|
||||
assert "external_knowledge_id" not in updated_values
|
||||
assert "external_retrieval_model" not in updated_values
|
||||
session.commit.assert_called_once()
|
||||
# flush() (not commit()) preserves the caller-managed transaction (#39191)
|
||||
session.flush.assert_called_once()
|
||||
session.commit.assert_not_called()
|
||||
session.refresh.assert_called_once_with(dataset)
|
||||
update_pipeline.assert_called_once_with(dataset, "user-1", session)
|
||||
vector_task.delay.assert_called_once_with("dataset-1", "update")
|
||||
|
||||
@@ -261,11 +261,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/account/oauth/authorize/page.tsx": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app-sidebar/app-info/app-info-modals.tsx": {
|
||||
"jsx_a11y/label-has-associated-control": {
|
||||
"count": 1
|
||||
@@ -1599,11 +1594,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/icons/src/vender/solid/arrows/index.ts": {
|
||||
"no-barrel-files/no-barrel-files": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/icons/src/vender/solid/communication/index.ts": {
|
||||
"no-barrel-files/no-barrel-files": {
|
||||
"count": 6
|
||||
@@ -2117,11 +2107,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/search-input/__tests__/index.spec.tsx": {
|
||||
"jsx_a11y/no-autofocus": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/search-input/index.stories.tsx": {
|
||||
"jsx_a11y/label-has-associated-control": {
|
||||
"count": 3
|
||||
@@ -3351,11 +3336,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/marketplace/search-box/index.tsx": {
|
||||
"jsx_a11y/no-autofocus": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/marketplace/search-box/tags-filter.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@@ -4110,59 +4090,16 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/block-selector/all-tools.tsx": {
|
||||
"jsx_a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx_a11y/no-static-element-interactions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/block-selector/constants.tsx": {
|
||||
"react/only-export-components": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/block-selector/featured-tools.tsx": {
|
||||
"jsx_a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx_a11y/no-static-element-interactions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/block-selector/featured-triggers.tsx": {
|
||||
"jsx_a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx_a11y/no-static-element-interactions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/block-selector/hooks.ts": {
|
||||
"eslint-react/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/block-selector/index-bar.tsx": {
|
||||
"react/only-export-components": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/block-selector/main.tsx": {
|
||||
"jsx_a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx_a11y/no-autofocus": {
|
||||
"count": 4
|
||||
},
|
||||
"jsx_a11y/no-static-element-interactions": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/block-selector/market-place-plugin/list.tsx": {
|
||||
"jsx_a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
@@ -4171,14 +4108,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/block-selector/rag-tool-recommendations/index.tsx": {
|
||||
"jsx_a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx_a11y/no-static-element-interactions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/block-selector/rag-tool-recommendations/uninstalled-item.tsx": {
|
||||
"jsx_a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
@@ -4187,19 +4116,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/block-selector/snippets/index.tsx": {
|
||||
"jsx_a11y/no-autofocus": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/block-selector/tabs.tsx": {
|
||||
"jsx_a11y/click-events-have-key-events": {
|
||||
"count": 2
|
||||
},
|
||||
"jsx_a11y/no-static-element-interactions": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/block-selector/tool-picker.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@@ -4213,11 +4129,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/block-selector/types.ts": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/block-selector/use-sticky-scroll.ts": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 1
|
||||
@@ -5674,11 +5585,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/operator/add-block.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/operator/hooks.ts": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 1
|
||||
@@ -6702,11 +6608,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/service/use-oauth.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/service/use-pipeline.ts": {
|
||||
"@tanstack/query/exhaustive-deps": {
|
||||
"count": 1
|
||||
|
||||
@@ -12,7 +12,7 @@ const meta = {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Unstyled Base UI Collapsible primitive. The examples mirror the official Root, Trigger, and Panel anatomy, with presentation supplied at the call site using Dify UI tokens.',
|
||||
'Styled Dify disclosure wrapper over Base UI Collapsible. It preserves the official Root, Trigger, and Panel anatomy while providing Dify layout, focus, disabled, and motion styles.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -10,91 +10,56 @@ describe('PreviewCardContent', () => {
|
||||
it('should use bottom placement and default offsets when placement props are not provided', async () => {
|
||||
const screen = await renderWithSafeViewport(
|
||||
<PreviewCard open>
|
||||
<PreviewCardTrigger
|
||||
render={
|
||||
<button type="button" aria-label="preview trigger">
|
||||
Open
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<PreviewCardTrigger href="#default-preview">Open</PreviewCardTrigger>
|
||||
<PreviewCardContent
|
||||
positionerProps={{ role: 'group', 'aria-label': 'default positioner' }}
|
||||
popupProps={{ role: 'dialog', 'aria-label': 'default popup' }}
|
||||
positionerProps={{ id: 'default-positioner' }}
|
||||
popupProps={{ id: 'default-popup' }}
|
||||
>
|
||||
<span>Default content</span>
|
||||
</PreviewCardContent>
|
||||
</PreviewCard>,
|
||||
)
|
||||
|
||||
await expect
|
||||
.element(screen.getByRole('group', { name: 'default positioner' }))
|
||||
.toHaveAttribute('data-side', 'bottom')
|
||||
await expect
|
||||
.element(screen.getByRole('group', { name: 'default positioner' }))
|
||||
.toHaveAttribute('data-align', 'center')
|
||||
await expect
|
||||
.element(screen.getByRole('dialog', { name: 'default popup' }))
|
||||
.toHaveTextContent('Default content')
|
||||
await expect.element(screen.getByText('Default content')).toBeInTheDocument()
|
||||
expect(document.getElementById('default-positioner')).toHaveAttribute('data-side', 'bottom')
|
||||
expect(document.getElementById('default-positioner')).toHaveAttribute('data-align', 'center')
|
||||
expect(document.getElementById('default-popup')).toHaveTextContent('Default content')
|
||||
})
|
||||
|
||||
it('should apply parsed custom placement and custom offsets when placement props are provided', async () => {
|
||||
const screen = await renderWithSafeViewport(
|
||||
<PreviewCard open>
|
||||
<PreviewCardTrigger
|
||||
render={
|
||||
<button type="button" aria-label="preview trigger">
|
||||
Open
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<PreviewCardTrigger href="#custom-preview">Open</PreviewCardTrigger>
|
||||
<PreviewCardContent
|
||||
placement="top-end"
|
||||
sideOffset={14}
|
||||
alignOffset={6}
|
||||
positionerProps={{ role: 'group', 'aria-label': 'custom positioner' }}
|
||||
popupProps={{ role: 'dialog', 'aria-label': 'custom popup' }}
|
||||
positionerProps={{ id: 'custom-positioner' }}
|
||||
popupProps={{ id: 'custom-popup' }}
|
||||
>
|
||||
<span>Custom placement content</span>
|
||||
</PreviewCardContent>
|
||||
</PreviewCard>,
|
||||
)
|
||||
|
||||
await expect
|
||||
.element(screen.getByRole('group', { name: 'custom positioner' }))
|
||||
.toHaveAttribute('data-side', 'top')
|
||||
await expect
|
||||
.element(screen.getByRole('group', { name: 'custom positioner' }))
|
||||
.toHaveAttribute('data-align', 'end')
|
||||
await expect
|
||||
.element(screen.getByRole('dialog', { name: 'custom popup' }))
|
||||
.toHaveTextContent('Custom placement content')
|
||||
await expect.element(screen.getByText('Custom placement content')).toBeInTheDocument()
|
||||
expect(document.getElementById('custom-positioner')).toHaveAttribute('data-side', 'top')
|
||||
expect(document.getElementById('custom-positioner')).toHaveAttribute('data-align', 'end')
|
||||
expect(document.getElementById('custom-popup')).toHaveTextContent('Custom placement content')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Passthrough props', () => {
|
||||
it('should forward positionerProps and popupProps when passthrough props are provided', async () => {
|
||||
const onPopupClick = vi.fn()
|
||||
|
||||
const screen = await render(
|
||||
<PreviewCard open>
|
||||
<PreviewCardTrigger
|
||||
render={
|
||||
<button type="button" aria-label="preview trigger">
|
||||
Open
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<PreviewCardTrigger href="#passthrough-preview">Open</PreviewCardTrigger>
|
||||
<PreviewCardContent
|
||||
positionerProps={{
|
||||
role: 'group',
|
||||
'aria-label': 'preview positioner',
|
||||
id: 'preview-positioner-id',
|
||||
}}
|
||||
popupProps={{
|
||||
id: 'preview-popup-id',
|
||||
role: 'dialog',
|
||||
'aria-label': 'preview content',
|
||||
onClick: onPopupClick,
|
||||
}}
|
||||
>
|
||||
<span>Preview body</span>
|
||||
@@ -102,40 +67,26 @@ describe('PreviewCardContent', () => {
|
||||
</PreviewCard>,
|
||||
)
|
||||
|
||||
const popup = screen.getByRole('dialog', { name: 'preview content' })
|
||||
await popup.click()
|
||||
|
||||
await expect
|
||||
.element(screen.getByRole('group', { name: 'preview positioner' }))
|
||||
.toHaveAttribute('id', 'preview-positioner-id')
|
||||
await expect.element(popup).toHaveAttribute('id', 'preview-popup-id')
|
||||
expect(onPopupClick).toHaveBeenCalledTimes(1)
|
||||
await expect.element(screen.getByText('Preview body')).toBeInTheDocument()
|
||||
expect(document.getElementById('preview-positioner-id')).toBeInTheDocument()
|
||||
expect(document.getElementById('preview-popup-id')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Trigger click behavior', () => {
|
||||
it('should forward the trigger click to the consumer handler so the primary action runs', async () => {
|
||||
const onPrimaryClick = vi.fn()
|
||||
|
||||
describe('Trigger semantics', () => {
|
||||
it('should preserve the link destination', async () => {
|
||||
const screen = await renderWithSafeViewport(
|
||||
<PreviewCard>
|
||||
<PreviewCardTrigger
|
||||
render={
|
||||
<button type="button" aria-label="preview trigger" onClick={onPrimaryClick}>
|
||||
Open
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<PreviewCardContent popupProps={{ role: 'dialog', 'aria-label': 'preview content' }}>
|
||||
<PreviewCardTrigger href="/preview-destination">Preview destination</PreviewCardTrigger>
|
||||
<PreviewCardContent>
|
||||
<span>Preview body</span>
|
||||
</PreviewCardContent>
|
||||
</PreviewCard>,
|
||||
)
|
||||
|
||||
const trigger = screen.getByRole('button', { name: 'preview trigger' })
|
||||
await trigger.click()
|
||||
|
||||
expect(onPrimaryClick).toHaveBeenCalledTimes(1)
|
||||
await expect
|
||||
.element(screen.getByRole('link', { name: 'Preview destination' }))
|
||||
.toHaveAttribute('href', '/preview-destination')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,9 +3,6 @@ import type { Placement } from '.'
|
||||
import * as React from 'react'
|
||||
import { createPreviewCardHandle, PreviewCard, PreviewCardContent, PreviewCardTrigger } from '.'
|
||||
|
||||
const rowButtonClassName =
|
||||
'flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm text-text-secondary outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid'
|
||||
|
||||
const triggerButtonClassName =
|
||||
'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid'
|
||||
|
||||
@@ -20,7 +17,7 @@ const meta = {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Hover- and focus-activated rich preview for triggers whose primary click has its own destination (following a link, selecting a row, jumping to a definition). Built on Base UI PreviewCard.\n\n**A11y contract:** touch and screen-reader users cannot open the preview. Never place information or actions in the popup that are not also reachable from the trigger's primary click destination. If that is unavoidable, add a separate click affordance (Popover) or move the unique content onto the destination.",
|
||||
'Hover- and focus-activated rich link preview built on Base UI PreviewCard.\n\n**A11y contract:** touch and screen-reader users cannot open the preview. Keep popup content available on the link destination. A polymorphic action trigger is a Dify application-level extension and is only valid when its click result exposes the same information.',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -85,38 +82,6 @@ export const LinkPreview: Story = {
|
||||
),
|
||||
}
|
||||
|
||||
export const Supplementary: Story = {
|
||||
name: 'Supplementary preview on a button trigger',
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Application-level adaptation of the same semantic: the trigger is a `<button>` that owns a primary action (selecting a model row) rather than an `<a>`. The preview still only shows supplementary info reachable from the selection destination, so the a11y contract holds.',
|
||||
},
|
||||
},
|
||||
},
|
||||
render: () => (
|
||||
<PreviewCard>
|
||||
<PreviewCardTrigger
|
||||
render={
|
||||
<button type="button" className={rowButtonClassName}>
|
||||
<span className="i-ri-sparkling-fill h-4 w-4 text-text-accent" />
|
||||
<span>gpt-4o</span>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<PreviewCardContent placement="right" popupClassName="w-[220px] p-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-sm font-medium text-text-primary">gpt-4o</div>
|
||||
<div className="text-xs text-text-tertiary">
|
||||
Multimodal flagship model. Vision, audio and 128k context.
|
||||
</div>
|
||||
</div>
|
||||
</PreviewCardContent>
|
||||
</PreviewCard>
|
||||
),
|
||||
}
|
||||
|
||||
const PLACEMENTS: Placement[] = [
|
||||
'top-start',
|
||||
'top',
|
||||
@@ -152,13 +117,9 @@ const PlacementsDemo = () => {
|
||||
))}
|
||||
</div>
|
||||
<PreviewCard open>
|
||||
<PreviewCardTrigger
|
||||
render={
|
||||
<button type="button" className={triggerButtonClassName}>
|
||||
Hover me
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<PreviewCardTrigger href="#preview-card-placement" className={triggerButtonClassName}>
|
||||
Hover me
|
||||
</PreviewCardTrigger>
|
||||
<PreviewCardContent placement={placement} popupClassName="w-56 p-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-sm font-semibold text-text-primary">
|
||||
@@ -187,12 +148,11 @@ const CustomDelayDemo = () => (
|
||||
<PreviewCardTrigger
|
||||
delay={100}
|
||||
closeDelay={100}
|
||||
render={
|
||||
<button type="button" className={triggerButtonClassName}>
|
||||
Snappy trigger
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
href="#preview-card-delay"
|
||||
className={triggerButtonClassName}
|
||||
>
|
||||
Snappy trigger
|
||||
</PreviewCardTrigger>
|
||||
<PreviewCardContent popupClassName="w-64 p-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-sm font-semibold text-text-primary">Fast hover</div>
|
||||
|
||||
@@ -11,20 +11,22 @@ export type { Placement }
|
||||
|
||||
/**
|
||||
* PreviewCard is a hover/focus-triggered rich preview intended to supplement a
|
||||
* trigger whose primary action is its own click destination (e.g. a link, a
|
||||
* selectable row, a chip that jumps to a definition).
|
||||
* link. Base UI's canonical trigger renders an anchor.
|
||||
*
|
||||
* A11y contract — match Base UI's guidance:
|
||||
* - The popup MUST NOT contain information or actions that are not also
|
||||
* reachable from the trigger's primary click destination. Touch and screen
|
||||
* reader users cannot open the card and must be able to get the same
|
||||
* information/actions without it.
|
||||
* reachable from the link destination. Touch and screen reader users cannot
|
||||
* open the card and must be able to get the same information/actions without
|
||||
* it.
|
||||
* - A polymorphic action trigger is an application-level extension and is only
|
||||
* valid when its primary click result exposes the same information.
|
||||
* - If content is unique to the popup, either (a) add a separate click-triggered
|
||||
* affordance (Popover) next to the trigger, or (b) move the unique content
|
||||
* onto the click destination.
|
||||
*/
|
||||
export const PreviewCard = BasePreviewCard.Root
|
||||
export const PreviewCardTrigger = BasePreviewCard.Trigger
|
||||
export const PreviewCardViewport = BasePreviewCard.Viewport
|
||||
export const createPreviewCardHandle = BasePreviewCard.createHandle
|
||||
|
||||
type PreviewCardContentProps = {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import OAuthAuthorize from '../page'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
push: vi.fn(),
|
||||
request: vi.fn(),
|
||||
searchParams: new URLSearchParams(),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({ push: mocks.push }),
|
||||
useSearchParams: () => mocks.searchParams,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/base', () => ({
|
||||
get: vi.fn(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
avatar_url: null,
|
||||
email: '[email protected]',
|
||||
name: 'Test User',
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
),
|
||||
post: vi.fn(),
|
||||
request: (...args: unknown[]) => mocks.request(...args),
|
||||
sseGeneratorPost: vi.fn(),
|
||||
}))
|
||||
|
||||
function renderPage() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
mutations: { retry: false },
|
||||
queries: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<OAuthAuthorize />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
function findRequest(path: string) {
|
||||
return mocks.request.mock.calls.find(([url]) => String(url).endsWith(path))
|
||||
}
|
||||
|
||||
describe('OAuthAuthorize', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.searchParams = new URLSearchParams({
|
||||
client_id: 'client-1',
|
||||
redirect_uri: 'https://client.example.com/callback?state=state-1',
|
||||
})
|
||||
mocks.request.mockImplementation(async (url: string) => {
|
||||
if (url.endsWith('/oauth/provider/authorize')) return jsonResponse({ code: 'oauth-code' })
|
||||
if (url.endsWith('/oauth/provider')) {
|
||||
return jsonResponse({
|
||||
app_icon: '',
|
||||
app_label: { en_US: 'Test OAuth App' },
|
||||
scope: '',
|
||||
})
|
||||
}
|
||||
throw new Error(`Unexpected request: ${url}`)
|
||||
})
|
||||
vi.stubGlobal('location', {
|
||||
href: 'https://dify.test/account/oauth/authorize',
|
||||
origin: 'https://dify.test',
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('authorizes the displayed app and redirects with the returned code', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
expect((await screen.findAllByText('Test OAuth App')).length).toBeGreaterThan(0)
|
||||
const providerRequest = findRequest('/oauth/provider')
|
||||
const providerTransportRequest = providerRequest?.[2]?.request as Request
|
||||
await expect(providerTransportRequest.clone().json()).resolves.toEqual({
|
||||
client_id: 'client-1',
|
||||
redirect_uri: 'https://client.example.com/callback?state=state-1',
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /continue/i }))
|
||||
|
||||
await waitFor(() => expect(findRequest('/oauth/provider/authorize')).toBeDefined())
|
||||
const authorizeRequest = findRequest('/oauth/provider/authorize')
|
||||
const transportRequest = authorizeRequest?.[2]?.request as Request
|
||||
await expect(transportRequest.clone().json()).resolves.toEqual({ client_id: 'client-1' })
|
||||
await waitFor(() =>
|
||||
expect(globalThis.location.href).toBe(
|
||||
'https://client.example.com/callback?state=state-1&code=oauth-code',
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
RiMailLine,
|
||||
RiTranslate2,
|
||||
} from '@remixicon/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { skipToken, useMutation, useQuery } from '@tanstack/react-query'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -18,8 +18,8 @@ import Loading from '@/app/components/base/loading'
|
||||
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import { isLegacyBase401, userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { useLogout } from '@/service/use-common'
|
||||
import { useAuthorizeOAuthApp, useOAuthAppInfo } from '@/service/use-oauth'
|
||||
|
||||
function buildReturnUrl(pathname: string, search: string) {
|
||||
try {
|
||||
@@ -64,6 +64,7 @@ export default function OAuthAuthorize() {
|
||||
const searchParams = useSearchParams()
|
||||
const client_id = decodeURIComponent(searchParams.get('client_id') || '')
|
||||
const redirect_uri = decodeURIComponent(searchParams.get('redirect_uri') || '')
|
||||
const hasOAuthParams = Boolean(client_id && redirect_uri)
|
||||
// Probe user profile. 401 stays as `error` (legitimate "not logged in" state),
|
||||
// other errors throw to the nearest error.tsx; jumpTo same-pathname guard in
|
||||
// service/base.ts prevents a redirect loop here.
|
||||
@@ -81,10 +82,23 @@ export default function OAuthAuthorize() {
|
||||
data: authAppInfo,
|
||||
isLoading: isOAuthLoading,
|
||||
isError,
|
||||
} = useOAuthAppInfo(client_id, redirect_uri)
|
||||
const { mutateAsync: authorize, isPending: authorizing } = useAuthorizeOAuthApp()
|
||||
} = useQuery(
|
||||
consoleQuery.oauth.provider.post.queryOptions({
|
||||
input: hasOAuthParams ? { body: { client_id, redirect_uri } } : skipToken,
|
||||
context: { silent: true },
|
||||
}),
|
||||
)
|
||||
const { mutateAsync: authorize, isPending: authorizing } = useMutation(
|
||||
consoleQuery.oauth.provider.authorize.post.mutationOptions(),
|
||||
)
|
||||
const { mutateAsync: logout } = useLogout()
|
||||
const hasNotifiedRef = useRef(false)
|
||||
const localizedAppLabel = authAppInfo?.app_label[language]
|
||||
const englishAppLabel = authAppInfo?.app_label.en_US
|
||||
const appLabel =
|
||||
(typeof localizedAppLabel === 'string' && localizedAppLabel) ||
|
||||
(typeof englishAppLabel === 'string' && englishAppLabel) ||
|
||||
t(($) => $.unknownApp, { ns: 'oauth' })
|
||||
|
||||
const isLoading = isOAuthLoading || isProfileLoading
|
||||
const onLoginSwitchClick = async () => {
|
||||
@@ -100,12 +114,13 @@ export default function OAuthAuthorize() {
|
||||
const onAuthorize = async () => {
|
||||
if (!client_id || !redirect_uri) return
|
||||
try {
|
||||
const { code } = await authorize({ client_id })
|
||||
const { code } = await authorize({ body: { client_id } })
|
||||
const url = new URL(redirect_uri)
|
||||
url.searchParams.set('code', code)
|
||||
globalThis.location.href = url.toString()
|
||||
} catch (err: any) {
|
||||
toast.error(`${t(($) => $['error.authorizeFailed'], { ns: 'oauth' })}: ${err.message}`)
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
toast.error(`${t(($) => $['error.authorizeFailed'], { ns: 'oauth' })}: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,11 +158,7 @@ export default function OAuthAuthorize() {
|
||||
{isLoggedIn && (
|
||||
<div className="text-text-primary">{t(($) => $.connect, { ns: 'oauth' })}</div>
|
||||
)}
|
||||
<div className="text-saas-dify-blue-inverted">
|
||||
{authAppInfo?.app_label[language] ||
|
||||
authAppInfo?.app_label?.en_US ||
|
||||
t(($) => $.unknownApp, { ns: 'oauth' })}
|
||||
</div>
|
||||
<div className="text-saas-dify-blue-inverted">{appLabel}</div>
|
||||
{!isLoggedIn && (
|
||||
<div className="text-text-primary">
|
||||
{t(($) => $['tips.notLoggedIn'], { ns: 'oauth' })}
|
||||
@@ -156,7 +167,7 @@ export default function OAuthAuthorize() {
|
||||
</div>
|
||||
<div className="body-md-regular text-text-secondary">
|
||||
{isLoggedIn
|
||||
? `${authAppInfo?.app_label[language] || authAppInfo?.app_label?.en_US || t(($) => $.unknownApp, { ns: 'oauth' })} ${t(($) => $['tips.loggedIn'], { ns: 'oauth' })}`
|
||||
? `${appLabel} ${t(($) => $['tips.loggedIn'], { ns: 'oauth' })}`
|
||||
: t(($) => $['tips.needLogin'], { ns: 'oauth' })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"width": "16",
|
||||
"height": "16",
|
||||
"viewBox": "0 0 16 16",
|
||||
"fill": "none",
|
||||
"xmlns": "http://www.w3.org/2000/svg"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M6.02888 6.23572C5.08558 6.23572 4.56458 7.33027 5.15943 8.06239L7.13069 10.4885C7.57898 11.0403 8.42124 11.0403 8.86962 10.4885L10.8408 8.06239C11.4357 7.33027 10.9147 6.23572 9.97134 6.23572H6.02888Z",
|
||||
"fill": "currentColor",
|
||||
"fill-opacity": "0.3"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "ArrowDownRoundFill"
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
import * as React from 'react'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import data from './ArrowDownRoundFill.json'
|
||||
|
||||
const Icon = ({
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
}) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'ArrowDownRoundFill'
|
||||
|
||||
export default Icon
|
||||
@@ -1 +0,0 @@
|
||||
export { default as ArrowDownRoundFill } from './ArrowDownRoundFill'
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { useState } from 'react'
|
||||
import { createRef, useState } from 'react'
|
||||
import { SearchInput } from '..'
|
||||
|
||||
describe('SearchInput', () => {
|
||||
@@ -26,7 +26,15 @@ describe('SearchInput', () => {
|
||||
expect(screen.getByRole('searchbox', { name: 'Search providers' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('exposes the input element through its ref', () => {
|
||||
const ref = createRef<HTMLInputElement>()
|
||||
render(<SearchInput ref={ref} value="" onValueChange={() => {}} />)
|
||||
|
||||
expect(ref.current).toBe(screen.getByRole('searchbox', { name: 'common.operation.search' }))
|
||||
})
|
||||
|
||||
it('focuses the searchbox when autoFocus is enabled', () => {
|
||||
// oxlint-disable-next-line jsx-a11y/no-autofocus
|
||||
render(<SearchInput value="" onValueChange={() => {}} autoFocus />)
|
||||
expect(screen.getByRole('searchbox', { name: 'common.operation.search' })).toHaveFocus()
|
||||
})
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { InputProps } from '@langgenius/dify-ui/input'
|
||||
import type { Ref } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useImperativeHandle, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type SearchInputProps = {
|
||||
ref?: Ref<HTMLInputElement>
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
placeholder?: string
|
||||
@@ -12,6 +14,7 @@ type SearchInputProps = {
|
||||
} & Pick<InputProps, 'aria-label' | 'autoFocus'>
|
||||
|
||||
export function SearchInput({
|
||||
ref,
|
||||
placeholder,
|
||||
className,
|
||||
value,
|
||||
@@ -25,6 +28,7 @@ export function SearchInput({
|
||||
const compositionCommitRef = useRef<string | null>(null)
|
||||
const [compositionValue, setCompositionValue] = useState('')
|
||||
const inputValue = isComposingRef.current ? compositionValue : value
|
||||
useImperativeHandle(ref, () => inputRef.current as HTMLInputElement, [])
|
||||
|
||||
const handleClear = () => {
|
||||
isComposingRef.current = false
|
||||
|
||||
+68
-325
@@ -1,341 +1,84 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { createElement } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { useDatasetCardState } from '../use-dataset-card-state'
|
||||
|
||||
const { mockToastSuccess, mockToastError } = vi.hoisted(() => ({
|
||||
mockToastSuccess: vi.fn(),
|
||||
mockToastError: vi.fn(),
|
||||
const mocks = vi.hoisted(() => ({
|
||||
request: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: {
|
||||
success: mockToastSuccess,
|
||||
error: mockToastError,
|
||||
},
|
||||
}))
|
||||
|
||||
const mockCheckUsage = vi.fn()
|
||||
const mockDeleteDataset = vi.fn()
|
||||
const mockExportPipeline = vi.fn()
|
||||
const mockPush = vi.fn()
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-dataset-card', () => ({
|
||||
useCheckDatasetUsage: () => ({ mutateAsync: mockCheckUsage }),
|
||||
useDeleteDataset: () => ({ mutateAsync: mockDeleteDataset }),
|
||||
vi.mock('@/service/base', () => ({
|
||||
request: (...args: unknown[]) => mocks.request(...args),
|
||||
sseGeneratorPost: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-pipeline', () => ({
|
||||
useExportPipelineDSL: () => ({ mutateAsync: mockExportPipeline }),
|
||||
useExportPipelineDSL: () => ({ mutateAsync: vi.fn() }),
|
||||
}))
|
||||
|
||||
const dataset = {
|
||||
id: 'dataset-1',
|
||||
name: 'Test Dataset',
|
||||
} as DataSet
|
||||
|
||||
function renderDatasetCardState() {
|
||||
const onSuccess = vi.fn()
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
mutations: { retry: false },
|
||||
queries: {
|
||||
retry: false,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
},
|
||||
},
|
||||
})
|
||||
const wrapper = ({ children }: { children: ReactNode }) =>
|
||||
createElement(QueryClientProvider, { client: queryClient }, children)
|
||||
const rendered = renderHook(useDatasetCardState, {
|
||||
initialProps: { dataset, onSuccess },
|
||||
wrapper,
|
||||
})
|
||||
|
||||
return { ...rendered, onSuccess }
|
||||
}
|
||||
|
||||
describe('useDatasetCardState', () => {
|
||||
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet =>
|
||||
({
|
||||
id: 'dataset-1',
|
||||
name: 'Test Dataset',
|
||||
description: 'Test description',
|
||||
provider: 'vendor',
|
||||
permission: DatasetPermission.allTeamMembers,
|
||||
data_source_type: DataSourceType.FILE,
|
||||
indexing_technique: IndexingType.QUALIFIED,
|
||||
embedding_available: true,
|
||||
app_count: 5,
|
||||
document_count: 10,
|
||||
word_count: 1000,
|
||||
created_at: 1609459200,
|
||||
updated_at: 1609545600,
|
||||
tags: [{ id: 'tag-1', name: 'Tag 1', type: 'knowledge', binding_count: '' }],
|
||||
embedding_model: 'text-embedding-ada-002',
|
||||
embedding_model_provider: 'openai',
|
||||
created_by: 'user-1',
|
||||
doc_form: ChunkingMode.text,
|
||||
pipeline_id: 'pipeline-1',
|
||||
...overrides,
|
||||
}) as DataSet
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCheckUsage.mockResolvedValue({ is_using: false })
|
||||
mockDeleteDataset.mockResolvedValue({})
|
||||
mockExportPipeline.mockResolvedValue({ data: 'yaml content' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('Initial State', () => {
|
||||
it('should have initial modal state closed', () => {
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() => useDatasetCardState({ dataset, onSuccess: vi.fn() }))
|
||||
|
||||
expect(result.current.modalState.showRenameModal).toBe(false)
|
||||
expect(result.current.modalState.showConfirmDelete).toBe(false)
|
||||
expect(result.current.modalState.confirmMessage).toBe('')
|
||||
})
|
||||
|
||||
it('should not be exporting initially', () => {
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() => useDatasetCardState({ dataset, onSuccess: vi.fn() }))
|
||||
|
||||
expect(result.current.exporting).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Modal Handlers', () => {
|
||||
it('should open rename modal when openRenameModal is called', () => {
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() => useDatasetCardState({ dataset, onSuccess: vi.fn() }))
|
||||
|
||||
act(() => {
|
||||
result.current.openRenameModal()
|
||||
})
|
||||
|
||||
expect(result.current.modalState.showRenameModal).toBe(true)
|
||||
})
|
||||
|
||||
it('should close rename modal when closeRenameModal is called', () => {
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() => useDatasetCardState({ dataset, onSuccess: vi.fn() }))
|
||||
|
||||
act(() => {
|
||||
result.current.openRenameModal()
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.closeRenameModal()
|
||||
})
|
||||
|
||||
expect(result.current.modalState.showRenameModal).toBe(false)
|
||||
})
|
||||
|
||||
it('should close confirm delete modal when closeConfirmDelete is called', async () => {
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() => useDatasetCardState({ dataset, onSuccess: vi.fn() }))
|
||||
|
||||
// First trigger show confirm delete
|
||||
act(() => {
|
||||
result.current.detectIsUsedByApp()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.modalState.showConfirmDelete).toBe(true)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.closeConfirmDelete()
|
||||
})
|
||||
|
||||
expect(result.current.modalState.showConfirmDelete).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectIsUsedByApp', () => {
|
||||
it('should check usage and show confirm modal with not-in-use message', async () => {
|
||||
mockCheckUsage.mockResolvedValue({ is_using: false })
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() => useDatasetCardState({ dataset, onSuccess: vi.fn() }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.detectIsUsedByApp()
|
||||
})
|
||||
|
||||
expect(mockCheckUsage).toHaveBeenCalledWith('dataset-1')
|
||||
expect(result.current.modalState.showConfirmDelete).toBe(true)
|
||||
expect(result.current.modalState.confirmMessage).toContain('deleteDatasetConfirmContent')
|
||||
})
|
||||
|
||||
it('should show in-use message when dataset is used by app', async () => {
|
||||
mockCheckUsage.mockResolvedValue({ is_using: true })
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() => useDatasetCardState({ dataset, onSuccess: vi.fn() }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.detectIsUsedByApp()
|
||||
})
|
||||
|
||||
expect(result.current.modalState.confirmMessage).toContain('datasetUsedByApp')
|
||||
})
|
||||
})
|
||||
|
||||
describe('onConfirmDelete', () => {
|
||||
it('should delete dataset and call onSuccess', async () => {
|
||||
const onSuccess = vi.fn()
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() => useDatasetCardState({ dataset, onSuccess }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onConfirmDelete()
|
||||
})
|
||||
|
||||
expect(mockDeleteDataset).toHaveBeenCalledWith('dataset-1')
|
||||
expect(onSuccess).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should close confirm modal after delete', async () => {
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() => useDatasetCardState({ dataset, onSuccess: vi.fn() }))
|
||||
|
||||
// First open confirm modal
|
||||
await act(async () => {
|
||||
await result.current.detectIsUsedByApp()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onConfirmDelete()
|
||||
})
|
||||
|
||||
expect(result.current.modalState.showConfirmDelete).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleExportPipeline', () => {
|
||||
it('should not export if pipeline_id is missing', async () => {
|
||||
const dataset = createMockDataset({ pipeline_id: undefined })
|
||||
const { result } = renderHook(() => useDatasetCardState({ dataset, onSuccess: vi.fn() }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExportPipeline()
|
||||
})
|
||||
|
||||
expect(mockExportPipeline).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should export pipeline with correct parameters', async () => {
|
||||
const dataset = createMockDataset({ pipeline_id: 'pipeline-1', name: 'Test Pipeline' })
|
||||
const { result } = renderHook(() => useDatasetCardState({ dataset, onSuccess: vi.fn() }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExportPipeline(true)
|
||||
})
|
||||
|
||||
expect(mockExportPipeline).toHaveBeenCalledWith({
|
||||
pipelineId: 'pipeline-1',
|
||||
include: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined onSuccess', async () => {
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() => useDatasetCardState({ dataset }))
|
||||
|
||||
// Should not throw when onSuccess is undefined
|
||||
await act(async () => {
|
||||
await result.current.onConfirmDelete()
|
||||
})
|
||||
|
||||
expect(mockDeleteDataset).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should show error toast when export pipeline fails', async () => {
|
||||
const { toast } = await import('@langgenius/dify-ui/toast')
|
||||
mockExportPipeline.mockRejectedValue(new Error('Export failed'))
|
||||
|
||||
const dataset = createMockDataset({ pipeline_id: 'pipeline-1' })
|
||||
const { result } = renderHook(() => useDatasetCardState({ dataset, onSuccess: vi.fn() }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExportPipeline()
|
||||
})
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.any(String))
|
||||
})
|
||||
|
||||
it('should handle Response error in detectIsUsedByApp', async () => {
|
||||
const { toast } = await import('@langgenius/dify-ui/toast')
|
||||
const mockResponse = new Response(JSON.stringify({ message: 'API Error' }), {
|
||||
status: 400,
|
||||
})
|
||||
mockCheckUsage.mockRejectedValue(mockResponse)
|
||||
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() => useDatasetCardState({ dataset, onSuccess: vi.fn() }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.detectIsUsedByApp()
|
||||
})
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('API Error'))
|
||||
})
|
||||
|
||||
it('should handle generic Error in detectIsUsedByApp', async () => {
|
||||
const { toast } = await import('@langgenius/dify-ui/toast')
|
||||
mockCheckUsage.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() => useDatasetCardState({ dataset, onSuccess: vi.fn() }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.detectIsUsedByApp()
|
||||
})
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('Network error')
|
||||
})
|
||||
|
||||
it('should handle error without message in detectIsUsedByApp', async () => {
|
||||
const { toast } = await import('@langgenius/dify-ui/toast')
|
||||
mockCheckUsage.mockRejectedValue({})
|
||||
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() => useDatasetCardState({ dataset, onSuccess: vi.fn() }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.detectIsUsedByApp()
|
||||
})
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('dataset.unknownError')
|
||||
})
|
||||
|
||||
it('should handle exporting state correctly', async () => {
|
||||
const dataset = createMockDataset({ pipeline_id: 'pipeline-1' })
|
||||
const { result } = renderHook(() => useDatasetCardState({ dataset, onSuccess: vi.fn() }))
|
||||
|
||||
// Exporting should initially be false
|
||||
expect(result.current.exporting).toBe(false)
|
||||
|
||||
// Export should work when not exporting
|
||||
await act(async () => {
|
||||
await result.current.handleExportPipeline()
|
||||
})
|
||||
|
||||
expect(mockExportPipeline).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reset exporting state after export completes', async () => {
|
||||
const dataset = createMockDataset({ pipeline_id: 'pipeline-1' })
|
||||
const { result } = renderHook(() => useDatasetCardState({ dataset, onSuccess: vi.fn() }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExportPipeline()
|
||||
})
|
||||
|
||||
expect(result.current.exporting).toBe(false)
|
||||
})
|
||||
|
||||
it('should reset exporting state even when export fails', async () => {
|
||||
mockExportPipeline.mockRejectedValue(new Error('Export failed'))
|
||||
|
||||
const dataset = createMockDataset({ pipeline_id: 'pipeline-1' })
|
||||
const { result } = renderHook(() => useDatasetCardState({ dataset, onSuccess: vi.fn() }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExportPipeline()
|
||||
})
|
||||
|
||||
expect(result.current.exporting).toBe(false)
|
||||
})
|
||||
it('uses the latest usage state before deleting the dataset', async () => {
|
||||
mocks.request
|
||||
.mockResolvedValueOnce(Response.json({ is_using: false }))
|
||||
.mockResolvedValueOnce(Response.json({ is_using: true }))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 204 }))
|
||||
const { onSuccess, result } = renderDatasetCardState()
|
||||
|
||||
await act(result.current.detectIsUsedByApp)
|
||||
expect(result.current.modalState.confirmMessage).toContain('deleteDatasetConfirmContent')
|
||||
|
||||
act(result.current.closeConfirmDelete)
|
||||
await act(result.current.detectIsUsedByApp)
|
||||
|
||||
expect(result.current.modalState.confirmMessage).toContain('datasetUsedByApp')
|
||||
expect(result.current.modalState.showConfirmDelete).toBe(true)
|
||||
expect(mocks.request).toHaveBeenCalledTimes(2)
|
||||
expect(
|
||||
mocks.request.mock.calls.every(([url]) =>
|
||||
String(url).endsWith('/datasets/dataset-1/use-check'),
|
||||
),
|
||||
).toBe(true)
|
||||
|
||||
await act(result.current.onConfirmDelete)
|
||||
|
||||
expect(mocks.request).toHaveBeenCalledTimes(3)
|
||||
const deleteRequest = mocks.request.mock.calls[2]?.[2]?.request as Request
|
||||
expect(deleteRequest.method).toBe('DELETE')
|
||||
expect(deleteRequest.url).toContain('/datasets/dataset-1')
|
||||
expect(result.current.modalState.showConfirmDelete).toBe(false)
|
||||
expect(onSuccess).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { useCheckDatasetUsage, useDeleteDataset } from '@/service/use-dataset-card'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { useExportPipelineDSL } from '@/service/use-pipeline'
|
||||
import { downloadBlob } from '@/utils/download'
|
||||
|
||||
@@ -22,6 +23,7 @@ type UseDatasetCardStateOptions = {
|
||||
export const useDatasetCardState = ({ dataset, onSuccess }: UseDatasetCardStateOptions) => {
|
||||
const { t } = useTranslation()
|
||||
const { push } = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Modal state
|
||||
const [modalState, setModalState] = useState<ModalState>({
|
||||
@@ -56,8 +58,9 @@ export const useDatasetCardState = ({ dataset, onSuccess }: UseDatasetCardStateO
|
||||
}, [])
|
||||
|
||||
// API mutations
|
||||
const { mutateAsync: checkUsage } = useCheckDatasetUsage()
|
||||
const { mutateAsync: deleteDatasetMutation } = useDeleteDataset()
|
||||
const { mutateAsync: deleteDatasetMutation } = useMutation(
|
||||
consoleQuery.datasets.byDatasetId.delete.mutationOptions(),
|
||||
)
|
||||
const { mutateAsync: exportPipelineConfig } = useExportPipelineDSL()
|
||||
|
||||
// Export pipeline handler
|
||||
@@ -86,7 +89,18 @@ export const useDatasetCardState = ({ dataset, onSuccess }: UseDatasetCardStateO
|
||||
// Delete flow handlers
|
||||
const detectIsUsedByApp = useCallback(async () => {
|
||||
try {
|
||||
const { is_using: isUsedByApp } = await checkUsage(dataset.id)
|
||||
const { is_using: isUsedByApp } = await queryClient.fetchQuery(
|
||||
consoleQuery.datasets.byDatasetId.useCheck.get.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
dataset_id: dataset.id,
|
||||
},
|
||||
},
|
||||
staleTime: 0,
|
||||
retry: false,
|
||||
context: { silent: true },
|
||||
}),
|
||||
)
|
||||
const message = isUsedByApp
|
||||
? t(($) => $.datasetUsedByApp, { ns: 'dataset' })!
|
||||
: t(($) => $.deleteDatasetConfirmContent, { ns: 'dataset' })!
|
||||
@@ -103,11 +117,15 @@ export const useDatasetCardState = ({ dataset, onSuccess }: UseDatasetCardStateO
|
||||
toast.error((e as Error)?.message || t(($) => $.unknownError, { ns: 'dataset' }))
|
||||
}
|
||||
}
|
||||
}, [dataset.id, checkUsage, t])
|
||||
}, [dataset.id, queryClient, t])
|
||||
|
||||
const onConfirmDelete = useCallback(async () => {
|
||||
try {
|
||||
await deleteDatasetMutation(dataset.id)
|
||||
await deleteDatasetMutation({
|
||||
params: {
|
||||
dataset_id: dataset.id,
|
||||
},
|
||||
})
|
||||
toast.success(t(($) => $.datasetDeleted, { ns: 'dataset' }))
|
||||
onSuccess?.()
|
||||
} finally {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fireEvent, screen, within } from '@testing-library/react'
|
||||
import { screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { renderWithNuqs } from '@/test/nuqs-testing'
|
||||
import IntegrationsPage from '../page'
|
||||
|
||||
@@ -476,10 +477,11 @@ describe('IntegrationsPage', () => {
|
||||
['extension', 'empty marketplace', '/plugins/extension'],
|
||||
] as const)(
|
||||
'opens the %s marketplace path from integrations',
|
||||
(section, buttonName, marketplacePath) => {
|
||||
async (section, buttonName, marketplacePath) => {
|
||||
const user = userEvent.setup()
|
||||
renderIntegrationsPage({ section })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: buttonName }))
|
||||
await user.click(screen.getByRole('button', { name: buttonName }))
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`${marketplacePath}?source=`),
|
||||
@@ -490,11 +492,12 @@ describe('IntegrationsPage', () => {
|
||||
},
|
||||
)
|
||||
|
||||
it('passes marketplace platform paths to external marketplace callbacks', () => {
|
||||
it('passes marketplace platform paths to external marketplace callbacks', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSwitchToMarketplace = vi.fn()
|
||||
renderIntegrationsPage({ section: 'trigger' }, { onSwitchToMarketplace })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'empty marketplace' }))
|
||||
await user.click(screen.getByRole('button', { name: 'empty marketplace' }))
|
||||
|
||||
expect(onSwitchToMarketplace).toHaveBeenCalledWith('/plugins/trigger')
|
||||
expect(mockRouterPush).not.toHaveBeenCalled()
|
||||
@@ -633,60 +636,8 @@ describe('IntegrationsPage', () => {
|
||||
).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
})
|
||||
|
||||
it('uses hover-only arrows for the tools parent icon', () => {
|
||||
const view = renderIntegrationsPage({ section: 'provider' })
|
||||
|
||||
const collapsedToolsButton = screen.getByRole('button', { name: 'common.menus.tools' })
|
||||
const collapsedDisclosureIcon = collapsedToolsButton.querySelector(
|
||||
'svg[viewBox="0 0 12 14.0003"]',
|
||||
)
|
||||
|
||||
expect(collapsedToolsButton).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(collapsedDisclosureIcon).toBeInTheDocument()
|
||||
expect(collapsedDisclosureIcon).toHaveClass('h-3.5', 'w-3', 'group-hover:hidden')
|
||||
expect(collapsedToolsButton.querySelector('[data-icon="MagicBox"]')).not.toBeInTheDocument()
|
||||
expect(
|
||||
collapsedToolsButton.querySelector('.i-custom-vender-solid-mediaAndDevices-magic-box'),
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
collapsedToolsButton.querySelector('.i-custom-vender-plugin-box-sparkle-fill'),
|
||||
).not.toBeInTheDocument()
|
||||
expect(collapsedToolsButton.querySelector('.i-ri-arrow-down-s-line')).toHaveClass(
|
||||
'hidden',
|
||||
'group-hover:inline-block',
|
||||
)
|
||||
expect(collapsedToolsButton.querySelector('.i-ri-arrow-up-s-line')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('link', { name: 'common.toolsPage.toolPlugin' }),
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
view.unmount()
|
||||
renderIntegrationsPage({ section: 'mcp' })
|
||||
|
||||
const expandedToolsButton = screen.getByRole('button', { name: 'common.menus.tools' })
|
||||
const expandedDisclosureIcon = expandedToolsButton.querySelector(
|
||||
'svg[viewBox="0 0 12 14.0003"]',
|
||||
)
|
||||
|
||||
expect(expandedToolsButton).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(expandedToolsButton).not.toHaveClass('bg-state-base-active')
|
||||
expect(expandedToolsButton).not.toHaveAttribute('aria-current')
|
||||
expect(expandedDisclosureIcon).toBeInTheDocument()
|
||||
expect(expandedToolsButton.querySelector('.i-ri-arrow-up-s-line')).toHaveClass(
|
||||
'hidden',
|
||||
'group-hover:inline-block',
|
||||
)
|
||||
expect(expandedToolsButton.querySelector('.i-ri-arrow-down-s-line')).not.toBeInTheDocument()
|
||||
expect(
|
||||
expandedToolsButton.querySelector('.i-custom-vender-integrations-tools-active'),
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'common.toolsPage.toolPlugin' })).toHaveAttribute(
|
||||
'href',
|
||||
'/integrations/tools/built-in',
|
||||
)
|
||||
})
|
||||
|
||||
it('toggles the tools submenu without other nav items closing it', () => {
|
||||
it('toggles the tools submenu from the keyboard without other nav items closing it', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSectionChange = vi.fn()
|
||||
renderWithNuqs(<IntegrationsPage section="provider" onSectionChange={onSectionChange} />)
|
||||
|
||||
@@ -699,31 +650,34 @@ describe('IntegrationsPage', () => {
|
||||
expect(toolsButton).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(screen.queryByRole('button', { name: 'MCP' })).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(toolsButton)
|
||||
toolsButton.focus()
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(onSectionChange).toHaveBeenCalledWith('builtin')
|
||||
expect(toolsButton).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getByRole('button', { name: 'common.toolsPage.toolPlugin' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'MCP' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.settings.provider' }))
|
||||
await user.click(screen.getByRole('button', { name: 'common.settings.provider' }))
|
||||
|
||||
expect(onSectionChange).toHaveBeenCalledWith('provider')
|
||||
expect(toolsButton).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getByRole('button', { name: 'MCP' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(toolsButton)
|
||||
toolsButton.focus()
|
||||
await user.keyboard(' ')
|
||||
|
||||
expect(toolsButton).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(screen.queryByRole('button', { name: 'MCP' })).not.toBeInTheDocument()
|
||||
expect(onSectionChange).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps custom, workflow, and MCP tool entries visible without manage permissions', () => {
|
||||
it('keeps custom, workflow, and MCP tool entries visible without manage permissions', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockAppContextState.workspacePermissionKeys = ['mcp.manage']
|
||||
renderIntegrationsPage(undefined, { section: 'provider', onSectionChange: vi.fn() })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.menus.tools' }))
|
||||
await user.click(screen.getByRole('button', { name: 'common.menus.tools' }))
|
||||
|
||||
expect(screen.getByRole('button', { name: 'common.toolsPage.toolPlugin' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'MCP' })).toBeInTheDocument()
|
||||
@@ -735,15 +689,17 @@ describe('IntegrationsPage', () => {
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens tools to the tools plugin page when the parent tools nav is clicked', () => {
|
||||
it('opens tools to the tools plugin page when the parent tools nav is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderIntegrationsPage(undefined, 'provider')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.menus.tools' }))
|
||||
await user.click(screen.getByRole('button', { name: 'common.menus.tools' }))
|
||||
|
||||
expect(mockRouterPush).toHaveBeenCalledWith('/integrations/tools/built-in')
|
||||
})
|
||||
|
||||
it('keeps the tools disclosure independent from route section changes', () => {
|
||||
it('keeps the tools disclosure independent from route section changes', async () => {
|
||||
const user = userEvent.setup()
|
||||
const view = renderIntegrationsPage(undefined, 'mcp')
|
||||
|
||||
expect(screen.getByTestId('tool-provider-list')).toHaveAttribute('data-mounted-category', 'mcp')
|
||||
@@ -754,7 +710,7 @@ describe('IntegrationsPage', () => {
|
||||
expect(screen.getByRole('link', { name: 'common.toolsPage.toolPlugin' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'MCP' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.menus.tools' }))
|
||||
await user.click(screen.getByRole('button', { name: 'common.menus.tools' }))
|
||||
|
||||
expect(screen.getByTestId('tool-provider-list')).toHaveAttribute('data-mounted-category', 'mcp')
|
||||
expect(screen.getByRole('button', { name: 'common.menus.tools' })).toHaveAttribute(
|
||||
@@ -938,10 +894,11 @@ describe('IntegrationsPage', () => {
|
||||
},
|
||||
)
|
||||
|
||||
it('opens the integrations marketplace path from the install dropdown marketplace action', () => {
|
||||
it('opens the integrations marketplace path from the install dropdown marketplace action', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderIntegrationsPage({ section: 'builtin' })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin install' }))
|
||||
await user.click(screen.getByRole('button', { name: 'plugin install' }))
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/plugins/tool?source='),
|
||||
@@ -985,27 +942,18 @@ describe('IntegrationsPage', () => {
|
||||
expect(screen.queryByTestId('update-setting-dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the sidebar plugin permissions quick settings and updates permissions', () => {
|
||||
it('opens the sidebar plugin permissions quick settings and updates permissions', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderIntegrationsPage({ section: 'provider' })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.privilege.permissions' }))
|
||||
await user.click(screen.getByRole('button', { name: 'plugin.privilege.permissions' }))
|
||||
|
||||
expect(screen.getAllByText('plugin.privilege.permissions').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('plugin.privilege.quickWhoCanInstall')).toBeInTheDocument()
|
||||
expect(screen.getByText('plugin.privilege.quickWhoCanDebug')).toBeInTheDocument()
|
||||
const dialog = screen.getByRole('dialog', { name: 'plugin.privilege.permissions' })
|
||||
expect(within(dialog).getByText('plugin.privilege.quickWhoCanInstall')).toBeInTheDocument()
|
||||
expect(within(dialog).getByText('plugin.privilege.quickWhoCanDebug')).toBeInTheDocument()
|
||||
|
||||
const dialog = screen.getByRole('dialog')
|
||||
expect(
|
||||
within(dialog).getByText('plugin.privilege.permissions').closest('.w-\\[360px\\]'),
|
||||
).toHaveClass('rounded-2xl', 'shadow-2xl')
|
||||
expect(
|
||||
screen.getByRole('radio', {
|
||||
name: 'plugin.privilege.quickWhoCanInstall: plugin.privilege.everyone',
|
||||
}),
|
||||
).toHaveClass('w-[104px]', 'h-8')
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('radio', {
|
||||
await user.click(
|
||||
within(dialog).getByRole('radio', {
|
||||
name: 'plugin.privilege.quickWhoCanInstall: plugin.privilege.noone',
|
||||
}),
|
||||
)
|
||||
@@ -1014,6 +962,12 @@ describe('IntegrationsPage', () => {
|
||||
install_permission: 'noone',
|
||||
debug_permission: 'admins',
|
||||
})
|
||||
|
||||
await user.click(within(dialog).getByRole('button', { name: 'common.operation.close' }))
|
||||
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: 'plugin.privilege.permissions' }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the sidebar plugin permissions quick settings when permission management is unavailable', () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
|
||||
import { getToolType } from '@/app/components/tools/utils'
|
||||
import { renderWithNuqs } from '@/test/nuqs-testing'
|
||||
import { ToolTypeEnum } from '../../workflow/block-selector/types'
|
||||
import { ToolType } from '../../workflow/block-selector/types'
|
||||
import ProviderList from '../tool-provider-list'
|
||||
|
||||
vi.mock('@/app/components/plugins/hooks', () => ({
|
||||
@@ -371,12 +371,12 @@ vi.mock('@/app/components/tools/mcp/create-card', () => ({
|
||||
|
||||
describe('getToolType', () => {
|
||||
it.each([
|
||||
['builtin', ToolTypeEnum.BuiltIn],
|
||||
['api', ToolTypeEnum.Custom],
|
||||
['workflow', ToolTypeEnum.Workflow],
|
||||
['mcp', ToolTypeEnum.MCP],
|
||||
['unknown', ToolTypeEnum.BuiltIn],
|
||||
])('returns correct ToolTypeEnum for "%s"', (input, expected) => {
|
||||
['builtin', ToolType.BuiltIn],
|
||||
['api', ToolType.Custom],
|
||||
['workflow', ToolType.Workflow],
|
||||
['mcp', ToolType.MCP],
|
||||
['unknown', ToolType.BuiltIn],
|
||||
])('returns correct ToolType for "%s"', (input, expected) => {
|
||||
expect(getToolType(input)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { CSSProperties, ReactNode } from 'react'
|
||||
import type { IntegrationSection } from '@/app/components/integrations/routes'
|
||||
import type { DocPathWithoutLang } from '@/types/doc-paths'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from '@langgenius/dify-ui/collapsible'
|
||||
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -191,10 +192,9 @@ export default function IntegrationsPage({
|
||||
|
||||
router.push(buildIntegrationPath(nextSection))
|
||||
}
|
||||
const handleToggleTools = () => {
|
||||
const willExpand = !isToolsExpanded
|
||||
setIsToolsExpanded(willExpand)
|
||||
if (willExpand && section !== 'builtin') handleSelectSection('builtin')
|
||||
const handleToolsOpenChange = (open: boolean) => {
|
||||
setIsToolsExpanded(open)
|
||||
if (open && section !== 'builtin') handleSelectSection('builtin')
|
||||
}
|
||||
const toolsNavItemClassName = cn(
|
||||
integrationSidebarNavItemClassName,
|
||||
@@ -204,12 +204,8 @@ export default function IntegrationsPage({
|
||||
const toolsNavItemContent = (
|
||||
<>
|
||||
<span aria-hidden className="flex size-5 shrink-0 items-center justify-center">
|
||||
<ToolsDisclosureIcon className="h-3.5 w-3 group-hover:hidden" />
|
||||
{isToolsExpanded ? (
|
||||
<span className="i-ri-arrow-up-s-line hidden size-4 group-hover:inline-block" />
|
||||
) : (
|
||||
<span className="i-ri-arrow-down-s-line hidden size-4 group-hover:inline-block" />
|
||||
)}
|
||||
<ToolsDisclosureIcon className="h-3.5 w-3 group-hover:hidden group-focus-visible:hidden" />
|
||||
<span className="i-ri-arrow-down-s-line hidden size-4 transition-transform duration-100 ease-out group-hover:inline-block group-focus-visible:inline-block group-data-panel-open:rotate-180 motion-reduce:transition-none" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{t(($) => $['menus.tools'], { ns: 'common' })}
|
||||
@@ -254,29 +250,27 @@ export default function IntegrationsPage({
|
||||
onSelect={onSectionChange}
|
||||
section={section}
|
||||
/>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
<Collapsible open={isToolsExpanded} onOpenChange={handleToolsOpenChange}>
|
||||
<CollapsibleTrigger
|
||||
aria-label={t(($) => $['menus.tools'], { ns: 'common' })}
|
||||
aria-expanded={isToolsExpanded}
|
||||
className={cn(toolsNavItemClassName, 'border-none bg-transparent')}
|
||||
onClick={handleToggleTools}
|
||||
className={cn(
|
||||
toolsNavItemClassName,
|
||||
'border-none bg-transparent data-panel-open:text-components-menu-item-text',
|
||||
)}
|
||||
>
|
||||
{toolsNavItemContent}
|
||||
</button>
|
||||
{isToolsExpanded && (
|
||||
<div className="relative space-y-px before:absolute before:top-[-1px] before:bottom-0 before:left-[17.5px] before:w-px before:bg-divider-regular">
|
||||
{toolItems.map((item) => (
|
||||
<IntegrationSidebarNavItem
|
||||
key={item.label}
|
||||
item={item}
|
||||
onSelect={onSectionChange}
|
||||
section={section}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsiblePanel className="relative space-y-px before:absolute before:top-[-1px] before:bottom-0 before:left-[17.5px] before:w-px before:bg-divider-regular">
|
||||
{toolItems.map((item) => (
|
||||
<IntegrationSidebarNavItem
|
||||
key={item.label}
|
||||
item={item}
|
||||
onSelect={onSectionChange}
|
||||
section={section}
|
||||
/>
|
||||
))}
|
||||
</CollapsiblePanel>
|
||||
</Collapsible>
|
||||
<IntegrationSidebarNavItem
|
||||
item={dataSourceItem}
|
||||
onSelect={onSectionChange}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const integrationSidebarNavItemClassName =
|
||||
'flex h-8 w-full items-center gap-2 rounded-lg py-1 pr-1 pl-2 text-left system-sm-medium transition-colors'
|
||||
'flex h-8 w-full items-center gap-2 rounded-lg py-1 pr-1 pl-2 text-left system-sm-medium outline-hidden transition-colors focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-state-accent-solid'
|
||||
export const integrationSidebarActiveNavItemClassName =
|
||||
'bg-state-base-active text-components-menu-item-text-active'
|
||||
export const integrationSidebarInactiveNavItemClassName =
|
||||
|
||||
@@ -6,14 +6,6 @@ vi.mock('../../../base/icons/src/vender/line/files', () => ({
|
||||
CopyCheck: () => <span />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/action-button', () => ({
|
||||
default: ({ children, onClick, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
|
||||
<button onClick={onClick} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
const mockCopy = vi.fn()
|
||||
vi.mock('copy-to-clipboard', () => ({
|
||||
default: (...args: unknown[]) => mockCopy(...args),
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import { CopyCheck } from '../../base/icons/src/vender/line/files'
|
||||
|
||||
type Props = Readonly<{
|
||||
@@ -17,13 +15,13 @@ type Props = Readonly<{
|
||||
valueMaxWidthClassName?: string
|
||||
}>
|
||||
|
||||
const KeyValueItem: FC<Props> = ({
|
||||
function KeyValueItem({
|
||||
label,
|
||||
labelWidthClassName = 'w-10',
|
||||
value,
|
||||
maskedValue,
|
||||
valueMaxWidthClassName = 'max-w-[162px]',
|
||||
}) => {
|
||||
}: Props) {
|
||||
const { t } = useTranslation()
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
const handleCopy = useCallback(() => {
|
||||
@@ -63,7 +61,12 @@ const KeyValueItem: FC<Props> = ({
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<ActionButton aria-label={copyLabel} onClick={handleCopy}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label={copyLabel}
|
||||
className="size-6 p-0"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{isCopied ? (
|
||||
<CopyCheck aria-hidden className="size-3.5 shrink-0 text-text-tertiary" />
|
||||
) : (
|
||||
@@ -72,7 +75,7 @@ const KeyValueItem: FC<Props> = ({
|
||||
className="i-ri-clipboard-line size-3.5 shrink-0 text-text-tertiary"
|
||||
/>
|
||||
)}
|
||||
</ActionButton>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent placement="top">{copyLabel}</TooltipContent>
|
||||
@@ -82,4 +85,4 @@ const KeyValueItem: FC<Props> = ({
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(KeyValueItem)
|
||||
export default KeyValueItem
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useState } from 'react'
|
||||
import { createRef, useState } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import SearchBox from '../index'
|
||||
|
||||
@@ -55,4 +55,22 @@ describe('SearchBox', () => {
|
||||
await user.click(screen.getByRole('button'))
|
||||
expect(onShowAddCustomCollectionModal).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('exposes the input element through its ref', () => {
|
||||
const ref = createRef<HTMLInputElement>()
|
||||
|
||||
render(
|
||||
<SearchBox
|
||||
ref={ref}
|
||||
search=""
|
||||
onSearchChange={vi.fn()}
|
||||
tags={[]}
|
||||
onTagsChange={vi.fn()}
|
||||
placeholder="Search plugins"
|
||||
showTags={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(ref.current).toBe(screen.getByPlaceholderText('Search plugins'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
'use client'
|
||||
import type { Ref } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RiAddLine, RiCloseLine, RiSearchLine } from '@remixicon/react'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
@@ -6,6 +7,7 @@ import Divider from '@/app/components/base/divider'
|
||||
import TagsFilter from './tags-filter'
|
||||
|
||||
type SearchBoxProps = {
|
||||
ref?: Ref<HTMLInputElement>
|
||||
search: string
|
||||
onSearchChange: (search: string) => void
|
||||
wrapperClassName?: string
|
||||
@@ -23,6 +25,7 @@ type SearchBoxProps = {
|
||||
showTags?: boolean
|
||||
}
|
||||
const SearchBox = ({
|
||||
ref,
|
||||
search,
|
||||
onSearchChange,
|
||||
wrapperClassName,
|
||||
@@ -60,6 +63,8 @@ const SearchBox = ({
|
||||
)}
|
||||
<div className="flex grow items-center gap-x-2 p-1">
|
||||
<input
|
||||
ref={ref}
|
||||
aria-label={placeholder || undefined}
|
||||
className={cn(
|
||||
'inline-block grow appearance-none bg-transparent body-md-medium text-text-secondary outline-hidden',
|
||||
inputElementClassName,
|
||||
@@ -85,6 +90,9 @@ const SearchBox = ({
|
||||
className={cn('size-4 text-components-input-text-placeholder', searchIconClassName)}
|
||||
/>
|
||||
<input
|
||||
ref={ref}
|
||||
aria-label={placeholder || undefined}
|
||||
// oxlint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus={autoFocus}
|
||||
className={cn(
|
||||
'mr-1 ml-1.5 inline-block min-w-0 grow appearance-none truncate bg-transparent system-sm-regular text-components-input-text-filled caret-primary-600 outline-hidden placeholder:text-components-input-text-placeholder',
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@ import type { PluginDeclaration, PluginDetail } from '@/app/components/plugins/t
|
||||
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { TriggerCredentialType } from '@/app/components/workflow/block-selector/types'
|
||||
import { createReactI18nextMock } from '@/test/i18n-mock'
|
||||
import { SubscriptionList } from '../index'
|
||||
import { SubscriptionListMode } from '../types'
|
||||
@@ -53,7 +53,7 @@ const createSubscription = (overrides: Partial<TriggerSubscription> = {}): Trigg
|
||||
id: 'sub-1',
|
||||
name: 'Subscription One',
|
||||
provider: 'provider-1',
|
||||
credential_type: TriggerCredentialTypeEnum.ApiKey,
|
||||
credential_type: TriggerCredentialType.ApiKey,
|
||||
credentials: {},
|
||||
endpoint: 'https://example.com',
|
||||
parameters: {},
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { TriggerCredentialType } from '@/app/components/workflow/block-selector/types'
|
||||
import { SubscriptionListView } from '../list-view'
|
||||
|
||||
let mockSubscriptions: TriggerSubscription[] = []
|
||||
@@ -24,7 +24,7 @@ const createSubscription = (overrides: Partial<TriggerSubscription> = {}): Trigg
|
||||
id: 'sub-1',
|
||||
name: 'Subscription One',
|
||||
provider: 'provider-1',
|
||||
credential_type: TriggerCredentialTypeEnum.ApiKey,
|
||||
credential_type: TriggerCredentialType.ApiKey,
|
||||
credentials: {},
|
||||
endpoint: 'https://example.com',
|
||||
parameters: {},
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { TriggerCredentialType } from '@/app/components/workflow/block-selector/types'
|
||||
import { SubscriptionSelectorEntry } from '../selector-entry'
|
||||
|
||||
vi.mock('@langgenius/dify-ui/popover', async () => {
|
||||
@@ -86,7 +86,7 @@ const createSubscription = (overrides: Partial<TriggerSubscription> = {}): Trigg
|
||||
id: 'sub-1',
|
||||
name: 'Subscription One',
|
||||
provider: 'provider-1',
|
||||
credential_type: TriggerCredentialTypeEnum.ApiKey,
|
||||
credential_type: TriggerCredentialType.ApiKey,
|
||||
credentials: {},
|
||||
endpoint: 'https://example.com',
|
||||
parameters: {},
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { TriggerCredentialType } from '@/app/components/workflow/block-selector/types'
|
||||
import { SubscriptionSelectorView } from '../selector-view'
|
||||
|
||||
let mockSubscriptions: TriggerSubscription[] = []
|
||||
@@ -41,7 +41,7 @@ const createSubscription = (overrides: Partial<TriggerSubscription> = {}): Trigg
|
||||
id: 'sub-1',
|
||||
name: 'Subscription One',
|
||||
provider: 'provider-1',
|
||||
credential_type: TriggerCredentialTypeEnum.ApiKey,
|
||||
credential_type: TriggerCredentialType.ApiKey,
|
||||
credentials: {},
|
||||
endpoint: 'https://example.com',
|
||||
parameters: {},
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { TriggerCredentialType } from '@/app/components/workflow/block-selector/types'
|
||||
import SubscriptionCard from '../subscription-card'
|
||||
|
||||
const mockRefetch = vi.fn()
|
||||
@@ -47,7 +47,7 @@ const createSubscription = (overrides: Partial<TriggerSubscription> = {}): Trigg
|
||||
id: 'sub-1',
|
||||
name: 'Subscription One',
|
||||
provider: 'provider-1',
|
||||
credential_type: TriggerCredentialTypeEnum.ApiKey,
|
||||
credential_type: TriggerCredentialType.ApiKey,
|
||||
credentials: {},
|
||||
endpoint: 'https://example.com',
|
||||
parameters: {},
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@ import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SupportedCreationMethods } from '@/app/components/plugins/types'
|
||||
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { TriggerCredentialType } from '@/app/components/workflow/block-selector/types'
|
||||
import { CommonCreateModal } from '../common-modal'
|
||||
|
||||
type PluginDetail = {
|
||||
@@ -63,7 +63,7 @@ function createMockSubscriptionBuilder(
|
||||
id: 'builder-123',
|
||||
name: 'Test Builder',
|
||||
provider: 'test-provider',
|
||||
credential_type: TriggerCredentialTypeEnum.ApiKey,
|
||||
credential_type: TriggerCredentialType.ApiKey,
|
||||
credentials: {},
|
||||
endpoint: 'https://example.com/callback',
|
||||
parameters: {},
|
||||
|
||||
+6
-6
@@ -9,7 +9,7 @@ import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SupportedCreationMethods } from '@/app/components/plugins/types'
|
||||
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { TriggerCredentialType } from '@/app/components/workflow/block-selector/types'
|
||||
import { CreateSubscriptionButton } from '../index'
|
||||
import { CreateButtonType, DEFAULT_METHOD } from '../types'
|
||||
|
||||
@@ -116,7 +116,7 @@ vi.mock('../oauth-client', () => ({
|
||||
id: 'test-builder',
|
||||
name: 'test',
|
||||
provider: 'test-provider',
|
||||
credential_type: TriggerCredentialTypeEnum.Oauth2,
|
||||
credential_type: TriggerCredentialType.Oauth2,
|
||||
credentials: {},
|
||||
endpoint: 'https://test.com',
|
||||
parameters: {},
|
||||
@@ -285,7 +285,7 @@ const createSubscription = (overrides: Partial<TriggerSubscription> = {}): Trigg
|
||||
id: 'test-subscription',
|
||||
name: 'Test Subscription',
|
||||
provider: 'test-provider',
|
||||
credential_type: TriggerCredentialTypeEnum.ApiKey,
|
||||
credential_type: TriggerCredentialType.ApiKey,
|
||||
credentials: {},
|
||||
endpoint: 'https://test.com',
|
||||
parameters: {},
|
||||
@@ -1133,7 +1133,7 @@ describe('CreateSubscriptionButton', () => {
|
||||
id: 'oauth-builder',
|
||||
name: 'OAuth Builder',
|
||||
provider: 'test-provider',
|
||||
credential_type: TriggerCredentialTypeEnum.Oauth2,
|
||||
credential_type: TriggerCredentialType.Oauth2,
|
||||
credentials: {},
|
||||
endpoint: 'https://test.com',
|
||||
parameters: {},
|
||||
@@ -1598,7 +1598,7 @@ describe('CreateSubscriptionButton', () => {
|
||||
id: 'oauth-builder',
|
||||
name: 'OAuth Builder',
|
||||
provider: 'test-provider',
|
||||
credential_type: TriggerCredentialTypeEnum.Oauth2,
|
||||
credential_type: TriggerCredentialType.Oauth2,
|
||||
credentials: {},
|
||||
endpoint: 'https://test.com',
|
||||
parameters: {},
|
||||
@@ -1770,7 +1770,7 @@ describe('CreateSubscriptionButton', () => {
|
||||
id: 'oauth-builder',
|
||||
name: 'OAuth Builder',
|
||||
provider: 'test-provider',
|
||||
credential_type: TriggerCredentialTypeEnum.Oauth2,
|
||||
credential_type: TriggerCredentialType.Oauth2,
|
||||
credentials: {},
|
||||
endpoint: 'https://test.com',
|
||||
parameters: {},
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ import type {
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { TriggerCredentialType } from '@/app/components/workflow/block-selector/types'
|
||||
import { OAuthClientSettingsModal } from '../oauth-client'
|
||||
|
||||
type PluginDetail = {
|
||||
@@ -59,7 +59,7 @@ function createMockSubscriptionBuilder(
|
||||
id: 'builder-123',
|
||||
name: 'Test Builder',
|
||||
provider: 'test-provider',
|
||||
credential_type: TriggerCredentialTypeEnum.Oauth2,
|
||||
credential_type: TriggerCredentialType.Oauth2,
|
||||
credentials: {},
|
||||
endpoint: 'https://example.com/callback',
|
||||
parameters: {},
|
||||
|
||||
+4
-4
@@ -3,7 +3,7 @@ import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SupportedCreationMethods } from '@/app/components/plugins/types'
|
||||
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { TriggerCredentialType } from '@/app/components/workflow/block-selector/types'
|
||||
import { ApiKeyStep, useCommonModalState } from '../use-common-modal-state'
|
||||
|
||||
type MockPluginDetail = {
|
||||
@@ -27,7 +27,7 @@ const createMockBuilder = (
|
||||
id: 'builder-1',
|
||||
name: 'builder',
|
||||
provider: 'provider-a',
|
||||
credential_type: TriggerCredentialTypeEnum.ApiKey,
|
||||
credential_type: TriggerCredentialType.ApiKey,
|
||||
credentials: {},
|
||||
endpoint: 'https://example.com/callback',
|
||||
parameters: {},
|
||||
@@ -148,7 +148,7 @@ describe('useCommonModalState', () => {
|
||||
|
||||
expect(mockCreateBuilder).toHaveBeenCalledWith({
|
||||
provider: 'provider-a',
|
||||
credential_type: TriggerCredentialTypeEnum.ApiKey,
|
||||
credential_type: TriggerCredentialType.ApiKey,
|
||||
})
|
||||
expect(result.current.currentStep).toBe(ApiKeyStep.Verify)
|
||||
expect(result.current.apiKeyCredentialsSchema[0]).toMatchObject({
|
||||
@@ -248,7 +248,7 @@ describe('useCommonModalState', () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
const builder = createMockBuilder({
|
||||
credential_type: TriggerCredentialTypeEnum.Unauthorized,
|
||||
credential_type: TriggerCredentialType.Unauthorized,
|
||||
})
|
||||
const { result } = renderHook(() =>
|
||||
useCommonModalState({
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ import type {
|
||||
} from '@/app/components/workflow/block-selector/types'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { TriggerCredentialType } from '@/app/components/workflow/block-selector/types'
|
||||
import {
|
||||
AuthorizationStatusEnum,
|
||||
ClientTypeEnum,
|
||||
@@ -52,7 +52,7 @@ function createMockSubscriptionBuilder(
|
||||
id: 'builder-123',
|
||||
name: 'Test Builder',
|
||||
provider: 'test-provider',
|
||||
credential_type: TriggerCredentialTypeEnum.Oauth2,
|
||||
credential_type: TriggerCredentialType.Oauth2,
|
||||
credentials: {},
|
||||
endpoint: 'https://example.com/callback',
|
||||
parameters: {},
|
||||
|
||||
+5
-5
@@ -12,7 +12,7 @@ import { debounce } from 'es-toolkit/compat'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SupportedCreationMethods } from '@/app/components/plugins/types'
|
||||
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { TriggerCredentialType } from '@/app/components/workflow/block-selector/types'
|
||||
import {
|
||||
useBuildTriggerSubscription,
|
||||
useCreateTriggerSubscriptionBuilder,
|
||||
@@ -42,10 +42,10 @@ export enum ApiKeyStep {
|
||||
Configuration = 'configuration',
|
||||
}
|
||||
|
||||
const CREDENTIAL_TYPE_MAP: Record<SupportedCreationMethods, TriggerCredentialTypeEnum> = {
|
||||
[SupportedCreationMethods.APIKEY]: TriggerCredentialTypeEnum.ApiKey,
|
||||
[SupportedCreationMethods.OAUTH]: TriggerCredentialTypeEnum.Oauth2,
|
||||
[SupportedCreationMethods.MANUAL]: TriggerCredentialTypeEnum.Unauthorized,
|
||||
const CREDENTIAL_TYPE_MAP: Record<SupportedCreationMethods, TriggerCredentialType> = {
|
||||
[SupportedCreationMethods.APIKEY]: TriggerCredentialType.ApiKey,
|
||||
[SupportedCreationMethods.OAUTH]: TriggerCredentialType.Oauth2,
|
||||
[SupportedCreationMethods.MANUAL]: TriggerCredentialType.Unauthorized,
|
||||
}
|
||||
|
||||
export const MODAL_TITLE_KEY_MAP: Record<
|
||||
|
||||
+14
-14
@@ -4,7 +4,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { FormTypeEnum } from '@/app/components/base/form/types'
|
||||
import { PluginCategoryEnum, PluginSource } from '@/app/components/plugins/types'
|
||||
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { TriggerCredentialType } from '@/app/components/workflow/block-selector/types'
|
||||
import { ApiKeyEditModal } from '../apikey-edit-modal'
|
||||
import { EditModal } from '../index'
|
||||
import { ManualEditModal } from '../manual-edit-modal'
|
||||
@@ -191,7 +191,7 @@ const createSubscription = (overrides: Partial<TriggerSubscription> = {}): Trigg
|
||||
id: 'test-subscription-id',
|
||||
name: 'Test Subscription',
|
||||
provider: 'test-provider',
|
||||
credential_type: TriggerCredentialTypeEnum.Unauthorized,
|
||||
credential_type: TriggerCredentialType.Unauthorized,
|
||||
credentials: {},
|
||||
endpoint: 'https://example.com/webhook',
|
||||
parameters: {},
|
||||
@@ -317,9 +317,9 @@ describe('Edit Modal Components', () => {
|
||||
|
||||
describe('EditModal (Router)', () => {
|
||||
it.each([
|
||||
{ type: TriggerCredentialTypeEnum.Unauthorized, name: 'ManualEditModal' },
|
||||
{ type: TriggerCredentialTypeEnum.Oauth2, name: 'OAuthEditModal' },
|
||||
{ type: TriggerCredentialTypeEnum.ApiKey, name: 'ApiKeyEditModal' },
|
||||
{ type: TriggerCredentialType.Unauthorized, name: 'ManualEditModal' },
|
||||
{ type: TriggerCredentialType.Oauth2, name: 'OAuthEditModal' },
|
||||
{ type: TriggerCredentialType.ApiKey, name: 'ApiKeyEditModal' },
|
||||
])('should render $name for $type credential type', ({ type }) => {
|
||||
render(
|
||||
<EditModal
|
||||
@@ -335,7 +335,7 @@ describe('Edit Modal Components', () => {
|
||||
<EditModal
|
||||
onClose={vi.fn()}
|
||||
subscription={createSubscription({
|
||||
credential_type: 'unknown' as TriggerCredentialTypeEnum,
|
||||
credential_type: 'unknown' as TriggerCredentialType,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
@@ -707,7 +707,7 @@ describe('Edit Modal Components', () => {
|
||||
|
||||
const createProps = (overrides = {}) => ({
|
||||
onClose: vi.fn(),
|
||||
subscription: createSubscription({ credential_type: TriggerCredentialTypeEnum.Oauth2 }),
|
||||
subscription: createSubscription({ credential_type: TriggerCredentialType.Oauth2 }),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@@ -744,7 +744,7 @@ describe('Edit Modal Components', () => {
|
||||
<OAuthEditModal
|
||||
{...createProps({
|
||||
subscription: createSubscription({
|
||||
credential_type: TriggerCredentialTypeEnum.Oauth2,
|
||||
credential_type: TriggerCredentialType.Oauth2,
|
||||
parameters: { channel: 'general' },
|
||||
}),
|
||||
})}
|
||||
@@ -808,7 +808,7 @@ describe('Edit Modal Components', () => {
|
||||
<OAuthEditModal
|
||||
{...createProps({
|
||||
subscription: createSubscription({
|
||||
credential_type: TriggerCredentialTypeEnum.Oauth2,
|
||||
credential_type: TriggerCredentialType.Oauth2,
|
||||
parameters: { channel: 'general' },
|
||||
}),
|
||||
})}
|
||||
@@ -830,7 +830,7 @@ describe('Edit Modal Components', () => {
|
||||
<OAuthEditModal
|
||||
{...createProps({
|
||||
subscription: createSubscription({
|
||||
credential_type: TriggerCredentialTypeEnum.Oauth2,
|
||||
credential_type: TriggerCredentialType.Oauth2,
|
||||
parameters: { channel: 'old' },
|
||||
}),
|
||||
})}
|
||||
@@ -982,7 +982,7 @@ describe('Edit Modal Components', () => {
|
||||
|
||||
const createProps = (overrides = {}) => ({
|
||||
onClose: vi.fn(),
|
||||
subscription: createSubscription({ credential_type: TriggerCredentialTypeEnum.ApiKey }),
|
||||
subscription: createSubscription({ credential_type: TriggerCredentialType.ApiKey }),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@@ -1038,7 +1038,7 @@ describe('Edit Modal Components', () => {
|
||||
<ApiKeyEditModal
|
||||
{...createProps({
|
||||
subscription: createSubscription({
|
||||
credential_type: TriggerCredentialTypeEnum.ApiKey,
|
||||
credential_type: TriggerCredentialType.ApiKey,
|
||||
credentials: { api_key: '[__HIDDEN__]' },
|
||||
}),
|
||||
})}
|
||||
@@ -1375,7 +1375,7 @@ describe('Edit Modal Components', () => {
|
||||
<ApiKeyEditModal
|
||||
{...createProps({
|
||||
subscription: createSubscription({
|
||||
credential_type: TriggerCredentialTypeEnum.ApiKey,
|
||||
credential_type: TriggerCredentialType.ApiKey,
|
||||
parameters: { param1: 'value' },
|
||||
}),
|
||||
})}
|
||||
@@ -1404,7 +1404,7 @@ describe('Edit Modal Components', () => {
|
||||
<ApiKeyEditModal
|
||||
{...createProps({
|
||||
subscription: createSubscription({
|
||||
credential_type: TriggerCredentialTypeEnum.ApiKey,
|
||||
credential_type: TriggerCredentialType.ApiKey,
|
||||
parameters: { param1: 'old_value' },
|
||||
}),
|
||||
})}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
import type { PluginDetail } from '@/app/components/plugins/types'
|
||||
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
|
||||
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { TriggerCredentialType } from '@/app/components/workflow/block-selector/types'
|
||||
import { ApiKeyEditModal } from './apikey-edit-modal'
|
||||
import { ManualEditModal } from './manual-edit-modal'
|
||||
import { OAuthEditModal } from './oauth-edit-modal'
|
||||
@@ -16,7 +16,7 @@ export const EditModal = ({ onClose, subscription, pluginDetail }: Props) => {
|
||||
const credentialType = subscription.credential_type
|
||||
|
||||
switch (credentialType) {
|
||||
case TriggerCredentialTypeEnum.Unauthorized:
|
||||
case TriggerCredentialType.Unauthorized:
|
||||
return (
|
||||
<ManualEditModal
|
||||
onClose={onClose}
|
||||
@@ -24,11 +24,11 @@ export const EditModal = ({ onClose, subscription, pluginDetail }: Props) => {
|
||||
pluginDetail={pluginDetail}
|
||||
/>
|
||||
)
|
||||
case TriggerCredentialTypeEnum.Oauth2:
|
||||
case TriggerCredentialType.Oauth2:
|
||||
return (
|
||||
<OAuthEditModal onClose={onClose} subscription={subscription} pluginDetail={pluginDetail} />
|
||||
)
|
||||
case TriggerCredentialTypeEnum.ApiKey:
|
||||
case TriggerCredentialType.ApiKey:
|
||||
return (
|
||||
<ApiKeyEditModal
|
||||
onClose={onClose}
|
||||
|
||||
@@ -76,7 +76,7 @@ describe('DebugInfo', () => {
|
||||
'rounded-2xl',
|
||||
'shadow-2xl',
|
||||
)
|
||||
expect(screen.getByRole('link')).toHaveAttribute(
|
||||
expect(screen.getByRole('link', { name: 'plugin.debugInfo.viewDocs' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://docs.example.com/develop-plugin/features-and-specs/plugin-types/remote-debug-a-plugin',
|
||||
)
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { ComponentProps, ReactNode } from 'react'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { RiArrowRightUpLine, RiBugLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { useDebugKey } from '@/service/use-plugins'
|
||||
@@ -29,7 +28,7 @@ function DebugInfo({
|
||||
const { t } = useTranslation()
|
||||
const docLink = useDocLink()
|
||||
const { data: info, isLoading } = useDebugKey()
|
||||
const trigger = triggerContent ?? <RiBugLine className="size-4" />
|
||||
const trigger = triggerContent ?? <span aria-hidden className="i-ri-bug-line size-4" />
|
||||
const triggerClassNames = cn(
|
||||
!triggerClassName && 'size-full p-2 text-components-button-secondary-text',
|
||||
triggerClassName,
|
||||
@@ -73,10 +72,10 @@ function DebugInfo({
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex cursor-pointer items-center gap-1 system-xs-regular text-text-accent"
|
||||
className="flex cursor-pointer items-center gap-1 rounded-xs system-xs-regular text-text-accent outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
>
|
||||
<span>{t(($) => $[`${i18nPrefix}.viewDocs`], { ns: 'plugin' })}</span>
|
||||
<RiArrowRightUpLine className="size-3" />
|
||||
<span aria-hidden className="i-ri-arrow-right-up-line size-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { PopoverClose } from '@langgenius/dify-ui/popover'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { PopoverClose, PopoverTitle } from '@langgenius/dify-ui/popover'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type PluginSidecarPanelProps = {
|
||||
@@ -18,18 +19,20 @@ export function PluginSidecarPanel({ children, footer, title }: PluginSidecarPan
|
||||
<div className="relative flex w-full shrink-0 flex-col gap-0.5 px-3 pt-3.5 pb-1">
|
||||
<div className="flex w-full shrink-0 items-start">
|
||||
<div className="flex min-w-0 flex-1 flex-col items-start pr-8 pl-1">
|
||||
<div className="w-full system-xl-semibold text-text-primary">{title}</div>
|
||||
<PopoverTitle className="w-full system-xl-semibold text-text-primary">
|
||||
{title}
|
||||
</PopoverTitle>
|
||||
</div>
|
||||
</div>
|
||||
<PopoverClose
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
|
||||
className="absolute top-2.5 right-2.5 flex size-8 items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
|
||||
className="absolute top-2.5 right-2.5 size-8 p-0 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-line size-4" />
|
||||
</button>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ToolTypeEnum } from '../../../workflow/block-selector/types'
|
||||
import { ToolType } from '../../../workflow/block-selector/types'
|
||||
import Empty from '../empty'
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({ default: () => ({ theme: 'light' }) }))
|
||||
|
||||
describe('Empty', () => {
|
||||
it.each([
|
||||
[ToolTypeEnum.Custom, '/integrations/tools/api'],
|
||||
[ToolTypeEnum.MCP, '/integrations/tools/mcp'],
|
||||
[ToolType.Custom, '/integrations/tools/api'],
|
||||
[ToolType.MCP, '/integrations/tools/mcp'],
|
||||
])('links the %s empty state to its integration page', (type, href) => {
|
||||
render(<Empty type={type} />)
|
||||
|
||||
@@ -16,7 +16,7 @@ describe('Empty', () => {
|
||||
})
|
||||
|
||||
it('links the workflow guide to Studio and the documentation', () => {
|
||||
render(<Empty type={ToolTypeEnum.Workflow} />)
|
||||
render(<Empty type={ToolType.Workflow} />)
|
||||
|
||||
expect(screen.getByRole('link', { name: /goToStudio/i })).toHaveAttribute('href', '/apps')
|
||||
expect(screen.getByRole('link', { name: /learnMore/i })).toHaveAttribute(
|
||||
@@ -26,7 +26,7 @@ describe('Empty', () => {
|
||||
})
|
||||
|
||||
it('does not offer installation navigation in an agent empty state', () => {
|
||||
render(<Empty type={ToolTypeEnum.Custom} isAgent />)
|
||||
render(<Empty type={ToolType.Custom} isAgent />)
|
||||
|
||||
expect(screen.queryByRole('link')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -7,10 +7,10 @@ import { useDocLink } from '@/context/i18n'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import Link from '@/next/link'
|
||||
import { NoToolPlaceholder } from '../../base/icons/src/vender/other'
|
||||
import { ToolTypeEnum } from '../../workflow/block-selector/types'
|
||||
import { ToolType } from '../../workflow/block-selector/types'
|
||||
|
||||
type Props = Readonly<{
|
||||
type?: ToolTypeEnum
|
||||
type?: ToolType
|
||||
isAgent?: boolean
|
||||
}>
|
||||
|
||||
@@ -20,11 +20,11 @@ const workflowToolStepKeys = [
|
||||
'workflowToolEmpty.step3',
|
||||
] as const
|
||||
|
||||
const getLink = (type?: ToolTypeEnum) => {
|
||||
const getLink = (type?: ToolType) => {
|
||||
switch (type) {
|
||||
case ToolTypeEnum.Custom:
|
||||
case ToolType.Custom:
|
||||
return buildIntegrationPath('custom-tool')
|
||||
case ToolTypeEnum.MCP:
|
||||
case ToolType.MCP:
|
||||
return buildIntegrationPath('mcp')
|
||||
default:
|
||||
return buildIntegrationPath('custom-tool')
|
||||
@@ -35,7 +35,7 @@ const Empty = ({ type, isAgent }: Props) => {
|
||||
const docLink = useDocLink()
|
||||
const { theme } = useTheme()
|
||||
|
||||
const hasLink = type && [ToolTypeEnum.Custom, ToolTypeEnum.MCP].includes(type)
|
||||
const hasLink = type === ToolType.Custom || type === ToolType.MCP
|
||||
const renderType = isAgent ? ('agent' as const) : type
|
||||
const hasTitle =
|
||||
renderType &&
|
||||
@@ -52,7 +52,7 @@ const Empty = ({ type, isAgent }: Props) => {
|
||||
</>
|
||||
)
|
||||
|
||||
if (!isAgent && type === ToolTypeEnum.Workflow) {
|
||||
if (!isAgent && type === ToolType.Workflow) {
|
||||
return (
|
||||
<div className="flex w-full max-w-[1060px] flex-col items-center gap-8 text-center">
|
||||
<div className="flex w-full max-w-[739px] flex-col items-center gap-1">
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import type { ThoughtItem } from '@/app/components/base/chat/chat/type'
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import type { VisionFile } from '@/types/app'
|
||||
import { ToolTypeEnum } from '../../workflow/block-selector/types'
|
||||
import { ToolType } from '../../workflow/block-selector/types'
|
||||
|
||||
export const getToolType = (type: string) => {
|
||||
switch (type) {
|
||||
case 'builtin':
|
||||
return ToolTypeEnum.BuiltIn
|
||||
return ToolType.BuiltIn
|
||||
case 'api':
|
||||
return ToolTypeEnum.Custom
|
||||
return ToolType.Custom
|
||||
case 'workflow':
|
||||
return ToolTypeEnum.Workflow
|
||||
return ToolType.Workflow
|
||||
case 'mcp':
|
||||
return ToolTypeEnum.MCP
|
||||
return ToolType.MCP
|
||||
default:
|
||||
return ToolTypeEnum.BuiltIn
|
||||
return ToolType.BuiltIn
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-3
@@ -4,7 +4,7 @@ import type { BlockDefaultValue } from '@/app/components/workflow/block-selector
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import NodeSelector from '@/app/components/workflow/block-selector'
|
||||
import { TabsEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { TabType } from '@/app/components/workflow/block-selector/types'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import StartNodeOption from './start-node-option'
|
||||
|
||||
@@ -49,8 +49,7 @@ const StartNodeSelectionPanel: FC<StartNodeSelectionPanelProps> = ({
|
||||
offset={-200}
|
||||
noBlocks={true}
|
||||
showStartTab={true}
|
||||
defaultActiveTab={TabsEnum.Start}
|
||||
forceShowStartContent={true}
|
||||
defaultActiveTab={TabType.Start}
|
||||
availableBlocksTypes={[
|
||||
BlockEnum.TriggerSchedule,
|
||||
BlockEnum.TriggerWebhook,
|
||||
|
||||
@@ -33,6 +33,14 @@ vi.mock('@/utils/var', async (importOriginal) => ({
|
||||
getMarketplaceUrl: (path = '') => `https://marketplace.test${path}`,
|
||||
}))
|
||||
|
||||
vi.mock('../rag-tool-recommendations', () => ({
|
||||
RAGToolRecommendations: ({ onLoadMore }: { onLoadMore: () => void }) => (
|
||||
<button type="button" onClick={onLoadMore}>
|
||||
Load more RAG tools
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
const mockUseMarketplacePlugins = vi.mocked(useMarketplacePlugins)
|
||||
const mockUseGetLanguage = vi.mocked(useGetLanguage)
|
||||
const mockUseTheme = vi.mocked(useTheme)
|
||||
@@ -193,4 +201,27 @@ describe('AllTools', () => {
|
||||
expect(screen.getByText('workflow.tabs.noPluginsFound')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the next tag value when loading more RAG tools', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onTagsChange = vi.fn()
|
||||
|
||||
render(
|
||||
<AllTools
|
||||
searchText=""
|
||||
tags={[]}
|
||||
onTagsChange={onTagsChange}
|
||||
onSelect={vi.fn()}
|
||||
buildInTools={[createToolProvider({ id: 'provider-built-in' })]}
|
||||
customTools={[]}
|
||||
workflowTools={[]}
|
||||
mcpTools={[]}
|
||||
isInRAGPipeline
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Load more RAG tools' }))
|
||||
|
||||
expect(onTagsChange).toHaveBeenCalledWith(['rag'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,7 +11,7 @@ import { HooksStoreContext } from '../../hooks-store/provider'
|
||||
import { createHooksStore } from '../../hooks-store/store'
|
||||
import { BlockEnum } from '../../types'
|
||||
import Blocks from '../blocks'
|
||||
import { BlockClassificationEnum } from '../types'
|
||||
import { BlockClassification } from '../types'
|
||||
|
||||
const runtimeState = vi.hoisted(() => ({
|
||||
appType: 'workflow' as string | undefined,
|
||||
@@ -53,7 +53,7 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
const createBlock = (
|
||||
type: BlockEnum,
|
||||
title: string,
|
||||
classification = BlockClassificationEnum.Default,
|
||||
classification: BlockClassification = BlockClassification.Default,
|
||||
sort = 0,
|
||||
): NodeDefault => ({
|
||||
metaData: {
|
||||
@@ -142,18 +142,20 @@ describe('Blocks', () => {
|
||||
availableBlocksTypes={[BlockEnum.LLM, BlockEnum.LoopEnd, BlockEnum.KnowledgeBase]}
|
||||
blocks={[
|
||||
createBlock(BlockEnum.LLM, 'LLM'),
|
||||
createBlock(BlockEnum.LoopEnd, 'Exit Loop', BlockClassificationEnum.Logic),
|
||||
createBlock(BlockEnum.LoopEnd, 'Exit Loop', BlockClassification.Logic),
|
||||
createBlock(BlockEnum.KnowledgeBase, 'Knowledge Retrieval'),
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'LLM' })).toBeInTheDocument()
|
||||
const llmButton = screen.getByRole('button', { name: 'LLM' })
|
||||
expect(llmButton).toBeInTheDocument()
|
||||
expect(llmButton).toHaveAccessibleDescription('LLM description')
|
||||
expect(screen.getByText('Exit Loop')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.nodes.loop.loopNode')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Knowledge Retrieval')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'LLM' }))
|
||||
await user.click(llmButton)
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(BlockEnum.LLM)
|
||||
})
|
||||
@@ -245,8 +247,8 @@ describe('Blocks', () => {
|
||||
onSelect={onSelect}
|
||||
availableBlocksTypes={[BlockEnum.LLM, BlockEnum.AgentV2]}
|
||||
blocks={[
|
||||
createBlock(BlockEnum.LLM, 'LLM', BlockClassificationEnum.Default, 0),
|
||||
createBlock(BlockEnum.AgentV2, 'Agent', BlockClassificationEnum.Default, 3),
|
||||
createBlock(BlockEnum.LLM, 'LLM', BlockClassification.Default, 0),
|
||||
createBlock(BlockEnum.AgentV2, 'Agent', BlockClassification.Default, 3),
|
||||
]}
|
||||
/>
|
||||
</HooksStoreContext>
|
||||
@@ -334,7 +336,7 @@ describe('Blocks', () => {
|
||||
searchText=""
|
||||
onSelect={vi.fn()}
|
||||
availableBlocksTypes={[BlockEnum.AgentV2]}
|
||||
blocks={[createBlock(BlockEnum.AgentV2, 'Agent', BlockClassificationEnum.Default, 3)]}
|
||||
blocks={[createBlock(BlockEnum.AgentV2, 'Agent', BlockClassification.Default, 3)]}
|
||||
/>
|
||||
</HooksStoreContext>
|
||||
</QueryClientProvider>,
|
||||
@@ -392,7 +394,7 @@ describe('Blocks', () => {
|
||||
searchText=""
|
||||
onSelect={onSelect}
|
||||
availableBlocksTypes={[BlockEnum.AgentV2]}
|
||||
blocks={[createBlock(BlockEnum.AgentV2, 'Agent', BlockClassificationEnum.Default, 3)]}
|
||||
blocks={[createBlock(BlockEnum.AgentV2, 'Agent', BlockClassification.Default, 3)]}
|
||||
/>
|
||||
</HooksStoreContext>
|
||||
</QueryClientProvider>,
|
||||
@@ -435,7 +437,7 @@ describe('Blocks', () => {
|
||||
searchText=""
|
||||
onSelect={onSelect}
|
||||
availableBlocksTypes={[BlockEnum.AgentV2]}
|
||||
blocks={[createBlock(BlockEnum.AgentV2, 'Agent', BlockClassificationEnum.Default, 3)]}
|
||||
blocks={[createBlock(BlockEnum.AgentV2, 'Agent', BlockClassification.Default, 3)]}
|
||||
/>
|
||||
</HooksStoreContext>
|
||||
</QueryClientProvider>,
|
||||
|
||||
@@ -20,6 +20,21 @@ vi.mock('@/app/components/workflow/nodes/_base/components/mcp-tool-availability'
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/block-selector/market-place-plugin/action', () => ({
|
||||
default: () => <button type="button" aria-label="common.operation.more" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({
|
||||
default: () => <div data-testid="install-from-marketplace" />,
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission',
|
||||
() => ({
|
||||
default: () => ({ canInstallPlugin: true, currentDifyVersion: '1.0.0' }),
|
||||
}),
|
||||
)
|
||||
|
||||
vi.mock('@/utils/var', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/utils/var')>()),
|
||||
getMarketplaceUrl: (path = '') => `https://marketplace.test${path}`,
|
||||
@@ -36,9 +51,9 @@ describe('FeaturedTools', () => {
|
||||
mockUseTheme.mockReturnValue({ theme: Theme.light } as ReturnType<typeof useTheme>)
|
||||
})
|
||||
|
||||
it('shows more featured tools when the list exceeds the initial quota', async () => {
|
||||
it('reveals featured tools in batches and then returns to the initial list', async () => {
|
||||
const user = userEvent.setup()
|
||||
const plugins = Array.from({ length: 6 }, (_, index) =>
|
||||
const plugins = Array.from({ length: 11 }, (_, index) =>
|
||||
createPlugin({
|
||||
plugin_id: `plugin-${index + 1}`,
|
||||
latest_package_identifier: `plugin-${index + 1}@1.0.0`,
|
||||
@@ -59,12 +74,27 @@ describe('FeaturedTools', () => {
|
||||
expect(screen.getByText('Provider 1')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Provider 6')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('workflow.tabs.showMoreFeatured'))
|
||||
const showMoreButton = screen.getByRole('button', {
|
||||
name: 'workflow.tabs.showMoreFeatured',
|
||||
})
|
||||
expect(showMoreButton).not.toHaveAttribute('aria-expanded')
|
||||
|
||||
expect(screen.getByText('Provider 6')).toBeInTheDocument()
|
||||
await user.click(showMoreButton)
|
||||
|
||||
expect(screen.getByText('Provider 10')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Provider 11')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'workflow.tabs.showMoreFeatured' }))
|
||||
|
||||
expect(screen.getByText('Provider 11')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'workflow.tabs.showLessFeatured' }))
|
||||
|
||||
expect(screen.queryByText('Provider 6')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('honors the persisted collapsed state', () => {
|
||||
it('restores the collapsed state and expands from the keyboard', async () => {
|
||||
const user = userEvent.setup()
|
||||
localStorage.setItem('workflow_tools_featured_collapsed', 'true')
|
||||
|
||||
render(
|
||||
@@ -75,8 +105,44 @@ describe('FeaturedTools', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.tabs.featuredTools')).toBeInTheDocument()
|
||||
const trigger = screen.getByRole('button', { name: 'workflow.tabs.featuredTools' })
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(screen.queryByText('Provider One')).not.toBeInTheDocument()
|
||||
|
||||
trigger.focus()
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getByText('Provider One')).toBeInTheDocument()
|
||||
expect(globalThis.localStorage.setItem).toHaveBeenCalledWith(
|
||||
'workflow_tools_featured_collapsed',
|
||||
'false',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the marketplace link and row actions keyboard reachable', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<FeaturedTools
|
||||
plugins={[createPlugin({ name: 'plugin-one' })]}
|
||||
providerMap={new Map()}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const detailsLink = screen.getByRole('link', { name: 'Plugin One' })
|
||||
const installButton = screen.getByRole('button', { name: 'plugin.installAction' })
|
||||
const moreButton = screen.getByRole('button', { name: 'common.operation.more' })
|
||||
|
||||
expect(detailsLink).toHaveAttribute('href', 'https://marketplace.test/plugins/org/plugin-one')
|
||||
|
||||
detailsLink.focus()
|
||||
await user.tab()
|
||||
expect(installButton).toHaveFocus()
|
||||
|
||||
await user.tab()
|
||||
expect(moreButton).toHaveFocus()
|
||||
})
|
||||
|
||||
it('shows the marketplace empty state when no featured tools are available', () => {
|
||||
|
||||
@@ -14,13 +14,20 @@ vi.mock('@/hooks/use-theme', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/block-selector/market-place-plugin/action', () => ({
|
||||
default: () => <div data-testid="marketplace-action" />,
|
||||
default: () => <button type="button" aria-label="common.operation.more" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({
|
||||
default: () => <div data-testid="install-from-marketplace" />,
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission',
|
||||
() => ({
|
||||
default: () => ({ canInstallPlugin: true, currentDifyVersion: '1.0.0' }),
|
||||
}),
|
||||
)
|
||||
|
||||
vi.mock('@/utils/var', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/utils/var')>()
|
||||
return {
|
||||
@@ -106,7 +113,12 @@ describe('FeaturedTriggers', () => {
|
||||
|
||||
render(<FeaturedTriggers plugins={[]} providerMap={new Map()} onSelect={vi.fn()} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /workflow\.tabs\.featuredTools/ }))
|
||||
const trigger = screen.getByRole('button', { name: /workflow\.tabs\.featuredTools/ })
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
||||
|
||||
await user.click(trigger)
|
||||
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
||||
|
||||
expect(
|
||||
screen.queryByRole('link', { name: 'workflow.tabs.noFeaturedTriggers' }),
|
||||
@@ -117,9 +129,9 @@ describe('FeaturedTriggers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should show more and show less across installed providers', async () => {
|
||||
it('should reveal installed providers in batches and then return to the initial list', async () => {
|
||||
const user = userEvent.setup()
|
||||
const providers = Array.from({ length: 6 }).map((_, index) =>
|
||||
const providers = Array.from({ length: 11 }).map((_, index) =>
|
||||
createTriggerProvider({
|
||||
id: `provider-${index}`,
|
||||
name: `provider-${index}`,
|
||||
@@ -141,10 +153,19 @@ describe('FeaturedTriggers', () => {
|
||||
expect(screen.getByText('Provider 4')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Provider 5')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('workflow.tabs.showMoreFeatured'))
|
||||
expect(screen.getByText('Provider 5')).toBeInTheDocument()
|
||||
const showMoreButton = screen.getByRole('button', {
|
||||
name: 'workflow.tabs.showMoreFeatured',
|
||||
})
|
||||
expect(showMoreButton).not.toHaveAttribute('aria-expanded')
|
||||
|
||||
await user.click(screen.getByText('workflow.tabs.showLessFeatured'))
|
||||
await user.click(showMoreButton)
|
||||
expect(screen.getByText('Provider 9')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Provider 10')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'workflow.tabs.showMoreFeatured' }))
|
||||
expect(screen.getByText('Provider 10')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'workflow.tabs.showLessFeatured' }))
|
||||
expect(screen.queryByText('Provider 5')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -192,38 +213,28 @@ describe('FeaturedTriggers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should align featured item icons with the trigger list column', () => {
|
||||
const provider = createTriggerProvider()
|
||||
it('should keep the marketplace link and row actions keyboard reachable', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<FeaturedTriggers
|
||||
plugins={[
|
||||
createPlugin({ plugin_id: 'plugin-1', latest_package_identifier: '[email protected]' }),
|
||||
createPlugin({
|
||||
name: 'plugin-two',
|
||||
plugin_id: 'plugin-2',
|
||||
latest_package_identifier: '[email protected]',
|
||||
label: { en_US: 'Plugin Two', zh_Hans: '插件二' },
|
||||
}),
|
||||
]}
|
||||
providerMap={
|
||||
new Map([
|
||||
['plugin-1', provider],
|
||||
['[email protected]', provider],
|
||||
])
|
||||
}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
<FeaturedTriggers plugins={[createPlugin()]} providerMap={new Map()} onSelect={vi.fn()} />,
|
||||
)
|
||||
|
||||
const installedRow = screen.getByText('Provider One').closest('.select-none')
|
||||
expect(installedRow).toHaveClass('h-8', 'pr-2', 'pl-3')
|
||||
expect(installedRow?.parentElement?.parentElement?.parentElement).toHaveClass('p-1')
|
||||
const detailsLink = screen.getByRole('link', { name: 'Plugin One' })
|
||||
const installButton = screen.getByRole('button', { name: 'plugin.installAction' })
|
||||
const moreButton = screen.getByRole('button', { name: 'common.operation.more' })
|
||||
|
||||
const uninstalledRow = screen.getByText('Plugin Two').closest('.group')
|
||||
expect(uninstalledRow).toHaveClass('h-8', 'pr-2', 'pl-3')
|
||||
expect(uninstalledRow?.parentElement).toHaveClass('mb-1', 'last-of-type:mb-0')
|
||||
expect(uninstalledRow?.parentElement?.parentElement).toHaveClass('p-1')
|
||||
expect(detailsLink).toHaveAttribute(
|
||||
'href',
|
||||
'https://marketplace.test/plugins/org/trigger-plugin',
|
||||
)
|
||||
|
||||
detailsLink.focus()
|
||||
await user.tab()
|
||||
expect(installButton).toHaveFocus()
|
||||
|
||||
await user.tab()
|
||||
expect(moreButton).toHaveFocus()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useTabs, useToolTabs } from '../hooks'
|
||||
import { TabsEnum, ToolTypeEnum } from '../types'
|
||||
import { TabType, ToolType } from '../types'
|
||||
|
||||
describe('block-selector hooks', () => {
|
||||
beforeEach(() => {
|
||||
@@ -11,12 +11,12 @@ describe('block-selector hooks', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabs({
|
||||
noStart: false,
|
||||
defaultActiveTab: TabsEnum.Start,
|
||||
defaultActiveTab: TabType.Start,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current.tabs.find((tab) => tab.key === TabsEnum.Start)?.disabled).toBeFalsy()
|
||||
expect(result.current.activeTab).toBe(TabsEnum.Start)
|
||||
expect(result.current.tabs.find((tab) => tab.key === TabType.Start)?.disabled).toBeFalsy()
|
||||
expect(result.current.initialTab).toBe(TabType.Start)
|
||||
})
|
||||
|
||||
it('disables the start tab when an unconfigured start placeholder exists', () => {
|
||||
@@ -24,87 +24,54 @@ describe('block-selector hooks', () => {
|
||||
useTabs({
|
||||
noStart: false,
|
||||
hasStartPlaceholderNode: true,
|
||||
defaultActiveTab: TabsEnum.Start,
|
||||
defaultActiveTab: TabType.Start,
|
||||
}),
|
||||
)
|
||||
|
||||
const startTab = result.current.tabs.find((tab) => tab.key === TabsEnum.Start)
|
||||
const startTab = result.current.tabs.find((tab) => tab.key === TabType.Start)
|
||||
expect(startTab?.disabled).toBe(true)
|
||||
expect(startTab?.disabledTip).toBe('workflow.tabs.unconfiguredStartDisabledTip')
|
||||
expect(startTab?.disabledTipLinkKey).toBe('startNodesDocs')
|
||||
expect(result.current.activeTab).toBe(TabsEnum.Blocks)
|
||||
expect(result.current.initialTab).toBe(TabType.Blocks)
|
||||
})
|
||||
|
||||
it('keeps the start tab enabled when forcing it on and resets to a valid tab after disabling blocks', () => {
|
||||
const props: Parameters<typeof useTabs>[0] = {
|
||||
noBlocks: false,
|
||||
noStart: false,
|
||||
forceEnableStartTab: true,
|
||||
}
|
||||
it('chooses the enabled start panel when blocks, sources, and tools are unavailable', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabs({
|
||||
noBlocks: true,
|
||||
noSources: true,
|
||||
noTools: true,
|
||||
noStart: false,
|
||||
forceEnableStartTab: true,
|
||||
}),
|
||||
)
|
||||
|
||||
const { result, rerender } = renderHook((nextProps) => useTabs(nextProps), {
|
||||
initialProps: props,
|
||||
})
|
||||
|
||||
expect(result.current.tabs.find((tab) => tab.key === TabsEnum.Start)?.disabled).toBeFalsy()
|
||||
|
||||
act(() => {
|
||||
result.current.setActiveTab(TabsEnum.Blocks)
|
||||
})
|
||||
|
||||
rerender({
|
||||
...props,
|
||||
noBlocks: true,
|
||||
noSources: true,
|
||||
noTools: true,
|
||||
})
|
||||
|
||||
expect(result.current.activeTab).toBe(TabsEnum.Start)
|
||||
expect(result.current.tabs.find((tab) => tab.key === TabType.Start)?.disabled).toBeFalsy()
|
||||
expect(result.current.initialTab).toBe(TabType.Start)
|
||||
})
|
||||
|
||||
it('returns the MCP tab only when it is not hidden', () => {
|
||||
const { result: visible } = renderHook(() => useToolTabs())
|
||||
const { result: hidden } = renderHook(() => useToolTabs(true))
|
||||
|
||||
expect(visible.current.some((tab) => tab.key === ToolTypeEnum.MCP)).toBe(true)
|
||||
expect(hidden.current.some((tab) => tab.key === ToolTypeEnum.MCP)).toBe(false)
|
||||
expect(visible.current.some((tab) => tab.key === ToolType.MCP)).toBe(true)
|
||||
expect(hidden.current.some((tab) => tab.key === ToolType.MCP)).toBe(false)
|
||||
})
|
||||
|
||||
it('includes the snippets tab by default', () => {
|
||||
const { result } = renderHook(() => useTabs({}))
|
||||
|
||||
expect(result.current.tabs.some((tab) => tab.key === TabsEnum.Snippets)).toBe(true)
|
||||
expect(result.current.tabs.some((tab) => tab.key === TabType.Snippets)).toBe(true)
|
||||
})
|
||||
|
||||
it('hides the snippets tab and falls back when snippets are disabled', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabs({
|
||||
defaultActiveTab: TabsEnum.Snippets,
|
||||
defaultActiveTab: TabType.Snippets,
|
||||
noSnippets: true,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current.tabs.some((tab) => tab.key === TabsEnum.Snippets)).toBe(false)
|
||||
expect(result.current.activeTab).toBe(TabsEnum.Blocks)
|
||||
})
|
||||
|
||||
it('resets the active tab to the current default tab', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabs({
|
||||
noStart: false,
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.setActiveTab(TabsEnum.Start)
|
||||
})
|
||||
|
||||
expect(result.current.activeTab).toBe(TabsEnum.Start)
|
||||
|
||||
act(() => {
|
||||
result.current.resetActiveTab()
|
||||
})
|
||||
|
||||
expect(result.current.activeTab).toBe(TabsEnum.Blocks)
|
||||
expect(result.current.tabs.some((tab) => tab.key === TabType.Snippets)).toBe(false)
|
||||
expect(result.current.initialTab).toBe(TabType.Blocks)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import { screen } from '@testing-library/react'
|
||||
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
|
||||
import { BlockEnum } from '../../types'
|
||||
import NodeSelectorWrapper from '../index'
|
||||
import { BlockClassificationEnum } from '../types'
|
||||
import { BlockClassification } from '../types'
|
||||
|
||||
vi.mock('reactflow', async () =>
|
||||
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock(),
|
||||
@@ -29,7 +29,7 @@ const createBlock = (type: BlockEnum, title: string): NodeDefault => ({
|
||||
type,
|
||||
title,
|
||||
sort: 0,
|
||||
classification: BlockClassificationEnum.Default,
|
||||
classification: BlockClassification.Default,
|
||||
author: 'Dify',
|
||||
description: `${title} description`,
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@ import { FlowType } from '@/types/common'
|
||||
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
|
||||
import { BlockEnum } from '../../types'
|
||||
import NodeSelector from '../main'
|
||||
import { BlockClassificationEnum, TabsEnum } from '../types'
|
||||
import { BlockClassification, TabType } from '../types'
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
useStoreApi: () => ({
|
||||
@@ -50,7 +50,7 @@ vi.mock('@/service/use-tools', () => ({
|
||||
|
||||
const createBlock = (type: BlockEnum, title: string): NodeDefault => ({
|
||||
metaData: {
|
||||
classification: BlockClassificationEnum.Default,
|
||||
classification: BlockClassification.Default,
|
||||
sort: 0,
|
||||
type,
|
||||
title,
|
||||
@@ -153,6 +153,63 @@ describe('NodeSelector', () => {
|
||||
expect(screen.getByPlaceholderText('workflow.tabs.searchBlock')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('preserves the current popup session until a controlled close actually unmounts it', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onOpenChange = vi.fn()
|
||||
|
||||
renderNodeSelector(
|
||||
<NodeSelector
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
onSelect={vi.fn()}
|
||||
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
|
||||
availableBlocksTypes={[BlockEnum.LLM, BlockEnum.Start]}
|
||||
showStartTab
|
||||
trigger={() => <button type="button">selector-open</button>}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'workflow.tabs.start' }))
|
||||
const searchInput = screen.getByPlaceholderText('workflow.tabs.searchTrigger')
|
||||
await user.type(searchInput, 'webhook')
|
||||
await user.click(screen.getByRole('button', { name: 'selector-open' }))
|
||||
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
expect(screen.getByRole('tab', { name: 'workflow.tabs.start' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
)
|
||||
expect(searchInput).toHaveValue('webhook')
|
||||
})
|
||||
|
||||
it('focuses the search input on open and keeps focus on a tab when it is activated', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderNodeSelector(
|
||||
<NodeSelector
|
||||
onSelect={vi.fn()}
|
||||
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
|
||||
availableBlocksTypes={[BlockEnum.LLM, BlockEnum.Start]}
|
||||
showStartTab
|
||||
trigger={(open) => (
|
||||
<button type="button">{open ? 'selector-open' : 'selector-closed'}</button>
|
||||
)}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'selector-closed' }))
|
||||
expect(screen.getByPlaceholderText('workflow.tabs.searchBlock')).toHaveFocus()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog').parentElement).toHaveStyle({ position: 'fixed' })
|
||||
})
|
||||
|
||||
const startTab = screen.getByRole('tab', { name: 'workflow.tabs.start' })
|
||||
await user.click(startTab)
|
||||
|
||||
expect(startTab).toHaveFocus()
|
||||
expect(screen.getByPlaceholderText('workflow.tabs.searchTrigger')).not.toHaveFocus()
|
||||
})
|
||||
|
||||
it('does not open or emit open changes when disabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onOpenChange = vi.fn()
|
||||
@@ -307,7 +364,7 @@ describe('NodeSelector', () => {
|
||||
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
|
||||
availableBlocksTypes={[BlockEnum.LLM, BlockEnum.Start]}
|
||||
showStartTab
|
||||
defaultActiveTab={TabsEnum.Start}
|
||||
defaultActiveTab={TabType.Start}
|
||||
/>,
|
||||
{
|
||||
initialStoreState: {
|
||||
@@ -328,9 +385,7 @@ describe('NodeSelector', () => {
|
||||
expect(
|
||||
await screen.findByText('workflow.tabs.unconfiguredStartDisabledTip'),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'workflow.tabs.startDisabledTipLearnMore' }),
|
||||
).toHaveAttribute('href', 'https://docs.dify.ai/en/self-host/use-dify/nodes/trigger/overview')
|
||||
expect(screen.queryByRole('link')).not.toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('workflow.tabs.searchBlock')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -342,7 +397,7 @@ describe('NodeSelector', () => {
|
||||
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
|
||||
availableBlocksTypes={[BlockEnum.LLM, BlockEnum.Start, BlockEnum.TriggerPlugin]}
|
||||
showStartTab
|
||||
defaultActiveTab={TabsEnum.Start}
|
||||
defaultActiveTab={TabType.Start}
|
||||
/>,
|
||||
{
|
||||
initialStoreState: {
|
||||
|
||||
@@ -50,12 +50,16 @@ describe('StartBlocks', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.blocks.start')).toBeInTheDocument()
|
||||
const userInputButton = screen.getByRole('button', { name: 'workflow.blocks.start' })
|
||||
expect(userInputButton).toBeInTheDocument()
|
||||
expect(userInputButton).toHaveAccessibleDescription(
|
||||
'workflow.nodes.start.userInputTipDescription',
|
||||
)
|
||||
expect(screen.getByText('workflow.blocks.trigger-webhook')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.blocks.originalStartNode')).toBeInTheDocument()
|
||||
expect(onContentStateChange).toHaveBeenCalledWith(true)
|
||||
|
||||
await user.click(screen.getByText('workflow.blocks.start'))
|
||||
await user.click(userInputButton)
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(BlockEnum.Start)
|
||||
})
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
|
||||
import Tabs from '../tabs'
|
||||
import { TabsEnum } from '../types'
|
||||
import { SelectorContent } from '../tabs'
|
||||
import { TabType } from '../types'
|
||||
|
||||
const render = (ui: React.ReactElement) =>
|
||||
renderWithSystemFeatures(ui, { systemFeatures: { enable_marketplace: true } })
|
||||
@@ -60,15 +60,15 @@ vi.mock('../data-sources', () => ({
|
||||
|
||||
vi.mock('../all-tools', () => ({
|
||||
default: (props: {
|
||||
buildInTools: Array<{ icon: string | Record<string, string> }>
|
||||
buildInTools: Array<{ icon: string | Record<string, string>; name: string }>
|
||||
showFeatured: boolean
|
||||
featuredLoading: boolean
|
||||
onFeaturedInstallSuccess: () => Promise<void>
|
||||
}) => (
|
||||
<div>
|
||||
tools-content
|
||||
{props.buildInTools.map((tool, index) => (
|
||||
<span key={index}>{typeof tool.icon === 'string' ? tool.icon : 'object-icon'}</span>
|
||||
{props.buildInTools.map((tool) => (
|
||||
<span key={tool.name}>{typeof tool.icon === 'string' ? tool.icon : 'object-icon'}</span>
|
||||
))}
|
||||
<span>{props.showFeatured ? 'featured-on' : 'featured-off'}</span>
|
||||
<span>{props.featuredLoading ? 'featured-loading' : 'featured-idle'}</span>
|
||||
@@ -87,67 +87,112 @@ describe('Tabs', () => {
|
||||
})
|
||||
|
||||
const baseProps = {
|
||||
activeTab: TabsEnum.Start,
|
||||
onActiveTabChange: vi.fn(),
|
||||
searchText: '',
|
||||
tags: [],
|
||||
onTagsChange: vi.fn(),
|
||||
defaultTab: TabType.Start,
|
||||
searchInputRef: React.createRef<HTMLInputElement>(),
|
||||
onSelect: vi.fn(),
|
||||
onRequestClose: vi.fn(),
|
||||
blocks: [],
|
||||
tabs: [
|
||||
{ key: TabsEnum.Start, name: 'Start' },
|
||||
{ key: TabsEnum.Blocks, name: 'Blocks', disabled: true },
|
||||
{ key: TabsEnum.Tools, name: 'Tools' },
|
||||
{ key: TabType.Start, name: 'Start' },
|
||||
{ key: TabType.Blocks, name: 'Blocks', disabled: true },
|
||||
{ key: TabType.Tools, name: 'Tools' },
|
||||
],
|
||||
filterElem: <div>filter</div>,
|
||||
}
|
||||
|
||||
it('should render start content and disabled tab tooltip text', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Tabs {...baseProps} />)
|
||||
render(<SelectorContent {...baseProps} />)
|
||||
|
||||
expect(screen.getByText('start-content'))!.toBeInTheDocument()
|
||||
await user.hover(screen.getByText('Blocks'))
|
||||
await user.hover(screen.getByRole('tab', { name: 'Blocks' }))
|
||||
expect(await screen.findByText('workflow.tabs.startDisabledTip'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should switch tabs through click handlers and render tools content with normalized icons', () => {
|
||||
const onActiveTabChange = vi.fn()
|
||||
it('should expose tab semantics and use manual keyboard activation', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SelectorContent {...baseProps} />)
|
||||
|
||||
render(<Tabs {...baseProps} activeTab={TabsEnum.Tools} onActiveTabChange={onActiveTabChange} />)
|
||||
const startTab = screen.getByRole('tab', { name: 'Start' })
|
||||
const blocksTab = screen.getByRole('tab', { name: 'Blocks' })
|
||||
const toolsTab = screen.getByRole('tab', { name: 'Tools' })
|
||||
|
||||
fireEvent.click(screen.getByText('Start'))
|
||||
expect(screen.getByRole('tablist')).toBeInTheDocument()
|
||||
expect(startTab).toHaveAttribute('aria-selected', 'true')
|
||||
expect(blocksTab).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(screen.getByRole('tabpanel', { name: 'Start' })).toBeInTheDocument()
|
||||
|
||||
await user.click(startTab)
|
||||
await user.keyboard('{ArrowRight}')
|
||||
expect(blocksTab).toHaveFocus()
|
||||
expect(startTab).toHaveAttribute('aria-selected', 'true')
|
||||
|
||||
await user.keyboard('{ArrowRight}')
|
||||
expect(toolsTab).toHaveFocus()
|
||||
expect(startTab).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
it('should sync normalized tools into workflow store state', () => {
|
||||
render(<SelectorContent {...baseProps} defaultTab={TabType.Tools} />)
|
||||
|
||||
expect(onActiveTabChange).toHaveBeenCalledWith(TabsEnum.Start)
|
||||
expect(screen.getByText('tools-content'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('/console/tool.svg'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('featured-on'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('featured-idle'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should sync normalized tools into workflow store state', () => {
|
||||
render(<Tabs {...baseProps} activeTab={TabsEnum.Tools} />)
|
||||
|
||||
expect(mockSetState).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should ignore clicks on disabled and already active tabs', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onActiveTabChange = vi.fn()
|
||||
render(<SelectorContent {...baseProps} />)
|
||||
|
||||
render(<Tabs {...baseProps} activeTab={TabsEnum.Start} onActiveTabChange={onActiveTabChange} />)
|
||||
const startTab = screen.getByRole('tab', { name: 'Start' })
|
||||
await user.click(startTab)
|
||||
await user.click(screen.getByRole('tab', { name: 'Blocks' }))
|
||||
|
||||
await user.click(screen.getByText('Start'))
|
||||
await user.click(screen.getByText('Blocks'))
|
||||
expect(startTab).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
expect(onActiveTabChange).not.toHaveBeenCalled()
|
||||
it('should fall back to an available tab when the active tab is removed', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
function Harness() {
|
||||
const [tabs, setTabs] = React.useState(baseProps.tabs)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setTabs((currentTabs) => currentTabs.filter((tab) => tab.key !== TabType.Tools))
|
||||
}
|
||||
>
|
||||
Remove tools
|
||||
</button>
|
||||
<SelectorContent {...baseProps} tabs={tabs} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
render(<Harness />)
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Tools' }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('tab', { name: 'Tools' })).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Remove tools' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('tab', { name: 'Start' })).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
it('should render sources content when the sources tab is active and data sources are provided', () => {
|
||||
render(
|
||||
<Tabs
|
||||
<SelectorContent
|
||||
{...baseProps}
|
||||
activeTab={TabsEnum.Sources}
|
||||
defaultTab={TabType.Sources}
|
||||
tabs={[...baseProps.tabs, { key: TabType.Sources, name: 'Sources' }]}
|
||||
dataSources={[{ name: 'dataset', icon: '/dataset.svg' } as never]}
|
||||
/>,
|
||||
)
|
||||
@@ -158,7 +203,7 @@ describe('Tabs', () => {
|
||||
it('should keep the previous workflow store state when tool references do not change', () => {
|
||||
mockToolsState.buildInTools = [{ icon: '/console/already-prefixed.svg', name: 'tool' }]
|
||||
|
||||
render(<Tabs {...baseProps} activeTab={TabsEnum.Tools} />)
|
||||
render(<SelectorContent {...baseProps} defaultTab={TabType.Tools} />)
|
||||
|
||||
const previousState = {
|
||||
buildInTools: mockToolsState.buildInTools,
|
||||
@@ -179,7 +224,7 @@ describe('Tabs', () => {
|
||||
mockToolsState.workflowTools = [{ icon: '/workflow.svg', name: 'workflow' }]
|
||||
mockToolsState.mcpTools = [{ icon: '/mcp.svg', name: 'mcp' }]
|
||||
|
||||
render(<Tabs {...baseProps} activeTab={TabsEnum.Tools} />)
|
||||
render(<SelectorContent {...baseProps} defaultTab={TabType.Tools} />)
|
||||
|
||||
expect(screen.getByText('object-icon'))!.toBeInTheDocument()
|
||||
|
||||
@@ -213,7 +258,7 @@ describe('Tabs', () => {
|
||||
it('should skip normalization when a tool list is undefined', () => {
|
||||
mockToolsState.buildInTools = undefined
|
||||
|
||||
render(<Tabs {...baseProps} activeTab={TabsEnum.Tools} />)
|
||||
render(<SelectorContent {...baseProps} defaultTab={TabType.Tools} />)
|
||||
|
||||
expect(screen.getByText('tools-content'))!.toBeInTheDocument()
|
||||
})
|
||||
@@ -221,7 +266,7 @@ describe('Tabs', () => {
|
||||
it('should force start content to render and invalidate built-in tools after featured installs', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<Tabs {...baseProps} activeTab={TabsEnum.Tools} />)
|
||||
render(<SelectorContent {...baseProps} defaultTab={TabType.Tools} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Install featured tool' }))
|
||||
|
||||
@@ -229,9 +274,10 @@ describe('Tabs', () => {
|
||||
expect(mockInvalidateBuiltInTools).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should render start content when blocks are hidden but forceShowStartContent is enabled', () => {
|
||||
render(<Tabs {...baseProps} activeTab={TabsEnum.Start} noBlocks forceShowStartContent />)
|
||||
it('should compose start content directly without tab semantics in standalone mode', () => {
|
||||
render(<SelectorContent {...baseProps} standalonePanel={TabType.Start} />)
|
||||
|
||||
expect(screen.getByText('start-content'))!.toBeInTheDocument()
|
||||
expect(screen.queryByRole('tablist')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Dispatch, RefObject, SetStateAction } from 'react'
|
||||
import type { RefObject } from 'react'
|
||||
import type { Plugin } from '../../plugins/types'
|
||||
import type { BlockEnum, ToolWithProvider } from '../types'
|
||||
import type { ToolDefaultValue, ToolValue } from './types'
|
||||
@@ -24,9 +24,9 @@ import { useMarketplacePlugins } from '../../plugins/marketplace/hooks'
|
||||
import { PluginCategoryEnum } from '../../plugins/types'
|
||||
import FeaturedTools from './featured-tools'
|
||||
import { useToolTabs } from './hooks'
|
||||
import RAGToolRecommendations from './rag-tool-recommendations'
|
||||
import { RAGToolRecommendations } from './rag-tool-recommendations'
|
||||
import Tools from './tools'
|
||||
import { ToolTypeEnum } from './types'
|
||||
import { ToolType } from './types'
|
||||
import ViewTypeSelect, { ViewType } from './view-type-select'
|
||||
|
||||
const marketplaceFooterClassName =
|
||||
@@ -45,7 +45,7 @@ type AllToolsProps = {
|
||||
canNotSelectMultiple?: boolean
|
||||
onSelectMultiple?: (type: BlockEnum, tools: ToolDefaultValue[]) => void
|
||||
selectedTools?: ToolValue[]
|
||||
onTagsChange?: Dispatch<SetStateAction<string[]>>
|
||||
onTagsChange?: (tags: string[]) => void
|
||||
isInRAGPipeline?: boolean
|
||||
featuredPlugins?: Plugin[]
|
||||
featuredLoading?: boolean
|
||||
@@ -78,12 +78,16 @@ const AllTools = ({
|
||||
const { t } = useTranslation()
|
||||
const language = useGetLanguage()
|
||||
const tabs = useToolTabs()
|
||||
const [activeTab, setActiveTab] = useState(ToolTypeEnum.All)
|
||||
const [activeTab, setActiveTab] = useState<ToolType>(ToolType.All)
|
||||
const [activeView, setActiveView] = useState<ViewType>(ViewType.flat)
|
||||
const trimmedSearchText = searchText.trim()
|
||||
const hasSearchText = trimmedSearchText.length > 0
|
||||
const hasTags = tags.length > 0
|
||||
const hasFilter = hasSearchText || hasTags
|
||||
const handleLoadMoreRAGTools = () => {
|
||||
if (!onTagsChange || tags.includes('rag')) return
|
||||
onTagsChange([...tags, 'rag'])
|
||||
}
|
||||
const isMatchingKeywords = (text: string, keywords: string) => {
|
||||
return text.toLowerCase().includes(keywords.toLowerCase())
|
||||
}
|
||||
@@ -101,12 +105,12 @@ const AllTools = ({
|
||||
}, [allProviders])
|
||||
const tools = useMemo(() => {
|
||||
let mergedTools: ToolWithProvider[] = []
|
||||
if (activeTab === ToolTypeEnum.All)
|
||||
if (activeTab === ToolType.All)
|
||||
mergedTools = [...buildInTools, ...customTools, ...workflowTools, ...mcpTools]
|
||||
if (activeTab === ToolTypeEnum.BuiltIn) mergedTools = buildInTools
|
||||
if (activeTab === ToolTypeEnum.Custom) mergedTools = customTools
|
||||
if (activeTab === ToolTypeEnum.Workflow) mergedTools = workflowTools
|
||||
if (activeTab === ToolTypeEnum.MCP) mergedTools = mcpTools
|
||||
if (activeTab === ToolType.BuiltIn) mergedTools = buildInTools
|
||||
if (activeTab === ToolType.Custom) mergedTools = customTools
|
||||
if (activeTab === ToolType.Workflow) mergedTools = workflowTools
|
||||
if (activeTab === ToolType.MCP) mergedTools = mcpTools
|
||||
|
||||
const normalizedSearch = trimmedSearchText.toLowerCase()
|
||||
const getLocalizedText = (text?: Record<string, string> | null) => {
|
||||
@@ -182,9 +186,9 @@ const AllTools = ({
|
||||
|
||||
const pluginRef = useRef<ListRef>(null)
|
||||
const wrapElemRef = useRef<HTMLDivElement>(null)
|
||||
const isSupportGroupView = [ToolTypeEnum.All, ToolTypeEnum.BuiltIn].includes(activeTab)
|
||||
const isSupportGroupView = activeTab === ToolType.All || activeTab === ToolType.BuiltIn
|
||||
|
||||
const isShowRAGRecommendations = isInRAGPipeline && activeTab === ToolTypeEnum.All && !hasFilter
|
||||
const isShowRAGRecommendations = isInRAGPipeline && activeTab === ToolType.All && !hasFilter
|
||||
const hasToolsListContent = tools.length > 0 || isShowRAGRecommendations
|
||||
const hasPluginContent = enable_marketplace && notInstalledPlugins.length > 0
|
||||
const shouldShowEmptyState = hasFilter && !hasToolsListContent && !hasPluginContent
|
||||
@@ -192,7 +196,7 @@ const AllTools = ({
|
||||
showFeatured &&
|
||||
enable_marketplace &&
|
||||
!isInRAGPipeline &&
|
||||
activeTab === ToolTypeEnum.All &&
|
||||
activeTab === ToolType.All &&
|
||||
!hasFilter
|
||||
const shouldShowMarketplaceFooter = enable_marketplace && !hasFilter
|
||||
|
||||
@@ -204,10 +208,10 @@ const AllTools = ({
|
||||
[onSelect],
|
||||
)
|
||||
const toolsListTitle = useMemo(() => {
|
||||
if (activeTab === ToolTypeEnum.BuiltIn) return t(($) => $.allToolPlugins, { ns: 'tools' })
|
||||
if (activeTab === ToolTypeEnum.Custom) return t(($) => $.allSwaggerAPIAsTool, { ns: 'tools' })
|
||||
if (activeTab === ToolTypeEnum.Workflow) return t(($) => $.allWorkflowAsTool, { ns: 'tools' })
|
||||
if (activeTab === ToolTypeEnum.MCP) return t(($) => $.allMCP, { ns: 'tools' })
|
||||
if (activeTab === ToolType.BuiltIn) return t(($) => $.allToolPlugins, { ns: 'tools' })
|
||||
if (activeTab === ToolType.Custom) return t(($) => $.allSwaggerAPIAsTool, { ns: 'tools' })
|
||||
if (activeTab === ToolType.Workflow) return t(($) => $.allWorkflowAsTool, { ns: 'tools' })
|
||||
if (activeTab === ToolType.MCP) return t(($) => $.allMCP, { ns: 'tools' })
|
||||
return t(($) => $.allTools, { ns: 'tools' })
|
||||
}, [activeTab, t])
|
||||
|
||||
@@ -216,17 +220,19 @@ const AllTools = ({
|
||||
<div className="flex items-center justify-between border-b border-divider-subtle px-3">
|
||||
<div className="flex h-8 items-center space-x-1">
|
||||
{tabs.map((tab) => (
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex h-6 cursor-pointer items-center rounded-md px-2 hover:bg-state-base-hover',
|
||||
'flex h-6 cursor-pointer items-center rounded-md border-0 bg-transparent px-2 hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
|
||||
'text-xs font-medium text-text-secondary',
|
||||
activeTab === tab.key && 'bg-state-base-hover-alt',
|
||||
)}
|
||||
key={tab.key}
|
||||
aria-pressed={activeTab === tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
>
|
||||
{tab.name}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{isSupportGroupView && <ViewTypeSelect viewType={activeView} onChange={setActiveView} />}
|
||||
@@ -242,7 +248,7 @@ const AllTools = ({
|
||||
<RAGToolRecommendations
|
||||
viewType={isSupportGroupView ? activeView : ViewType.flat}
|
||||
onSelect={handleRAGSelect}
|
||||
onTagsChange={onTagsChange}
|
||||
onLoadMore={handleLoadMoreRAGTools}
|
||||
/>
|
||||
)}
|
||||
{shouldShowFeatured && (
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import type { NodeDefault, OnSelectBlock } from '../types'
|
||||
import type { BlockClassificationEnum } from './types'
|
||||
import type { BlockClassification } from './types'
|
||||
import {
|
||||
createPreviewCardHandle,
|
||||
PreviewCard,
|
||||
PreviewCardContent,
|
||||
PreviewCardTrigger,
|
||||
} from '@langgenius/dify-ui/preview-card'
|
||||
import { groupBy } from 'es-toolkit/compat'
|
||||
import { memo, useCallback, useMemo } from 'react'
|
||||
import { Fragment, memo, useCallback, useId, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStoreApi } from 'reactflow'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
@@ -16,6 +15,7 @@ import { BlockEnum } from '../types'
|
||||
import { AgentBlockItem } from './agent-selector'
|
||||
import { BLOCK_CLASSIFICATIONS } from './constants'
|
||||
import { useBlocks } from './hooks'
|
||||
import { BlockSelectorPreviewCardContent } from './preview-card'
|
||||
|
||||
type BlocksProps = {
|
||||
searchText: string
|
||||
@@ -37,6 +37,7 @@ const Blocks = ({
|
||||
const store = useStoreApi()
|
||||
const blocksFromHooks = useBlocks()
|
||||
const previewCardHandle = useMemo(() => createPreviewCardHandle<BlockPreviewPayload>(), [])
|
||||
const previewDescriptionBaseId = useId()
|
||||
|
||||
// Use external blocks if provided, otherwise fallback to hook-based blocks
|
||||
const blocks =
|
||||
@@ -89,7 +90,7 @@ const Blocks = ({
|
||||
const isEmpty = Object.values(groups).every((list) => !list.length)
|
||||
|
||||
const renderGroup = useCallback(
|
||||
(classification: BlockClassificationEnum) => {
|
||||
(classification: BlockClassification) => {
|
||||
const list = [...groups[classification]!].sort((a, b) => {
|
||||
if (a.metaData.type === BlockEnum.AgentV2) return -1
|
||||
if (b.metaData.type === BlockEnum.AgentV2) return 1
|
||||
@@ -139,38 +140,49 @@ const Blocks = ({
|
||||
)
|
||||
}
|
||||
|
||||
const previewDescriptionId = block.metaData.description
|
||||
? `${previewDescriptionBaseId}-${block.metaData.type}`
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<PreviewCardTrigger
|
||||
key={block.metaData.type}
|
||||
delay={150}
|
||||
closeDelay={150}
|
||||
handle={previewCardHandle}
|
||||
payload={{ block }}
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full cursor-pointer items-center rounded-lg px-3 text-left hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
onClick={() => onSelect(block.metaData.type)}
|
||||
>
|
||||
<BlockIcon className="mr-2 shrink-0" type={block.metaData.type} />
|
||||
<span className="min-w-0 grow truncate text-sm text-text-secondary">
|
||||
{block.metaData.title}
|
||||
</span>
|
||||
{block.metaData.type === BlockEnum.LoopEnd && (
|
||||
<Badge
|
||||
text={t(($) => $['nodes.loop.loopNode'], { ns: 'workflow' })}
|
||||
className="ml-2 shrink-0"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<Fragment key={block.metaData.type}>
|
||||
<PreviewCardTrigger
|
||||
delay={150}
|
||||
closeDelay={150}
|
||||
handle={previewCardHandle}
|
||||
payload={{ block }}
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-describedby={previewDescriptionId}
|
||||
className="flex h-8 w-full cursor-pointer items-center rounded-lg px-3 text-left hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
onClick={() => onSelect(block.metaData.type)}
|
||||
>
|
||||
<BlockIcon className="mr-2 shrink-0" type={block.metaData.type} />
|
||||
<span className="min-w-0 grow truncate text-sm text-text-secondary">
|
||||
{block.metaData.title}
|
||||
</span>
|
||||
{block.metaData.type === BlockEnum.LoopEnd && (
|
||||
<Badge
|
||||
text={t(($) => $['nodes.loop.loopNode'], { ns: 'workflow' })}
|
||||
className="ml-2 shrink-0"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
{previewDescriptionId && (
|
||||
<span id={previewDescriptionId} className="sr-only">
|
||||
{block.metaData.description}
|
||||
</span>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
[groups, onSelect, previewCardHandle, t, store],
|
||||
[groups, onSelect, previewCardHandle, previewDescriptionBaseId, t, store],
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -198,15 +210,13 @@ function BlockPreviewCard({ payload }: BlockPreviewCardProps) {
|
||||
const { block } = payload
|
||||
|
||||
return (
|
||||
<PreviewCardContent placement="right" popupClassName="w-[200px] border-none px-3 py-2">
|
||||
<div>
|
||||
<BlockIcon size="md" className="mb-2" type={block.metaData.type} />
|
||||
<div className="mb-1 system-md-medium text-text-primary">{block.metaData.title}</div>
|
||||
<div className="system-xs-regular wrap-break-word text-text-tertiary">
|
||||
{block.metaData.description}
|
||||
</div>
|
||||
<BlockSelectorPreviewCardContent>
|
||||
<BlockIcon size="md" className="mb-2" type={block.metaData.type} />
|
||||
<div className="mb-1 system-md-medium text-text-primary">{block.metaData.title}</div>
|
||||
<div className="system-xs-regular wrap-break-word text-text-tertiary">
|
||||
{block.metaData.description}
|
||||
</div>
|
||||
</PreviewCardContent>
|
||||
</BlockSelectorPreviewCardContent>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { Block } from '../types'
|
||||
import { BlockEnum } from '../types'
|
||||
import { BlockClassificationEnum } from './types'
|
||||
import { BlockClassification } from './types'
|
||||
|
||||
export const BLOCK_CLASSIFICATIONS = [
|
||||
BlockClassificationEnum.Default,
|
||||
BlockClassificationEnum.QuestionUnderstand,
|
||||
BlockClassificationEnum.Logic,
|
||||
BlockClassificationEnum.Transform,
|
||||
BlockClassificationEnum.Utilities,
|
||||
BlockClassification.Default,
|
||||
BlockClassification.QuestionUnderstand,
|
||||
BlockClassification.Logic,
|
||||
BlockClassification.Transform,
|
||||
BlockClassification.Utilities,
|
||||
] as const
|
||||
|
||||
export const DEFAULT_FILE_EXTENSIONS_IN_LOCAL_FILE_DATA_SOURCE = [
|
||||
@@ -34,19 +34,19 @@ export const DEFAULT_FILE_EXTENSIONS_IN_LOCAL_FILE_DATA_SOURCE = [
|
||||
|
||||
export const START_BLOCKS = [
|
||||
{
|
||||
classification: BlockClassificationEnum.Default,
|
||||
classification: BlockClassification.Default,
|
||||
type: BlockEnum.Start,
|
||||
title: 'User Input',
|
||||
description: 'Traditional start node for user input',
|
||||
},
|
||||
{
|
||||
classification: BlockClassificationEnum.Default,
|
||||
classification: BlockClassification.Default,
|
||||
type: BlockEnum.TriggerSchedule,
|
||||
title: 'Schedule Trigger',
|
||||
description: 'Time-based workflow trigger',
|
||||
},
|
||||
{
|
||||
classification: BlockClassificationEnum.Default,
|
||||
classification: BlockClassification.Default,
|
||||
type: BlockEnum.TriggerWebhook,
|
||||
title: 'Webhook Trigger',
|
||||
description: 'HTTP callback trigger',
|
||||
@@ -62,98 +62,98 @@ export const ENTRY_NODE_TYPES = [
|
||||
|
||||
export const BLOCKS = [
|
||||
{
|
||||
classification: BlockClassificationEnum.Default,
|
||||
classification: BlockClassification.Default,
|
||||
type: BlockEnum.Agent,
|
||||
title: 'Agent',
|
||||
},
|
||||
{
|
||||
classification: BlockClassificationEnum.Default,
|
||||
classification: BlockClassification.Default,
|
||||
type: BlockEnum.AgentV2,
|
||||
title: 'Agent',
|
||||
},
|
||||
{
|
||||
classification: BlockClassificationEnum.Default,
|
||||
classification: BlockClassification.Default,
|
||||
type: BlockEnum.LLM,
|
||||
title: 'LLM',
|
||||
},
|
||||
{
|
||||
classification: BlockClassificationEnum.Default,
|
||||
classification: BlockClassification.Default,
|
||||
type: BlockEnum.KnowledgeRetrieval,
|
||||
title: 'Knowledge Retrieval',
|
||||
},
|
||||
{
|
||||
classification: BlockClassificationEnum.Default,
|
||||
classification: BlockClassification.Default,
|
||||
type: BlockEnum.End,
|
||||
title: 'End',
|
||||
},
|
||||
{
|
||||
classification: BlockClassificationEnum.Default,
|
||||
classification: BlockClassification.Default,
|
||||
type: BlockEnum.Answer,
|
||||
title: 'Direct Answer',
|
||||
},
|
||||
{
|
||||
classification: BlockClassificationEnum.QuestionUnderstand,
|
||||
classification: BlockClassification.QuestionUnderstand,
|
||||
type: BlockEnum.QuestionClassifier,
|
||||
title: 'Question Classifier',
|
||||
},
|
||||
{
|
||||
classification: BlockClassificationEnum.Logic,
|
||||
classification: BlockClassification.Logic,
|
||||
type: BlockEnum.IfElse,
|
||||
title: 'IF/ELSE',
|
||||
},
|
||||
{
|
||||
classification: BlockClassificationEnum.Logic,
|
||||
classification: BlockClassification.Logic,
|
||||
type: BlockEnum.LoopEnd,
|
||||
title: 'Exit Loop',
|
||||
description: '',
|
||||
},
|
||||
{
|
||||
classification: BlockClassificationEnum.Logic,
|
||||
classification: BlockClassification.Logic,
|
||||
type: BlockEnum.Iteration,
|
||||
title: 'Iteration',
|
||||
},
|
||||
{
|
||||
classification: BlockClassificationEnum.Logic,
|
||||
classification: BlockClassification.Logic,
|
||||
type: BlockEnum.Loop,
|
||||
title: 'Loop',
|
||||
},
|
||||
{
|
||||
classification: BlockClassificationEnum.Transform,
|
||||
classification: BlockClassification.Transform,
|
||||
type: BlockEnum.Code,
|
||||
title: 'Code',
|
||||
},
|
||||
{
|
||||
classification: BlockClassificationEnum.Transform,
|
||||
classification: BlockClassification.Transform,
|
||||
type: BlockEnum.TemplateTransform,
|
||||
title: 'Templating Transform',
|
||||
},
|
||||
{
|
||||
classification: BlockClassificationEnum.Transform,
|
||||
classification: BlockClassification.Transform,
|
||||
type: BlockEnum.VariableAggregator,
|
||||
title: 'Variable Aggregator',
|
||||
},
|
||||
{
|
||||
classification: BlockClassificationEnum.Transform,
|
||||
classification: BlockClassification.Transform,
|
||||
type: BlockEnum.DocExtractor,
|
||||
title: 'Doc Extractor',
|
||||
},
|
||||
{
|
||||
classification: BlockClassificationEnum.Transform,
|
||||
classification: BlockClassification.Transform,
|
||||
type: BlockEnum.Assigner,
|
||||
title: 'Variable Assigner',
|
||||
},
|
||||
{
|
||||
classification: BlockClassificationEnum.Transform,
|
||||
classification: BlockClassification.Transform,
|
||||
type: BlockEnum.ParameterExtractor,
|
||||
title: 'Parameter Extractor',
|
||||
},
|
||||
{
|
||||
classification: BlockClassificationEnum.Utilities,
|
||||
classification: BlockClassification.Utilities,
|
||||
type: BlockEnum.HttpRequest,
|
||||
title: 'HTTP Request',
|
||||
},
|
||||
{
|
||||
classification: BlockClassificationEnum.Utilities,
|
||||
classification: BlockClassification.Utilities,
|
||||
type: BlockEnum.ListFilter,
|
||||
title: 'List Filter',
|
||||
},
|
||||
|
||||
@@ -5,10 +5,10 @@ import type { ToolDefaultValue, ToolValue } from './types'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from '@langgenius/dify-ui/collapsible'
|
||||
import {
|
||||
createPreviewCardHandle,
|
||||
PreviewCard,
|
||||
PreviewCardContent,
|
||||
PreviewCardTrigger,
|
||||
} from '@langgenius/dify-ui/preview-card'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
@@ -23,11 +23,14 @@ import { useFeaturedToolsCollapsed } from '@/app/components/workflow/block-selec
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import Link from '@/next/link'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
import { PluginCategoryEnum } from '../../plugins/types'
|
||||
import BlockIcon from '../block-icon'
|
||||
import { BlockEnum } from '../types'
|
||||
import { BlockSelectorRow } from './block-selector-row'
|
||||
import { BlockSelectorPreviewCardContent } from './preview-card'
|
||||
import Tools from './tools'
|
||||
import { ToolTypeEnum } from './types'
|
||||
import { ToolType } from './types'
|
||||
import { ViewType } from './view-type-select'
|
||||
|
||||
const MAX_RECOMMENDED_COUNT = 15
|
||||
@@ -117,113 +120,118 @@ const FeaturedTools = ({
|
||||
const showEmptyState = !isLoading && totalVisible === 0
|
||||
|
||||
return (
|
||||
<div className="px-3 pt-2 pb-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center rounded-md px-0 py-1 text-left text-text-primary"
|
||||
onClick={() => setIsCollapsed((prev) => !prev)}
|
||||
>
|
||||
<Collapsible
|
||||
className="px-3 pt-2 pb-3"
|
||||
open={!isCollapsed}
|
||||
onOpenChange={(open) => setIsCollapsed(!open)}
|
||||
>
|
||||
<CollapsibleTrigger className="min-h-0 justify-start gap-0 rounded-md px-0 py-1 hover:not-data-disabled:bg-transparent">
|
||||
<span className="system-xs-medium text-text-primary">
|
||||
{t(($) => $['tabs.featuredTools'], { ns: 'workflow' })}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'i-custom-vender-solid-arrows-arrow-down-round-fill',
|
||||
'ml-0.5 size-4 text-text-tertiary transition-transform',
|
||||
isCollapsed ? '-rotate-90' : 'rotate-0',
|
||||
)}
|
||||
aria-hidden
|
||||
className="ml-0.5 i-custom-vender-solid-arrows-arrow-down-round-fill size-4 -rotate-90 text-text-tertiary transition-transform group-data-panel-open:rotate-0 motion-reduce:transition-none"
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
{isLoading && (
|
||||
<div className="py-3">
|
||||
<Loading type="app" />
|
||||
</div>
|
||||
)}
|
||||
<CollapsiblePanel>
|
||||
{isLoading && (
|
||||
<div className="py-3">
|
||||
<Loading type="app" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showEmptyState && (
|
||||
<p className="py-2 system-xs-regular text-text-tertiary">
|
||||
<Link
|
||||
className="text-text-accent"
|
||||
href={getMarketplaceCategoryUrl(PluginCategoryEnum.tool)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t(($) => $['tabs.noFeaturedPlugins'], { ns: 'workflow' })}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
{showEmptyState && (
|
||||
<p className="py-2 system-xs-regular text-text-tertiary">
|
||||
<Link
|
||||
className="text-text-accent"
|
||||
href={getMarketplaceCategoryUrl(PluginCategoryEnum.tool)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t(($) => $['tabs.noFeaturedPlugins'], { ns: 'workflow' })}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!showEmptyState && !isLoading && (
|
||||
<>
|
||||
{visibleInstalledProviders.length > 0 && (
|
||||
<Tools
|
||||
className="p-0"
|
||||
tools={visibleInstalledProviders}
|
||||
onSelect={onSelect}
|
||||
canNotSelectMultiple
|
||||
toolType={ToolTypeEnum.All}
|
||||
viewType={ViewType.flat}
|
||||
hasSearchText={false}
|
||||
selectedTools={selectedTools}
|
||||
{!showEmptyState && !isLoading && (
|
||||
<>
|
||||
{visibleInstalledProviders.length > 0 && (
|
||||
<Tools
|
||||
className="p-0"
|
||||
tools={visibleInstalledProviders}
|
||||
onSelect={onSelect}
|
||||
canNotSelectMultiple
|
||||
toolType={ToolType.All}
|
||||
viewType={ViewType.flat}
|
||||
hasSearchText={false}
|
||||
selectedTools={selectedTools}
|
||||
/>
|
||||
)}
|
||||
|
||||
{visibleUninstalledPlugins.length > 0 && (
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
{visibleUninstalledPlugins.map((plugin) => (
|
||||
<FeaturedToolUninstalledItem
|
||||
key={plugin.plugin_id}
|
||||
plugin={plugin}
|
||||
language={language}
|
||||
previewCardHandle={previewCardHandle}
|
||||
onInstallSuccess={async () => {
|
||||
await onInstallSuccess?.()
|
||||
}}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isLoading && totalVisible > 0 && canToggleVisibility && (
|
||||
<button
|
||||
type="button"
|
||||
className="group mt-1 flex w-full cursor-pointer touch-manipulation items-center gap-x-2 rounded-lg border-0 bg-transparent py-1 pr-2 pl-3 text-left text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden"
|
||||
onClick={() => {
|
||||
setVisibleCount((count) => {
|
||||
if (count >= maxAvailable) return INITIAL_VISIBLE_COUNT
|
||||
|
||||
return Math.min(count + INITIAL_VISIBLE_COUNT, maxAvailable)
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center px-1 text-text-tertiary group-hover:text-text-secondary group-focus-visible:text-text-secondary">
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-ri-more-line size-4 group-hover:hidden group-focus-visible:hidden"
|
||||
/>
|
||||
{isExpanded ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-custom-vender-solid-arrows-arrow-up-double-line hidden size-4 group-hover:block group-focus-visible:block"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-custom-vender-solid-arrows-arrow-down-double-line hidden size-4 group-hover:block group-focus-visible:block"
|
||||
/>
|
||||
)}
|
||||
|
||||
{visibleUninstalledPlugins.length > 0 && (
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
{visibleUninstalledPlugins.map((plugin) => (
|
||||
<FeaturedToolUninstalledItem
|
||||
key={plugin.plugin_id}
|
||||
plugin={plugin}
|
||||
language={language}
|
||||
previewCardHandle={previewCardHandle}
|
||||
onInstallSuccess={async () => {
|
||||
await onInstallSuccess?.()
|
||||
}}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isLoading && totalVisible > 0 && canToggleVisibility && (
|
||||
<div
|
||||
className="group mt-1 flex cursor-pointer items-center gap-x-2 rounded-lg py-1 pr-2 pl-3 text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary"
|
||||
onClick={() => {
|
||||
setVisibleCount((count) => {
|
||||
if (count >= maxAvailable) return INITIAL_VISIBLE_COUNT
|
||||
|
||||
return Math.min(count + INITIAL_VISIBLE_COUNT, maxAvailable)
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center px-1 text-text-tertiary transition-colors group-hover:text-text-secondary">
|
||||
<span className="i-ri-more-line size-4 group-hover:hidden" />
|
||||
{isExpanded ? (
|
||||
<span className="i-custom-vender-solid-arrows-arrow-up-double-line hidden size-4 group-hover:block" />
|
||||
) : (
|
||||
<span className="i-custom-vender-solid-arrows-arrow-down-double-line hidden size-4 group-hover:block" />
|
||||
)}
|
||||
</div>
|
||||
<div className="system-xs-regular">
|
||||
{t(($) => $[isExpanded ? 'tabs.showLessFeatured' : 'tabs.showMoreFeatured'], {
|
||||
ns: 'workflow',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="system-xs-regular">
|
||||
{t(($) => $[isExpanded ? 'tabs.showLessFeatured' : 'tabs.showMoreFeatured'], {
|
||||
ns: 'workflow',
|
||||
})}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</CollapsiblePanel>
|
||||
<PreviewCard handle={previewCardHandle}>
|
||||
{({ payload }) => (
|
||||
<FeaturedToolPreviewCard payload={payload as FeaturedToolPreviewPayload | undefined} />
|
||||
)}
|
||||
</PreviewCard>
|
||||
</div>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -266,63 +274,77 @@ function FeaturedToolUninstalledItem({
|
||||
}
|
||||
}, [actionOpen])
|
||||
|
||||
const row = (
|
||||
<div className="group flex h-8 w-full items-center rounded-lg pr-1 pl-3 hover:bg-state-base-hover">
|
||||
<div className="flex h-full min-w-0 items-center">
|
||||
const detailsLink = (
|
||||
<Link
|
||||
className="flex h-full min-w-0 flex-1 items-center rounded-lg focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
href={getMarketplaceUrl(`/plugins/${plugin.org}/${plugin.name}`)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<div className="flex min-w-0 items-center">
|
||||
<BlockIcon type={BlockEnum.Tool} toolIcon={plugin.icon} />
|
||||
<div className="ml-2 min-w-0">
|
||||
<div className="truncate system-sm-medium text-text-secondary">{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto flex h-full items-center gap-1 pl-1">
|
||||
<span
|
||||
className={`system-xs-regular text-text-tertiary ${actionOpen ? 'hidden' : 'group-hover:hidden'}`}
|
||||
>
|
||||
{installCountLabel}
|
||||
</span>
|
||||
<div
|
||||
className={`flex h-full items-center gap-1 system-xs-medium text-components-button-secondary-accent-text [&_.action-btn]:size-6 [&_.action-btn]:min-h-0 [&_.action-btn]:rounded-lg [&_.action-btn]:p-0 ${actionOpen ? '' : 'hidden group-hover:flex'}`}
|
||||
>
|
||||
{canInstallPlugin && (
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer rounded-md px-1.5 py-0.5 hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
setActionOpen(false)
|
||||
setIsInstallModalOpen(true)
|
||||
}}
|
||||
>
|
||||
{t(($) => $.installAction, { ns: 'plugin' })}
|
||||
</button>
|
||||
)}
|
||||
<Action
|
||||
open={actionOpen}
|
||||
onOpenChange={setActionOpen}
|
||||
author={plugin.org}
|
||||
name={plugin.name}
|
||||
version={plugin.latest_version}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{description ? (
|
||||
// Preview is supplementary: icon / label / brief are all reachable from
|
||||
// the InstallFromMarketplace modal that opens on click, so hover/focus-only
|
||||
// activation is a11y-safe. See packages/dify-ui/AGENTS.md → Overlay Primitive Selection.
|
||||
<PreviewCardTrigger
|
||||
delay={150}
|
||||
closeDelay={150}
|
||||
handle={previewCardHandle}
|
||||
payload={{ plugin, label, description }}
|
||||
render={row}
|
||||
/>
|
||||
) : (
|
||||
row
|
||||
)}
|
||||
<BlockSelectorRow as="div" className="group pr-1 focus-within:bg-state-base-hover">
|
||||
{description ? (
|
||||
<PreviewCardTrigger
|
||||
delay={150}
|
||||
closeDelay={150}
|
||||
handle={previewCardHandle}
|
||||
payload={{ plugin, label, description }}
|
||||
render={detailsLink}
|
||||
/>
|
||||
) : (
|
||||
detailsLink
|
||||
)}
|
||||
<div className="relative ml-auto flex h-full items-center pl-1">
|
||||
<span
|
||||
className={cn(
|
||||
'system-xs-regular text-text-tertiary',
|
||||
actionOpen
|
||||
? 'hidden'
|
||||
: 'group-focus-within:hidden group-hover:hidden [@media(hover:none)]:hidden',
|
||||
)}
|
||||
>
|
||||
{installCountLabel}
|
||||
</span>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute right-0 flex h-full items-center gap-1 system-xs-medium text-components-button-secondary-accent-text opacity-0 transition-opacity motion-reduce:transition-none [&_.action-btn]:size-6 [&_.action-btn]:min-h-0 [&_.action-btn]:rounded-lg [&_.action-btn]:p-0',
|
||||
actionOpen
|
||||
? 'pointer-events-auto opacity-100'
|
||||
: 'pointer-events-none group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 [@media(hover:none)]:pointer-events-auto [@media(hover:none)]:opacity-100',
|
||||
)}
|
||||
>
|
||||
{canInstallPlugin && (
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer rounded-md px-1.5 py-0.5 hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
onClick={() => {
|
||||
setActionOpen(false)
|
||||
setIsInstallModalOpen(true)
|
||||
}}
|
||||
>
|
||||
{t(($) => $.installAction, { ns: 'plugin' })}
|
||||
</button>
|
||||
)}
|
||||
<Action
|
||||
open={actionOpen}
|
||||
onOpenChange={setActionOpen}
|
||||
author={plugin.org}
|
||||
name={plugin.name}
|
||||
version={plugin.latest_version}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</BlockSelectorRow>
|
||||
{isInstallModalOpen && canInstallPlugin && (
|
||||
<PluginInstallPermissionProvider
|
||||
canInstallPlugin={canInstallPlugin}
|
||||
@@ -353,20 +375,13 @@ function FeaturedToolPreviewCard({ payload }: FeaturedToolPreviewCardProps) {
|
||||
if (!payload) return null
|
||||
|
||||
return (
|
||||
<PreviewCardContent placement="right" popupClassName="w-[224px] px-3 py-2.5">
|
||||
<div>
|
||||
<BlockIcon
|
||||
size="md"
|
||||
className="mb-2"
|
||||
type={BlockEnum.Tool}
|
||||
toolIcon={payload.plugin.icon}
|
||||
/>
|
||||
<div className="mb-1 text-sm/5 text-text-primary">{payload.label}</div>
|
||||
<div className="text-xs leading-[18px] wrap-break-word text-text-secondary">
|
||||
{payload.description}
|
||||
</div>
|
||||
<BlockSelectorPreviewCardContent>
|
||||
<BlockIcon size="md" className="mb-2" type={BlockEnum.Tool} toolIcon={payload.plugin.icon} />
|
||||
<div className="mb-1 text-sm/5 text-text-primary">{payload.label}</div>
|
||||
<div className="text-xs leading-[18px] wrap-break-word text-text-secondary">
|
||||
{payload.description}
|
||||
</div>
|
||||
</PreviewCardContent>
|
||||
</BlockSelectorPreviewCardContent>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@ import type { TriggerDefaultValue, TriggerWithProvider } from './types'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from '@langgenius/dify-ui/collapsible'
|
||||
import {
|
||||
createPreviewCardHandle,
|
||||
PreviewCard,
|
||||
PreviewCardContent,
|
||||
PreviewCardTrigger,
|
||||
} from '@langgenius/dify-ui/preview-card'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
@@ -23,10 +23,12 @@ import { useFeaturedTriggersCollapsed } from '@/app/components/workflow/block-se
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import Link from '@/next/link'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
import { PluginCategoryEnum } from '../../plugins/types'
|
||||
import BlockIcon from '../block-icon'
|
||||
import { BlockEnum } from '../types'
|
||||
import { BlockSelectorRow } from './block-selector-row'
|
||||
import { BlockSelectorPreviewCardContent } from './preview-card'
|
||||
import { TriggerPluginActionPreviewCard } from './trigger-plugin/action-item'
|
||||
import TriggerPluginItem from './trigger-plugin/item'
|
||||
|
||||
@@ -123,101 +125,106 @@ const FeaturedTriggers = ({
|
||||
const showEmptyState = !isLoading && totalVisible === 0
|
||||
|
||||
return (
|
||||
<div className="pt-2 pb-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center rounded-md px-4 py-1 text-left text-text-primary"
|
||||
onClick={() => setIsCollapsed((prev) => !prev)}
|
||||
>
|
||||
<Collapsible
|
||||
className="pt-2 pb-3"
|
||||
open={!isCollapsed}
|
||||
onOpenChange={(open) => setIsCollapsed(!open)}
|
||||
>
|
||||
<CollapsibleTrigger className="min-h-0 justify-start gap-0 rounded-md px-4 py-1 hover:not-data-disabled:bg-transparent">
|
||||
<span className="system-xs-medium text-text-primary">
|
||||
{t(($) => $['tabs.featuredTools'], { ns: 'workflow' })}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'i-custom-vender-solid-arrows-arrow-down-round-fill',
|
||||
'ml-0.5 size-4 text-text-tertiary transition-transform',
|
||||
isCollapsed ? '-rotate-90' : 'rotate-0',
|
||||
)}
|
||||
aria-hidden
|
||||
className="ml-0.5 i-custom-vender-solid-arrows-arrow-down-round-fill size-4 -rotate-90 text-text-tertiary transition-transform group-data-panel-open:rotate-0 motion-reduce:transition-none"
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
{isLoading && (
|
||||
<div className="py-3">
|
||||
<Loading type="app" />
|
||||
</div>
|
||||
)}
|
||||
<CollapsiblePanel>
|
||||
{isLoading && (
|
||||
<div className="py-3">
|
||||
<Loading type="app" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showEmptyState && (
|
||||
<p className="px-4 py-2 system-xs-regular text-text-tertiary">
|
||||
<Link
|
||||
className="text-text-accent"
|
||||
href={getMarketplaceCategoryUrl(PluginCategoryEnum.trigger)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t(($) => $['tabs.noFeaturedTriggers'], { ns: 'workflow' })}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!showEmptyState && !isLoading && (
|
||||
<div className="mt-1 p-1">
|
||||
{visibleInstalledProviders.map((provider) => (
|
||||
<TriggerPluginItem
|
||||
key={provider.id}
|
||||
payload={provider}
|
||||
hasSearchText={false}
|
||||
previewCardHandle={triggerActionPreviewCardHandle}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
|
||||
{visibleUninstalledPlugins.map((plugin) => (
|
||||
<div key={plugin.plugin_id} className="mb-1 last-of-type:mb-0">
|
||||
<FeaturedTriggerUninstalledItem
|
||||
plugin={plugin}
|
||||
language={language}
|
||||
previewCardHandle={previewCardHandle}
|
||||
onInstallSuccess={async () => {
|
||||
await onInstallSuccess?.()
|
||||
}}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && totalVisible > 0 && canToggleVisibility && (
|
||||
<div
|
||||
className="group mt-1 flex cursor-pointer items-center gap-x-2 rounded-lg py-1 pr-2 pl-3 text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary"
|
||||
onClick={() => {
|
||||
setVisibleCount((count) => {
|
||||
if (count >= maxAvailable) return INITIAL_VISIBLE_COUNT
|
||||
|
||||
return Math.min(count + INITIAL_VISIBLE_COUNT, maxAvailable)
|
||||
})
|
||||
}}
|
||||
{showEmptyState && (
|
||||
<p className="px-4 py-2 system-xs-regular text-text-tertiary">
|
||||
<Link
|
||||
className="text-text-accent"
|
||||
href={getMarketplaceCategoryUrl(PluginCategoryEnum.trigger)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<div className="flex items-center px-1 text-text-tertiary transition-colors group-hover:text-text-secondary">
|
||||
<span className="i-ri-more-line size-4 group-hover:hidden" />
|
||||
{isExpanded ? (
|
||||
<span className="i-custom-vender-solid-arrows-arrow-up-double-line hidden size-4 group-hover:block" />
|
||||
) : (
|
||||
<span className="i-custom-vender-solid-arrows-arrow-down-double-line hidden size-4 group-hover:block" />
|
||||
)}
|
||||
</div>
|
||||
<div className="system-xs-regular">
|
||||
{t(($) => $[isExpanded ? 'tabs.showLessFeatured' : 'tabs.showMoreFeatured'], {
|
||||
ns: 'workflow',
|
||||
})}
|
||||
{t(($) => $['tabs.noFeaturedTriggers'], { ns: 'workflow' })}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!showEmptyState && !isLoading && (
|
||||
<div className="mt-1 p-1">
|
||||
{visibleInstalledProviders.map((provider) => (
|
||||
<TriggerPluginItem
|
||||
key={provider.id}
|
||||
payload={provider}
|
||||
hasSearchText={false}
|
||||
previewCardHandle={triggerActionPreviewCardHandle}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
|
||||
{visibleUninstalledPlugins.map((plugin) => (
|
||||
<div key={plugin.plugin_id} className="mb-1 last-of-type:mb-0">
|
||||
<FeaturedTriggerUninstalledItem
|
||||
plugin={plugin}
|
||||
language={language}
|
||||
previewCardHandle={previewCardHandle}
|
||||
onInstallSuccess={async () => {
|
||||
await onInstallSuccess?.()
|
||||
}}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && totalVisible > 0 && canToggleVisibility && (
|
||||
<button
|
||||
type="button"
|
||||
className="group mt-1 flex w-full cursor-pointer touch-manipulation items-center gap-x-2 rounded-lg border-0 bg-transparent py-1 pr-2 pl-3 text-left text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden"
|
||||
onClick={() => {
|
||||
setVisibleCount((count) => {
|
||||
if (count >= maxAvailable) return INITIAL_VISIBLE_COUNT
|
||||
|
||||
return Math.min(count + INITIAL_VISIBLE_COUNT, maxAvailable)
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center px-1 text-text-tertiary group-hover:text-text-secondary group-focus-visible:text-text-secondary">
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-ri-more-line size-4 group-hover:hidden group-focus-visible:hidden"
|
||||
/>
|
||||
{isExpanded ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-custom-vender-solid-arrows-arrow-up-double-line hidden size-4 group-hover:block group-focus-visible:block"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-custom-vender-solid-arrows-arrow-down-double-line hidden size-4 group-hover:block group-focus-visible:block"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="system-xs-regular">
|
||||
{t(($) => $[isExpanded ? 'tabs.showLessFeatured' : 'tabs.showMoreFeatured'], {
|
||||
ns: 'workflow',
|
||||
})}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</CollapsiblePanel>
|
||||
<PreviewCard handle={previewCardHandle}>
|
||||
{({ payload }) => (
|
||||
<FeaturedTriggerPreviewCard
|
||||
@@ -232,7 +239,7 @@ const FeaturedTriggers = ({
|
||||
/>
|
||||
)}
|
||||
</PreviewCard>
|
||||
</div>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -275,8 +282,13 @@ function FeaturedTriggerUninstalledItem({
|
||||
}
|
||||
}, [actionOpen])
|
||||
|
||||
const row = (
|
||||
<BlockSelectorRow as="div" className="group select-none">
|
||||
const detailsLink = (
|
||||
<Link
|
||||
className="flex h-full min-w-0 flex-1 items-center rounded-lg focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
href={getMarketplaceUrl(`/plugins/${plugin.org}/${plugin.name}`)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<div className="flex min-w-0 items-center">
|
||||
<BlockIcon
|
||||
className="mr-2 shrink-0"
|
||||
@@ -288,55 +300,64 @@ function FeaturedTriggerUninstalledItem({
|
||||
<div className="truncate system-sm-medium text-text-secondary">{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto flex h-6 items-center gap-1 pl-1">
|
||||
<span
|
||||
className={`system-xs-regular text-text-tertiary ${actionOpen ? 'hidden' : 'group-hover:hidden'}`}
|
||||
>
|
||||
{installCountLabel}
|
||||
</span>
|
||||
<div
|
||||
className={`flex h-full items-center gap-1 system-xs-medium text-components-button-secondary-accent-text [&_.action-btn]:size-6 [&_.action-btn]:min-h-0 [&_.action-btn]:rounded-lg [&_.action-btn]:p-0 ${actionOpen ? '' : 'hidden group-hover:flex'}`}
|
||||
>
|
||||
{canInstallPlugin && (
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer rounded-md px-1.5 py-0.5 hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
setActionOpen(false)
|
||||
setIsInstallModalOpen(true)
|
||||
}}
|
||||
>
|
||||
{t(($) => $.installAction, { ns: 'plugin' })}
|
||||
</button>
|
||||
)}
|
||||
<Action
|
||||
open={actionOpen}
|
||||
onOpenChange={setActionOpen}
|
||||
author={plugin.org}
|
||||
name={plugin.name}
|
||||
version={plugin.latest_version}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</BlockSelectorRow>
|
||||
</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{description ? (
|
||||
// Preview is supplementary: icon / label / brief are all reachable from
|
||||
// the InstallFromMarketplace modal that opens on click, so hover/focus-only
|
||||
// activation is a11y-safe. See packages/dify-ui/AGENTS.md → Overlay Primitive Selection.
|
||||
<PreviewCardTrigger
|
||||
delay={150}
|
||||
closeDelay={150}
|
||||
handle={previewCardHandle}
|
||||
payload={{ plugin, label, description }}
|
||||
render={row}
|
||||
/>
|
||||
) : (
|
||||
row
|
||||
)}
|
||||
<BlockSelectorRow as="div" className="group select-none focus-within:bg-state-base-hover">
|
||||
{description ? (
|
||||
<PreviewCardTrigger
|
||||
delay={150}
|
||||
closeDelay={150}
|
||||
handle={previewCardHandle}
|
||||
payload={{ plugin, label, description }}
|
||||
render={detailsLink}
|
||||
/>
|
||||
) : (
|
||||
detailsLink
|
||||
)}
|
||||
<div className="relative ml-auto flex h-6 items-center pl-1">
|
||||
<span
|
||||
className={cn(
|
||||
'system-xs-regular text-text-tertiary',
|
||||
actionOpen
|
||||
? 'hidden'
|
||||
: 'group-focus-within:hidden group-hover:hidden [@media(hover:none)]:hidden',
|
||||
)}
|
||||
>
|
||||
{installCountLabel}
|
||||
</span>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute right-0 flex h-full items-center gap-1 system-xs-medium text-components-button-secondary-accent-text opacity-0 transition-opacity motion-reduce:transition-none [&_.action-btn]:size-6 [&_.action-btn]:min-h-0 [&_.action-btn]:rounded-lg [&_.action-btn]:p-0',
|
||||
actionOpen
|
||||
? 'pointer-events-auto opacity-100'
|
||||
: 'pointer-events-none group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 [@media(hover:none)]:pointer-events-auto [@media(hover:none)]:opacity-100',
|
||||
)}
|
||||
>
|
||||
{canInstallPlugin && (
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer rounded-md px-1.5 py-0.5 hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
onClick={() => {
|
||||
setActionOpen(false)
|
||||
setIsInstallModalOpen(true)
|
||||
}}
|
||||
>
|
||||
{t(($) => $.installAction, { ns: 'plugin' })}
|
||||
</button>
|
||||
)}
|
||||
<Action
|
||||
open={actionOpen}
|
||||
onOpenChange={setActionOpen}
|
||||
author={plugin.org}
|
||||
name={plugin.name}
|
||||
version={plugin.latest_version}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</BlockSelectorRow>
|
||||
{isInstallModalOpen && canInstallPlugin && (
|
||||
<PluginInstallPermissionProvider
|
||||
canInstallPlugin={canInstallPlugin}
|
||||
@@ -367,20 +388,18 @@ function FeaturedTriggerPreviewCard({ payload }: FeaturedTriggerPreviewCardProps
|
||||
if (!payload) return null
|
||||
|
||||
return (
|
||||
<PreviewCardContent placement="right" popupClassName="w-[224px] px-3 py-2.5">
|
||||
<div>
|
||||
<BlockIcon
|
||||
size="md"
|
||||
className="mb-2"
|
||||
type={BlockEnum.TriggerPlugin}
|
||||
toolIcon={payload.plugin.icon}
|
||||
/>
|
||||
<div className="mb-1 text-sm/5 text-text-primary">{payload.label}</div>
|
||||
<div className="text-xs leading-[18px] wrap-break-word text-text-secondary">
|
||||
{payload.description}
|
||||
</div>
|
||||
<BlockSelectorPreviewCardContent>
|
||||
<BlockIcon
|
||||
size="md"
|
||||
className="mb-2"
|
||||
type={BlockEnum.TriggerPlugin}
|
||||
toolIcon={payload.plugin.icon}
|
||||
/>
|
||||
<div className="mb-1 text-sm/5 text-text-primary">{payload.label}</div>
|
||||
<div className="text-xs leading-[18px] wrap-break-word text-text-secondary">
|
||||
{payload.description}
|
||||
</div>
|
||||
</PreviewCardContent>
|
||||
</BlockSelectorPreviewCardContent>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { BLOCKS } from './constants'
|
||||
import { TabsEnum, ToolTypeEnum } from './types'
|
||||
|
||||
const startNodesDocsTipLinkKey = 'startNodesDocs' as const
|
||||
import { TabType, ToolType } from './types'
|
||||
|
||||
export const useBlocks = () => {
|
||||
const { t } = useTranslation()
|
||||
@@ -33,7 +31,7 @@ export const useTabs = ({
|
||||
noTools?: boolean
|
||||
noSnippets?: boolean
|
||||
noStart?: boolean
|
||||
defaultActiveTab?: TabsEnum
|
||||
defaultActiveTab?: TabType
|
||||
hasStartPlaceholderNode?: boolean
|
||||
disableStartTab?: boolean
|
||||
forceEnableStartTab?: boolean
|
||||
@@ -49,33 +47,29 @@ export const useTabs = ({
|
||||
const tabs = useMemo(() => {
|
||||
const tabConfigs = [
|
||||
{
|
||||
key: TabsEnum.Blocks,
|
||||
key: TabType.Blocks,
|
||||
name: t(($) => $['tabs.blocks'], { ns: 'workflow' }),
|
||||
show: !noBlocks,
|
||||
},
|
||||
{
|
||||
key: TabsEnum.Sources,
|
||||
key: TabType.Sources,
|
||||
name: t(($) => $['tabs.sources'], { ns: 'workflow' }),
|
||||
show: !noSources,
|
||||
},
|
||||
{
|
||||
key: TabsEnum.Tools,
|
||||
key: TabType.Tools,
|
||||
name: t(($) => $['tabs.tools'], { ns: 'workflow' }),
|
||||
show: !noTools,
|
||||
},
|
||||
{
|
||||
key: TabsEnum.Start,
|
||||
key: TabType.Start,
|
||||
name: t(($) => $['tabs.start'], { ns: 'workflow' }),
|
||||
show: shouldShowStartTab,
|
||||
disabled: shouldDisableStartTab,
|
||||
disabledTip: shouldDisableStartTab ? startDisabledTip : undefined,
|
||||
disabledTipLinkKey:
|
||||
shouldDisableStartTab && !disableStartTab && hasStartPlaceholderNode
|
||||
? startNodesDocsTipLinkKey
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
key: TabsEnum.Snippets,
|
||||
key: TabType.Snippets,
|
||||
name: t(($) => $['tabs.snippets'], { ns: 'workflow' }),
|
||||
show: !noSnippets,
|
||||
},
|
||||
@@ -91,31 +85,25 @@ export const useTabs = ({
|
||||
shouldShowStartTab,
|
||||
shouldDisableStartTab,
|
||||
startDisabledTip,
|
||||
disableStartTab,
|
||||
hasStartPlaceholderNode,
|
||||
])
|
||||
|
||||
const getValidTabKey = useCallback(
|
||||
(targetKey?: TabsEnum) => {
|
||||
const initialTab = useMemo(() => {
|
||||
const getValidTabKey = (targetKey?: TabType) => {
|
||||
if (!targetKey) return undefined
|
||||
const tab = tabs.find((tabItem) => tabItem.key === targetKey)
|
||||
if (!tab || tab.disabled) return undefined
|
||||
return tab.key
|
||||
},
|
||||
[tabs],
|
||||
)
|
||||
|
||||
const initialTab = useMemo(() => {
|
||||
const fallbackTab = tabs.find((tab) => !tab.disabled)?.key ?? TabsEnum.Blocks
|
||||
}
|
||||
const fallbackTab = tabs.find((tab) => !tab.disabled)?.key ?? TabType.Blocks
|
||||
const preferredDefault = getValidTabKey(defaultActiveTab)
|
||||
if (preferredDefault) return preferredDefault
|
||||
|
||||
const preferredOrder: TabsEnum[] = []
|
||||
if (!noBlocks) preferredOrder.push(TabsEnum.Blocks)
|
||||
if (!noTools) preferredOrder.push(TabsEnum.Tools)
|
||||
if (!noSources) preferredOrder.push(TabsEnum.Sources)
|
||||
if (!noStart) preferredOrder.push(TabsEnum.Start)
|
||||
if (!noSnippets) preferredOrder.push(TabsEnum.Snippets)
|
||||
const preferredOrder: TabType[] = []
|
||||
if (!noBlocks) preferredOrder.push(TabType.Blocks)
|
||||
if (!noTools) preferredOrder.push(TabType.Tools)
|
||||
if (!noSources) preferredOrder.push(TabType.Sources)
|
||||
if (!noStart) preferredOrder.push(TabType.Start)
|
||||
if (!noSnippets) preferredOrder.push(TabType.Snippets)
|
||||
|
||||
for (const tabKey of preferredOrder) {
|
||||
const validKey = getValidTabKey(tabKey)
|
||||
@@ -123,48 +111,37 @@ export const useTabs = ({
|
||||
}
|
||||
|
||||
return fallbackTab
|
||||
}, [defaultActiveTab, noBlocks, noSources, noTools, noSnippets, noStart, tabs, getValidTabKey])
|
||||
const [activeTab, setActiveTab] = useState(initialTab)
|
||||
const resetActiveTab = useCallback(() => {
|
||||
setActiveTab(initialTab)
|
||||
}, [initialTab])
|
||||
|
||||
useEffect(() => {
|
||||
const currentTab = tabs.find((tab) => tab.key === activeTab)
|
||||
if (!currentTab || currentTab.disabled) resetActiveTab()
|
||||
}, [tabs, activeTab, resetActiveTab])
|
||||
}, [defaultActiveTab, noBlocks, noSources, noTools, noSnippets, noStart, tabs])
|
||||
|
||||
return {
|
||||
tabs,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
resetActiveTab,
|
||||
initialTab,
|
||||
}
|
||||
}
|
||||
|
||||
export const useToolTabs = (isHideMCPTools?: boolean) => {
|
||||
const { t } = useTranslation()
|
||||
const tabs = [
|
||||
const tabs: Array<{ key: ToolType; name: string }> = [
|
||||
{
|
||||
key: ToolTypeEnum.All,
|
||||
key: ToolType.All,
|
||||
name: t(($) => $['tabs.allTool'], { ns: 'workflow' }),
|
||||
},
|
||||
{
|
||||
key: ToolTypeEnum.BuiltIn,
|
||||
key: ToolType.BuiltIn,
|
||||
name: t(($) => $['tabs.plugin'], { ns: 'workflow' }),
|
||||
},
|
||||
{
|
||||
key: ToolTypeEnum.Custom,
|
||||
key: ToolType.Custom,
|
||||
name: t(($) => $['tabs.customTool'], { ns: 'workflow' }),
|
||||
},
|
||||
{
|
||||
key: ToolTypeEnum.Workflow,
|
||||
key: ToolType.Workflow,
|
||||
name: t(($) => $['tabs.workflowTool'], { ns: 'workflow' }),
|
||||
},
|
||||
]
|
||||
if (!isHideMCPTools) {
|
||||
tabs.push({
|
||||
key: ToolTypeEnum.MCP,
|
||||
key: ToolType.MCP,
|
||||
name: 'MCP',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OffsetOptions, Placement } from '@floating-ui/react'
|
||||
import type { Placement } from '@langgenius/dify-ui/popover'
|
||||
import type { MouseEventHandler } from 'react'
|
||||
import type {
|
||||
CommonNodeType,
|
||||
@@ -7,22 +7,25 @@ import type {
|
||||
OnSelectBlock,
|
||||
ToolWithProvider,
|
||||
} from '../types'
|
||||
import type { TabType } from './types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { memo, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import SearchBox from '@/app/components/plugins/marketplace/search-box'
|
||||
import { useHooksStore } from '@/app/components/workflow/hooks-store'
|
||||
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { BlockEnum, isTriggerNode } from '../types'
|
||||
import { useTabs } from './hooks'
|
||||
import Snippets from './snippets'
|
||||
import Tabs from './tabs'
|
||||
import { TabsEnum } from './types'
|
||||
import { SelectorContent } from './tabs'
|
||||
|
||||
type NodeSelectorOffset =
|
||||
| number
|
||||
| {
|
||||
mainAxis?: number
|
||||
crossAxis?: number
|
||||
}
|
||||
|
||||
export type NodeSelectorProps = {
|
||||
open?: boolean
|
||||
@@ -30,7 +33,7 @@ export type NodeSelectorProps = {
|
||||
onSelect: OnSelectBlock
|
||||
trigger?: (open: boolean) => React.ReactNode
|
||||
placement?: Placement
|
||||
offset?: OffsetOptions
|
||||
offset?: NodeSelectorOffset
|
||||
triggerStyle?: React.CSSProperties
|
||||
triggerClassName?: (open: boolean) => string
|
||||
triggerInnerClassName?: string
|
||||
@@ -43,8 +46,7 @@ export type NodeSelectorProps = {
|
||||
noBlocks?: boolean
|
||||
noTools?: boolean
|
||||
showStartTab?: boolean
|
||||
defaultActiveTab?: TabsEnum
|
||||
forceShowStartContent?: boolean
|
||||
defaultActiveTab?: TabType
|
||||
ignoreNodeIds?: string[]
|
||||
forceEnableStartTab?: boolean // Force enabling Start tab regardless of existing trigger/user input nodes (e.g., when changing Start node type).
|
||||
allowUserInputSelection?: boolean // Override user-input availability; default logic blocks it when triggers exist.
|
||||
@@ -71,7 +73,6 @@ function NodeSelector({
|
||||
noTools = false,
|
||||
showStartTab = false,
|
||||
defaultActiveTab,
|
||||
forceShowStartContent = false,
|
||||
ignoreNodeIds = [],
|
||||
forceEnableStartTab = false,
|
||||
allowUserInputSelection,
|
||||
@@ -81,13 +82,8 @@ function NodeSelector({
|
||||
const { t } = useTranslation()
|
||||
const nodes = useNodes()
|
||||
const flowType = useHooksStore((s) => s.configsMap?.flowType)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [snippetsLoading, setSnippetsLoading] = useState(
|
||||
() => Boolean(openFromProps) && defaultActiveTab === TabsEnum.Snippets,
|
||||
)
|
||||
const debouncedSearchText = useDebounce(searchText, { wait: 500 })
|
||||
const [tags, setTags] = useState<string[]>([])
|
||||
const [localOpen, setLocalOpen] = useState(false)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
// Exclude nodes explicitly ignored (such as the node currently being edited) when checking canvas state.
|
||||
const filteredNodes = useMemo(() => {
|
||||
if (!ignoreNodeIds.length) return nodes
|
||||
@@ -116,7 +112,7 @@ function NodeSelector({
|
||||
const canSelectUserInput = allowUserInputSelection ?? defaultAllowUserInputSelection
|
||||
const disableStartTab = flowType === FlowType.snippet
|
||||
const disableSnippetsTab = flowType === FlowType.snippet
|
||||
const { activeTab, resetActiveTab, setActiveTab, tabs } = useTabs({
|
||||
const { initialTab, tabs } = useTabs({
|
||||
noBlocks,
|
||||
noSources: !dataSources.length,
|
||||
noTools,
|
||||
@@ -133,22 +129,16 @@ function NodeSelector({
|
||||
if (disabled) return
|
||||
|
||||
setLocalOpen(newOpen)
|
||||
|
||||
if (!newOpen) {
|
||||
setSearchText('')
|
||||
setSnippetsLoading(false)
|
||||
resetActiveTab()
|
||||
} else if (activeTab === TabsEnum.Snippets) {
|
||||
setSnippetsLoading(true)
|
||||
}
|
||||
|
||||
if (onOpenChange) onOpenChange(newOpen)
|
||||
},
|
||||
[activeTab, disabled, onOpenChange, resetActiveTab],
|
||||
[disabled, onOpenChange],
|
||||
)
|
||||
const handleTrigger = useCallback<MouseEventHandler<HTMLElement>>((e) => {
|
||||
e.stopPropagation()
|
||||
}, [])
|
||||
const handlePopupClick = useCallback<MouseEventHandler<HTMLDivElement>>((event) => {
|
||||
event.stopPropagation()
|
||||
}, [])
|
||||
|
||||
const handleSelect = useCallback<OnSelectBlock>(
|
||||
(type, pluginDefaultValue) => {
|
||||
@@ -158,13 +148,6 @@ function NodeSelector({
|
||||
[handleOpenChange, onSelect],
|
||||
)
|
||||
|
||||
const handleActiveTabChange = useCallback(
|
||||
(newActiveTab: TabsEnum) => {
|
||||
setActiveTab(newActiveTab)
|
||||
if (open && newActiveTab === TabsEnum.Snippets) setSnippetsLoading(true)
|
||||
},
|
||||
[open, setActiveTab],
|
||||
)
|
||||
const handlePopupKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent) => {
|
||||
if (isolateKeyboardEvents) event.stopPropagation()
|
||||
@@ -172,34 +155,6 @@ function NodeSelector({
|
||||
[isolateKeyboardEvents],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!snippetsLoading) return
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
setSnippetsLoading(false)
|
||||
}, 200)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}, [snippetsLoading])
|
||||
const filterSearchText =
|
||||
activeTab === TabsEnum.Start || activeTab === TabsEnum.Tools ? debouncedSearchText : searchText
|
||||
|
||||
const searchPlaceholder = useMemo(() => {
|
||||
if (activeTab === TabsEnum.Start) return t(($) => $['tabs.searchTrigger'], { ns: 'workflow' })
|
||||
|
||||
if (activeTab === TabsEnum.Blocks) return t(($) => $['tabs.searchBlock'], { ns: 'workflow' })
|
||||
|
||||
if (activeTab === TabsEnum.Tools) return t(($) => $['tabs.searchTool'], { ns: 'workflow' })
|
||||
|
||||
if (activeTab === TabsEnum.Sources)
|
||||
return t(($) => $['tabs.searchDataSource'], { ns: 'workflow' })
|
||||
if (activeTab === TabsEnum.Snippets)
|
||||
return t(($) => $['tabs.searchSnippets'], { ns: 'workflow' })
|
||||
return ''
|
||||
}, [activeTab, t])
|
||||
|
||||
const defaultTriggerElement = (
|
||||
<PopoverTrigger
|
||||
aria-label={t(($) => $['common.addBlock'], { ns: 'workflow' })}
|
||||
@@ -223,8 +178,7 @@ function NodeSelector({
|
||||
) : (
|
||||
<div className={triggerInnerClassName}>{triggerElement}</div>
|
||||
)
|
||||
const resolvedOffset =
|
||||
typeof offset === 'number' || typeof offset === 'function' ? undefined : offset
|
||||
const resolvedOffset = typeof offset === 'number' ? undefined : offset
|
||||
const sideOffset = typeof offset === 'number' ? offset : (resolvedOffset?.mainAxis ?? 0)
|
||||
const alignOffset = typeof offset === 'number' ? 0 : (resolvedOffset?.crossAxis ?? 0)
|
||||
|
||||
@@ -243,8 +197,13 @@ function NodeSelector({
|
||||
placement={placement}
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
positionerProps={{ positionMethod: 'fixed' }}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
popupProps={isolateKeyboardEvents ? { onKeyDown: handlePopupKeyDown } : undefined}
|
||||
popupProps={{
|
||||
initialFocus: searchInputRef,
|
||||
onClick: handlePopupClick,
|
||||
...(isolateKeyboardEvents ? { onKeyDown: handlePopupKeyDown } : {}),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -252,84 +211,20 @@ function NodeSelector({
|
||||
popupClassName,
|
||||
)}
|
||||
>
|
||||
<Tabs
|
||||
<SelectorContent
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
defaultTab={initialTab}
|
||||
standalonePanel={noBlocks ? initialTab : undefined}
|
||||
searchInputRef={searchInputRef}
|
||||
blocks={blocks}
|
||||
allowStartNodeSelection={canSelectUserInput}
|
||||
hasUserInputNode={hasUserInputNode}
|
||||
hasTriggerNode={hasTriggerNode}
|
||||
onActiveTabChange={handleActiveTabChange}
|
||||
filterElem={
|
||||
activeTab === TabsEnum.Snippets ? null : (
|
||||
<div className="relative m-2" onClick={(e) => e.stopPropagation()}>
|
||||
{activeTab === TabsEnum.Start && (
|
||||
<SearchBox
|
||||
autoFocus
|
||||
search={searchText}
|
||||
onSearchChange={setSearchText}
|
||||
tags={tags}
|
||||
onTagsChange={setTags}
|
||||
placeholder={searchPlaceholder}
|
||||
inputClassName="grow"
|
||||
/>
|
||||
)}
|
||||
{activeTab === TabsEnum.Blocks && (
|
||||
<Input
|
||||
showLeftIcon
|
||||
showClearIcon
|
||||
autoFocus
|
||||
value={searchText}
|
||||
placeholder={searchPlaceholder}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
onClear={() => setSearchText('')}
|
||||
/>
|
||||
)}
|
||||
{activeTab === TabsEnum.Sources && (
|
||||
<Input
|
||||
showLeftIcon
|
||||
showClearIcon
|
||||
autoFocus
|
||||
value={searchText}
|
||||
placeholder={searchPlaceholder}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
onClear={() => setSearchText('')}
|
||||
/>
|
||||
)}
|
||||
{activeTab === TabsEnum.Tools && (
|
||||
<SearchBox
|
||||
autoFocus
|
||||
search={searchText}
|
||||
onSearchChange={setSearchText}
|
||||
tags={tags}
|
||||
onTagsChange={setTags}
|
||||
placeholder={t(($) => $.searchTools, { ns: 'plugin' })!}
|
||||
inputClassName="grow"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
onSelect={handleSelect}
|
||||
searchText={filterSearchText}
|
||||
tags={tags}
|
||||
onRequestClose={() => handleOpenChange(false)}
|
||||
availableBlocksTypes={availableBlocksTypes}
|
||||
noBlocks={noBlocks}
|
||||
dataSources={dataSources}
|
||||
noTools={noTools}
|
||||
onTagsChange={setTags}
|
||||
forceShowStartContent={forceShowStartContent}
|
||||
snippetsElem={
|
||||
disableSnippetsTab ? undefined : (
|
||||
<Snippets
|
||||
loading={snippetsLoading}
|
||||
searchText={searchText}
|
||||
onSearchTextChange={setSearchText}
|
||||
insertPayload={snippetInsertPayload}
|
||||
onInserted={() => handleOpenChange(false)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
snippetInsertPayload={snippetInsertPayload}
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { PreviewCardContent, PreviewCardViewport } from '@langgenius/dify-ui/preview-card'
|
||||
|
||||
export function BlockSelectorPreviewCardContent({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<PreviewCardContent
|
||||
placement="right"
|
||||
className="h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) transition-[top,left,right,bottom,transform] duration-180 ease-[cubic-bezier(0.22,1,0.36,1)] data-instant:transition-none motion-reduce:transition-none"
|
||||
popupClassName="relative h-[var(--popup-height,auto)] w-[var(--popup-width,auto)] overflow-hidden border-none transition-[width,height,transform,scale,opacity] duration-[180ms] ease-[cubic-bezier(0.22,1,0.36,1)] data-instant:transition-none motion-reduce:transition-none"
|
||||
>
|
||||
<PreviewCardViewport
|
||||
className={cn(
|
||||
'relative h-full w-full overflow-clip',
|
||||
'**:data-current:w-56 **:data-current:translate-y-0 **:data-current:px-3 **:data-current:py-2.5 **:data-current:opacity-100 **:data-current:transition-[translate,opacity] **:data-current:duration-[180ms,90ms] **:data-current:ease-[cubic-bezier(0.22,1,0.36,1)]',
|
||||
'**:data-previous:w-56 **:data-previous:translate-y-0 **:data-previous:px-3 **:data-previous:py-2.5 **:data-previous:opacity-100 **:data-previous:transition-[translate,opacity] **:data-previous:duration-[180ms,90ms] **:data-previous:ease-[cubic-bezier(0.22,1,0.36,1)]',
|
||||
'data-instant:**:data-current:transition-none data-instant:**:data-previous:transition-none',
|
||||
"data-[activation-direction~='up']:**:data-current:data-starting-style:-translate-y-2 data-[activation-direction~='up']:**:data-current:data-starting-style:opacity-0",
|
||||
"data-[activation-direction~='up']:**:data-previous:data-ending-style:translate-y-2 data-[activation-direction~='up']:**:data-previous:data-ending-style:opacity-0",
|
||||
"data-[activation-direction~='down']:**:data-current:data-starting-style:translate-y-2 data-[activation-direction~='down']:**:data-current:data-starting-style:opacity-0",
|
||||
"data-[activation-direction~='down']:**:data-previous:data-ending-style:-translate-y-2 data-[activation-direction~='down']:**:data-previous:data-ending-style:opacity-0",
|
||||
'motion-reduce:**:data-current:transition-none motion-reduce:**:data-previous:transition-none',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</PreviewCardViewport>
|
||||
</PreviewCardContent>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
'use client'
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import type { ViewType } from '@/app/components/workflow/block-selector/view-type-select'
|
||||
import type { OnSelectBlock } from '@/app/components/workflow/types'
|
||||
import { RiMoreLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useMemo } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid/arrows'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { getFormattedPlugin } from '@/app/components/plugins/marketplace/utils'
|
||||
import { useRAGRecommendationsCollapsed } from '@/app/components/workflow/block-selector/storage'
|
||||
@@ -18,14 +15,14 @@ import List from './list'
|
||||
type RAGToolRecommendationsProps = {
|
||||
viewType: ViewType
|
||||
onSelect: OnSelectBlock
|
||||
onTagsChange: Dispatch<SetStateAction<string[]>>
|
||||
onLoadMore: () => void
|
||||
}
|
||||
|
||||
const RAGToolRecommendations = ({
|
||||
export function RAGToolRecommendations({
|
||||
viewType,
|
||||
onSelect,
|
||||
onTagsChange,
|
||||
}: RAGToolRecommendationsProps) => {
|
||||
onLoadMore,
|
||||
}: RAGToolRecommendationsProps) {
|
||||
const { t } = useTranslation()
|
||||
const [isCollapsed, setIsCollapsed] = useRAGRecommendationsCollapsed()
|
||||
|
||||
@@ -46,25 +43,23 @@ const RAGToolRecommendations = ({
|
||||
return []
|
||||
}, [ragRecommendedPlugins])
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
onTagsChange((prev) => {
|
||||
if (prev.includes('rag')) return prev
|
||||
return [...prev, 'rag']
|
||||
})
|
||||
}, [onTagsChange])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col p-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center rounded-md px-3 pt-1 pb-0.5 text-left text-text-tertiary"
|
||||
className="flex w-full items-center rounded-md px-3 pt-1 pb-0.5 text-left text-text-tertiary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
aria-expanded={!isCollapsed}
|
||||
onClick={() => setIsCollapsed((prev) => !prev)}
|
||||
>
|
||||
<span className="system-xs-medium text-text-tertiary">
|
||||
{t(($) => $['ragToolSuggestions.title'], { ns: 'pipeline' })}
|
||||
</span>
|
||||
<ArrowDownRoundFill
|
||||
className={`ml-1 size-4 text-text-tertiary transition-transform ${isCollapsed ? '-rotate-90' : 'rotate-0'}`}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'ml-1 i-custom-vender-solid-arrows-arrow-down-round-fill size-4 text-text-tertiary transition-transform motion-reduce:transition-none',
|
||||
isCollapsed && '-rotate-90',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{!isCollapsed && (
|
||||
@@ -103,17 +98,21 @@ const RAGToolRecommendations = ({
|
||||
onSelect={onSelect}
|
||||
viewType={viewType}
|
||||
/>
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-x-2 py-1 pr-2 pl-3"
|
||||
onClick={loadMore}
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-x-2 rounded-md py-1 pr-2 pl-3 text-left focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
onClick={onLoadMore}
|
||||
>
|
||||
<div className="px-1">
|
||||
<RiMoreLine className="size-4 text-text-tertiary" />
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="i-ri-more-line block size-4 text-text-tertiary"
|
||||
/>
|
||||
</div>
|
||||
<div className="system-xs-regular text-text-tertiary">
|
||||
{t(($) => $['operation.more'], { ns: 'common' })}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
@@ -121,5 +120,3 @@ const RAGToolRecommendations = ({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(RAGToolRecommendations)
|
||||
|
||||
@@ -45,7 +45,16 @@ describe('Snippets', () => {
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render loading skeleton when loading', () => {
|
||||
const { container } = render(<Snippets loading searchText="" />)
|
||||
mockUseInfiniteSnippetList.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isFetching: true,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: undefined,
|
||||
})
|
||||
|
||||
const { container } = render(<Snippets searchText="" />)
|
||||
|
||||
expect(container.querySelectorAll('.bg-text-quaternary')).not.toHaveLength(0)
|
||||
})
|
||||
@@ -59,7 +68,7 @@ describe('Snippets', () => {
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render snippet rows from infinite list data', () => {
|
||||
it('should keep cached snippet rows visible while refetching', () => {
|
||||
mockUseInfiniteSnippetList.mockReturnValue({
|
||||
data: {
|
||||
pages: [
|
||||
@@ -85,7 +94,7 @@ describe('Snippets', () => {
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isFetching: true,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
|
||||
@@ -20,7 +20,6 @@ import SnippetTagsFilter from './snippet-tags-filter'
|
||||
import { useInsertSnippet } from './use-insert-snippet'
|
||||
|
||||
type SnippetsProps = {
|
||||
loading?: boolean
|
||||
searchText: string
|
||||
onSearchTextChange?: (searchText: string) => void
|
||||
insertPayload?: Parameters<OnNodeAdd>[1]
|
||||
@@ -50,13 +49,7 @@ const LoadingSkeleton = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const Snippets = ({
|
||||
loading = false,
|
||||
searchText,
|
||||
onSearchTextChange,
|
||||
insertPayload,
|
||||
onInserted,
|
||||
}: SnippetsProps) => {
|
||||
const Snippets = ({ searchText, onSearchTextChange, insertPayload, onInserted }: SnippetsProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { handleInsertSnippet } = useInsertSnippet()
|
||||
const deferredSearchText = useDeferredValue(searchText)
|
||||
@@ -103,7 +96,7 @@ const Snippets = ({
|
||||
)
|
||||
|
||||
const filter = (
|
||||
<div className="border-b border-divider-subtle p-2">
|
||||
<div className="p-2">
|
||||
<div className="flex items-center rounded-lg border border-transparent bg-components-input-bg-normal focus-within:border-components-input-border-active hover:border-components-input-border-hover">
|
||||
<div className="flex min-w-0 grow items-center py-1.75 pr-3 pl-2">
|
||||
<span
|
||||
@@ -111,11 +104,15 @@ const Snippets = ({
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
autoFocus
|
||||
type="search"
|
||||
aria-label={t(($) => $['tabs.searchSnippets'], { ns: 'workflow' })}
|
||||
name="query"
|
||||
autoComplete="off"
|
||||
value={searchText}
|
||||
placeholder={t(($) => $['tabs.searchSnippets'], { ns: 'workflow' })}
|
||||
className={cn(
|
||||
'mr-1 ml-1.5 inline-block min-w-0 grow appearance-none bg-transparent system-sm-regular text-components-input-text-filled outline-hidden placeholder:text-components-input-text-placeholder',
|
||||
'[&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none',
|
||||
searchText && 'mr-2',
|
||||
)}
|
||||
onChange={(event) => onSearchTextChange?.(event.target.value)}
|
||||
@@ -124,7 +121,7 @@ const Snippets = ({
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(($) => $['operation.clear'], { ns: 'common' })}
|
||||
className="group shrink-0 cursor-pointer rounded-md p-1 hover:bg-state-base-hover"
|
||||
className="group shrink-0 cursor-pointer rounded-md p-1 outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
onClick={() => onSearchTextChange?.('')}
|
||||
>
|
||||
<span className="i-ri-close-line size-4 text-text-tertiary" aria-hidden="true" />
|
||||
@@ -137,60 +134,56 @@ const Snippets = ({
|
||||
</div>
|
||||
)
|
||||
|
||||
if (loading || isLoading || (isFetching && snippets.length === 0)) {
|
||||
return (
|
||||
<>
|
||||
{filter}
|
||||
<LoadingSkeleton />
|
||||
</>
|
||||
const content =
|
||||
isLoading || (isFetching && snippets.length === 0) ? (
|
||||
<LoadingSkeleton />
|
||||
) : !snippets.length ? (
|
||||
<SnippetEmptyState />
|
||||
) : (
|
||||
<ScrollAreaRoot className="relative max-h-120 max-w-125 overflow-hidden">
|
||||
<ScrollAreaViewport ref={viewportRef}>
|
||||
<ScrollAreaContent className="p-1">
|
||||
{snippets.map((item) => {
|
||||
const row = (
|
||||
<SnippetListItem
|
||||
snippet={item}
|
||||
isHovered={hoveredSnippetId === item.id}
|
||||
onClick={() => handleSnippetClick(item.id)}
|
||||
onMouseEnter={() => setHoveredSnippetId(item.id)}
|
||||
onMouseLeave={() =>
|
||||
setHoveredSnippetId((current) => (current === item.id ? null : current))
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
if (!item.description) return <div key={item.id}>{row}</div>
|
||||
|
||||
return (
|
||||
<Tooltip key={item.id}>
|
||||
<TooltipTrigger delay={0} render={row} />
|
||||
<TooltipContent placement="right-start" className="bg-transparent! p-0!">
|
||||
<SnippetDetailCard snippet={item} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
{isFetchingNextPage && (
|
||||
<div className="flex justify-center px-3 py-2">
|
||||
<Loading />
|
||||
</div>
|
||||
)}
|
||||
</ScrollAreaContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar orientation="vertical">
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{filter}
|
||||
{!snippets.length ? (
|
||||
<SnippetEmptyState />
|
||||
) : (
|
||||
<ScrollAreaRoot className="relative max-h-120 max-w-125 overflow-hidden">
|
||||
<ScrollAreaViewport ref={viewportRef}>
|
||||
<ScrollAreaContent className="p-1">
|
||||
{snippets.map((item) => {
|
||||
const row = (
|
||||
<SnippetListItem
|
||||
snippet={item}
|
||||
isHovered={hoveredSnippetId === item.id}
|
||||
onClick={() => handleSnippetClick(item.id)}
|
||||
onMouseEnter={() => setHoveredSnippetId(item.id)}
|
||||
onMouseLeave={() =>
|
||||
setHoveredSnippetId((current) => (current === item.id ? null : current))
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
if (!item.description) return <div key={item.id}>{row}</div>
|
||||
|
||||
return (
|
||||
<Tooltip key={item.id}>
|
||||
<TooltipTrigger delay={0} render={row} />
|
||||
<TooltipContent placement="right-start" className="bg-transparent! p-0!">
|
||||
<SnippetDetailCard snippet={item} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
{isFetchingNextPage && (
|
||||
<div className="flex justify-center px-3 py-2">
|
||||
<Loading />
|
||||
</div>
|
||||
)}
|
||||
</ScrollAreaContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar orientation="vertical">
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
)}
|
||||
<div className="border-t border-divider-subtle">{content}</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,18 +4,17 @@ import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
createPreviewCardHandle,
|
||||
PreviewCard,
|
||||
PreviewCardContent,
|
||||
PreviewCardTrigger,
|
||||
} from '@langgenius/dify-ui/preview-card'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { memo, useCallback, useEffect, useMemo } from 'react'
|
||||
import { Fragment, memo, useCallback, useEffect, useId, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
|
||||
import BlockIcon from '../block-icon'
|
||||
import { BlockEnum as BlockEnumValues } from '../types'
|
||||
import { BlockSelectorRow } from './block-selector-row'
|
||||
// import { useNodeMetaData } from '../hooks'
|
||||
import { START_BLOCKS } from './constants'
|
||||
import { BlockSelectorPreviewCardContent } from './preview-card'
|
||||
|
||||
type StartBlocksProps = {
|
||||
searchText: string
|
||||
@@ -30,6 +29,8 @@ type StartBlocksProps = {
|
||||
}
|
||||
type StartBlockPreviewPayload = {
|
||||
block: (typeof START_BLOCKS)[number]
|
||||
label: string
|
||||
description: string
|
||||
}
|
||||
|
||||
const StartBlocks = ({
|
||||
@@ -46,7 +47,7 @@ const StartBlocks = ({
|
||||
const { t } = useTranslation()
|
||||
const nodes = useNodes()
|
||||
const previewCardHandle = useMemo(() => createPreviewCardHandle<StartBlockPreviewPayload>(), [])
|
||||
// const nodeMetaData = useNodeMetaData()
|
||||
const previewDescriptionBaseId = useId()
|
||||
|
||||
const filteredBlocks = useMemo(() => {
|
||||
// Check if Start node already exists in workflow
|
||||
@@ -99,22 +100,24 @@ const StartBlocks = ({
|
||||
onContentStateChange?.(!isEmpty)
|
||||
}, [isEmpty, onContentStateChange])
|
||||
|
||||
// Preview is supplementary: the block icon, title and description all become
|
||||
// reachable from the inspector + canvas once the row is clicked to insert
|
||||
// the start node, so hover/focus-only activation is a11y-safe. See
|
||||
// packages/dify-ui/AGENTS.md → Overlay Primitive Selection.
|
||||
const renderBlock = useCallback(
|
||||
(block: (typeof START_BLOCKS)[number]) => {
|
||||
const isUserInput = block.type === BlockEnumValues.Start
|
||||
const isUserInputDisabled = isUserInput && showUserInputDisabled
|
||||
const isRowDisabled = disabled || (isUserInput && showUserInputAdded) || isUserInputDisabled
|
||||
const label = t(($) => $[`blocks.${block.type}`], { ns: 'workflow' })
|
||||
const description =
|
||||
block.type === BlockEnumValues.Start
|
||||
? t(($) => $['nodes.start.userInputTipDescription'], { ns: 'workflow' })
|
||||
: t(($) => $[`blocksAbout.${block.type}`], { ns: 'workflow' })
|
||||
const previewDescriptionId = `${previewDescriptionBaseId}-${block.type}`
|
||||
const disabledReason = t(($) => $['nodes.startPlaceholder.userInputConflictTip'], {
|
||||
ns: 'workflow',
|
||||
})
|
||||
const row = (
|
||||
<BlockSelectorRow
|
||||
aria-disabled={isRowDisabled}
|
||||
aria-describedby={previewDescriptionId}
|
||||
aria-label={isUserInputDisabled ? `${label}. ${disabledReason}` : label}
|
||||
disabled={isRowDisabled}
|
||||
onClick={() => {
|
||||
@@ -153,34 +156,38 @@ const StartBlocks = ({
|
||||
|
||||
if (isUserInputDisabled) {
|
||||
return (
|
||||
<Tooltip key={block.type}>
|
||||
<TooltipTrigger render={row} />
|
||||
<TooltipContent
|
||||
placement="right"
|
||||
sideOffset={8}
|
||||
className="max-w-[240px] rounded-xl border-[0.5px] border-components-panel-border bg-components-tooltip-bg px-4 py-3.5 shadow-lg"
|
||||
>
|
||||
<p className="system-xs-regular text-text-secondary">{disabledReason}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Fragment key={block.type}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={row} />
|
||||
<TooltipContent className="rounded-xl">{disabledReason}</TooltipContent>
|
||||
</Tooltip>
|
||||
<span id={previewDescriptionId} className="sr-only">
|
||||
{description}
|
||||
</span>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PreviewCardTrigger
|
||||
key={block.type}
|
||||
delay={150}
|
||||
closeDelay={150}
|
||||
handle={previewCardHandle}
|
||||
payload={{ block }}
|
||||
render={row}
|
||||
/>
|
||||
<Fragment key={block.type}>
|
||||
<PreviewCardTrigger
|
||||
delay={150}
|
||||
closeDelay={150}
|
||||
handle={previewCardHandle}
|
||||
payload={{ block, label, description }}
|
||||
render={row}
|
||||
/>
|
||||
<span id={previewDescriptionId} className="sr-only">
|
||||
{description}
|
||||
</span>
|
||||
</Fragment>
|
||||
)
|
||||
},
|
||||
[
|
||||
disabled,
|
||||
onSelect,
|
||||
previewCardHandle,
|
||||
previewDescriptionBaseId,
|
||||
showMostCommonBadge,
|
||||
showUserInputAdded,
|
||||
showUserInputDisabled,
|
||||
@@ -223,11 +230,7 @@ type StartBlockPreviewCardProps = {
|
||||
function StartBlockPreviewCard({ payload, t }: StartBlockPreviewCardProps) {
|
||||
if (!payload) return null
|
||||
|
||||
const { block } = payload
|
||||
const description =
|
||||
block.type === BlockEnumValues.Start
|
||||
? t(($) => $['nodes.start.userInputTipDescription'], { ns: 'workflow' })
|
||||
: t(($) => $[`blocksAbout.${block.type}`], { ns: 'workflow' })
|
||||
const { block, label, description } = payload
|
||||
const showDifyTeamAuthor = [
|
||||
BlockEnumValues.Start,
|
||||
BlockEnumValues.TriggerWebhook,
|
||||
@@ -235,20 +238,16 @@ function StartBlockPreviewCard({ payload, t }: StartBlockPreviewCardProps) {
|
||||
].includes(block.type)
|
||||
|
||||
return (
|
||||
<PreviewCardContent placement="right" popupClassName="w-[224px] px-3 pt-3 pb-2.5">
|
||||
<div>
|
||||
<BlockIcon size="md" className="mb-2" type={block.type} />
|
||||
<div className="mb-1 system-md-medium text-text-primary">
|
||||
{t(($) => $[`blocks.${block.type}`], { ns: 'workflow' })}
|
||||
<BlockSelectorPreviewCardContent>
|
||||
<BlockIcon size="md" className="mb-2" type={block.type} />
|
||||
<div className="mb-1 system-md-medium text-text-primary">{label}</div>
|
||||
<div className="system-xs-regular wrap-break-word text-text-secondary">{description}</div>
|
||||
{showDifyTeamAuthor && (
|
||||
<div className="mt-1 system-xs-regular text-text-tertiary">
|
||||
{t(($) => $.author, { ns: 'tools' })} {t(($) => $.difyTeam, { ns: 'workflow' })}
|
||||
</div>
|
||||
<div className="system-xs-regular wrap-break-word text-text-secondary">{description}</div>
|
||||
{showDifyTeamAuthor && (
|
||||
<div className="mt-1 system-xs-regular text-text-tertiary">
|
||||
{t(($) => $.author, { ns: 'tools' })} {t(($) => $.difyTeam, { ns: 'workflow' })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PreviewCardContent>
|
||||
)}
|
||||
</BlockSelectorPreviewCardContent>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,331 +1,266 @@
|
||||
import type { Dispatch, FC, ReactNode, SetStateAction } from 'react'
|
||||
import type { BlockEnum, NodeDefault, OnSelectBlock, ToolWithProvider } from '../types'
|
||||
import type { ReactNode, Ref } from 'react'
|
||||
import type { BlockEnum, NodeDefault, OnNodeAdd, OnSelectBlock, ToolWithProvider } from '../types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Tabs, TabsIndicator, TabsList, TabsPanel, TabsTab } from '@langgenius/dify-ui/tabs'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { memo, useEffect, useMemo } from 'react'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { useFeaturedToolsRecommendations } from '@/service/use-plugins'
|
||||
import {
|
||||
useAllBuiltInTools,
|
||||
useAllCustomTools,
|
||||
useAllMCPTools,
|
||||
useAllWorkflowTools,
|
||||
useInvalidateAllBuiltInTools,
|
||||
} from '@/service/use-tools'
|
||||
import { basePath } from '@/utils/var'
|
||||
import { useWorkflowStore } from '../store'
|
||||
import { SearchInput } from '@/app/components/base/search-input'
|
||||
import SearchBox from '@/app/components/plugins/marketplace/search-box'
|
||||
import AllStartBlocks from './all-start-blocks'
|
||||
import AllTools from './all-tools'
|
||||
import Blocks from './blocks'
|
||||
import DataSources from './data-sources'
|
||||
import { TabsEnum } from './types'
|
||||
import Snippets from './snippets'
|
||||
import { ToolPanel } from './tool-panel'
|
||||
import { TabType } from './types'
|
||||
|
||||
type TabsProps = {
|
||||
activeTab: TabsEnum
|
||||
onActiveTabChange: (activeTab: TabsEnum) => void
|
||||
searchText: string
|
||||
tags: string[]
|
||||
onTagsChange: Dispatch<SetStateAction<string[]>>
|
||||
type TabConfig = {
|
||||
key: TabType
|
||||
name: string
|
||||
disabled?: boolean
|
||||
disabledTip?: ReactNode
|
||||
}
|
||||
|
||||
type SelectorContentProps = {
|
||||
defaultTab: TabType
|
||||
standalonePanel?: TabType
|
||||
tabs: TabConfig[]
|
||||
searchInputRef: Ref<HTMLInputElement>
|
||||
onSelect: OnSelectBlock
|
||||
onRequestClose: () => void
|
||||
availableBlocksTypes?: BlockEnum[]
|
||||
blocks: NodeDefault[]
|
||||
dataSources?: ToolWithProvider[]
|
||||
tabs: Array<{
|
||||
key: TabsEnum
|
||||
name: string
|
||||
disabled?: boolean
|
||||
disabledTip?: ReactNode
|
||||
disabledTipLinkKey?: 'startNodesDocs'
|
||||
}>
|
||||
filterElem: React.ReactNode
|
||||
noBlocks?: boolean
|
||||
noTools?: boolean
|
||||
forceShowStartContent?: boolean // Force show Start content even when noBlocks=true
|
||||
allowStartNodeSelection?: boolean // Allow user input option even when trigger node already exists (e.g. change-node flow or when no Start node yet).
|
||||
allowStartNodeSelection?: boolean
|
||||
hasUserInputNode?: boolean
|
||||
hasTriggerNode?: boolean
|
||||
snippetsElem?: React.ReactNode
|
||||
snippetInsertPayload?: Parameters<OnNodeAdd>[1]
|
||||
}
|
||||
|
||||
const normalizeToolList = (list: ToolWithProvider[] | undefined, currentBasePath?: string) => {
|
||||
if (!list || !currentBasePath) return list
|
||||
|
||||
let changed = false
|
||||
const normalized = list.map((provider) => {
|
||||
if (typeof provider.icon !== 'string') return provider
|
||||
|
||||
const shouldPrefix =
|
||||
provider.icon.startsWith('/') && !provider.icon.startsWith(`${currentBasePath}/`)
|
||||
|
||||
if (!shouldPrefix) return provider
|
||||
|
||||
changed = true
|
||||
return {
|
||||
...provider,
|
||||
icon: `${currentBasePath}${provider.icon}`,
|
||||
}
|
||||
})
|
||||
|
||||
return changed ? normalized : list
|
||||
}
|
||||
|
||||
const getStoreToolUpdates = ({
|
||||
state,
|
||||
buildInTools,
|
||||
customTools,
|
||||
workflowTools,
|
||||
mcpTools,
|
||||
}: {
|
||||
state: {
|
||||
buildInTools?: ToolWithProvider[]
|
||||
customTools?: ToolWithProvider[]
|
||||
workflowTools?: ToolWithProvider[]
|
||||
mcpTools?: ToolWithProvider[]
|
||||
}
|
||||
buildInTools?: ToolWithProvider[]
|
||||
customTools?: ToolWithProvider[]
|
||||
workflowTools?: ToolWithProvider[]
|
||||
mcpTools?: ToolWithProvider[]
|
||||
}) => {
|
||||
const updates: Partial<typeof state> = {}
|
||||
|
||||
if (buildInTools !== undefined && state.buildInTools !== buildInTools)
|
||||
updates.buildInTools = buildInTools
|
||||
if (customTools !== undefined && state.customTools !== customTools)
|
||||
updates.customTools = customTools
|
||||
if (workflowTools !== undefined && state.workflowTools !== workflowTools)
|
||||
updates.workflowTools = workflowTools
|
||||
if (mcpTools !== undefined && state.mcpTools !== mcpTools) updates.mcpTools = mcpTools
|
||||
|
||||
return updates
|
||||
}
|
||||
|
||||
const TabHeaderItem = ({
|
||||
function TabHeaderItem({
|
||||
tab,
|
||||
activeTab,
|
||||
onActiveTabChange,
|
||||
disabledTip,
|
||||
disabledTipLinkHref,
|
||||
disabledTipLinkLabel,
|
||||
fallbackDisabledTip,
|
||||
}: {
|
||||
tab: TabsProps['tabs'][number]
|
||||
activeTab: TabsEnum
|
||||
onActiveTabChange: (activeTab: TabsEnum) => void
|
||||
disabledTip: ReactNode
|
||||
disabledTipLinkHref?: string
|
||||
disabledTipLinkLabel?: string
|
||||
}) => {
|
||||
const className = cn(
|
||||
'relative mr-0.5 flex h-8 items-center rounded-t-lg px-3 system-sm-medium',
|
||||
tab.disabled
|
||||
? 'cursor-not-allowed text-text-disabled opacity-60'
|
||||
: activeTab === tab.key
|
||||
? 'sm-no-bottom cursor-default bg-components-panel-bg text-text-accent'
|
||||
: 'cursor-pointer text-text-tertiary',
|
||||
tab: TabConfig
|
||||
fallbackDisabledTip: ReactNode
|
||||
}) {
|
||||
const tabElement = (
|
||||
<TabsTab
|
||||
value={tab.key}
|
||||
disabled={tab.disabled}
|
||||
className={cn(
|
||||
'z-10 mr-0.5 h-8 rounded-t-lg border-b-0 px-3 py-0 system-sm-medium text-text-tertiary',
|
||||
'data-active:cursor-default data-active:border-transparent data-active:text-text-accent',
|
||||
'data-disabled:text-text-disabled data-disabled:opacity-60',
|
||||
)}
|
||||
>
|
||||
{tab.name}
|
||||
</TabsTab>
|
||||
)
|
||||
|
||||
const handleClick = () => {
|
||||
if (tab.disabled || activeTab === tab.key) return
|
||||
onActiveTabChange(tab.key)
|
||||
if (!tab.disabled) return tabElement
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={tabElement} />
|
||||
<TooltipContent placement="top" className="max-w-[230px] rounded-xl px-4 py-3.5">
|
||||
{tab.disabledTip || fallbackDisabledTip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectorContent({
|
||||
defaultTab,
|
||||
standalonePanel,
|
||||
tabs,
|
||||
searchInputRef,
|
||||
onSelect,
|
||||
onRequestClose,
|
||||
availableBlocksTypes,
|
||||
blocks,
|
||||
dataSources = [],
|
||||
allowStartNodeSelection = false,
|
||||
hasUserInputNode = false,
|
||||
hasTriggerNode = false,
|
||||
snippetInsertPayload,
|
||||
}: SelectorContentProps) {
|
||||
const { t } = useTranslation()
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const debouncedSearchText = useDebounce(searchText, { wait: 500 })
|
||||
const [tags, setTags] = useState<string[]>([])
|
||||
const fallbackDisabledTip = t(($) => $['tabs.startDisabledTip'], { ns: 'workflow' })
|
||||
|
||||
const renderSearchFilter = (tab: TabType, inputRef?: Ref<HTMLInputElement>) => {
|
||||
if (tab === TabType.Snippets) return null
|
||||
|
||||
const filter = (() => {
|
||||
if (tab === TabType.Start) {
|
||||
return (
|
||||
<SearchBox
|
||||
ref={inputRef}
|
||||
search={searchText}
|
||||
onSearchChange={setSearchText}
|
||||
tags={tags}
|
||||
onTagsChange={setTags}
|
||||
placeholder={t(($) => $['tabs.searchTrigger'], { ns: 'workflow' })}
|
||||
inputClassName="grow"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (tab === TabType.Tools) {
|
||||
return (
|
||||
<SearchBox
|
||||
ref={inputRef}
|
||||
search={searchText}
|
||||
onSearchChange={setSearchText}
|
||||
tags={tags}
|
||||
onTagsChange={setTags}
|
||||
placeholder={t(($) => $.searchTools, { ns: 'plugin' })!}
|
||||
inputClassName="grow"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SearchInput
|
||||
ref={inputRef}
|
||||
value={searchText}
|
||||
placeholder={
|
||||
tab === TabType.Blocks
|
||||
? t(($) => $['tabs.searchBlock'], { ns: 'workflow' })
|
||||
: t(($) => $['tabs.searchDataSource'], { ns: 'workflow' })
|
||||
}
|
||||
aria-label={
|
||||
tab === TabType.Blocks
|
||||
? t(($) => $['tabs.searchBlock'], { ns: 'workflow' })
|
||||
: t(($) => $['tabs.searchDataSource'], { ns: 'workflow' })
|
||||
}
|
||||
onValueChange={setSearchText}
|
||||
/>
|
||||
)
|
||||
})()
|
||||
|
||||
return <div className="relative m-2">{filter}</div>
|
||||
}
|
||||
|
||||
if (tab.disabled) {
|
||||
return (
|
||||
<Tooltip key={tab.key}>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
aria-disabled={tab.disabled}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{tab.name}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent placement="top" className="max-w-[230px] rounded-xl px-4 py-3.5">
|
||||
<div className="flex flex-col items-start gap-1 system-xs-regular text-text-secondary">
|
||||
<p>{disabledTip}</p>
|
||||
{disabledTipLinkHref && disabledTipLinkLabel && (
|
||||
<a
|
||||
className="text-text-accent hover:underline"
|
||||
href={disabledTipLinkHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{disabledTipLinkLabel}
|
||||
</a>
|
||||
)}
|
||||
const renderPanel = (tab: TabType, inputRef?: Ref<HTMLInputElement>) => {
|
||||
const searchFilter = renderSearchFilter(tab, inputRef)
|
||||
|
||||
if (tab === TabType.Start) {
|
||||
return (
|
||||
<>
|
||||
{searchFilter}
|
||||
<div className="border-t border-divider-subtle">
|
||||
<AllStartBlocks
|
||||
allowUserInputSelection={allowStartNodeSelection}
|
||||
hasUserInputNode={hasUserInputNode}
|
||||
hasTriggerNode={hasTriggerNode}
|
||||
searchText={debouncedSearchText}
|
||||
onSelect={onSelect}
|
||||
availableBlocksTypes={availableBlocksTypes}
|
||||
tags={tags}
|
||||
/>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (tab === TabType.Blocks) {
|
||||
return (
|
||||
<>
|
||||
{searchFilter}
|
||||
<div className="border-t border-divider-subtle">
|
||||
<Blocks
|
||||
searchText={searchText}
|
||||
onSelect={onSelect}
|
||||
availableBlocksTypes={availableBlocksTypes}
|
||||
blocks={blocks}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (tab === TabType.Sources) {
|
||||
return (
|
||||
<>
|
||||
{searchFilter}
|
||||
<div className="border-t border-divider-subtle">
|
||||
<DataSources searchText={searchText} onSelect={onSelect} dataSources={dataSources} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (tab === TabType.Tools) {
|
||||
return (
|
||||
<>
|
||||
{searchFilter}
|
||||
<ToolPanel
|
||||
searchText={debouncedSearchText}
|
||||
onSelect={onSelect}
|
||||
tags={tags}
|
||||
onTagsChange={setTags}
|
||||
dataSources={dataSources}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Snippets
|
||||
searchText={searchText}
|
||||
onSearchTextChange={setSearchText}
|
||||
insertPayload={snippetInsertPayload}
|
||||
onInserted={onRequestClose}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (standalonePanel) {
|
||||
return (
|
||||
<div className="w-full min-w-0">
|
||||
{renderPanel(
|
||||
standalonePanel,
|
||||
standalonePanel === TabType.Snippets ? undefined : searchInputRef,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={tab.key} className={className} aria-disabled={tab.disabled} onClick={handleClick}>
|
||||
{tab.name}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Tabs: FC<TabsProps> = ({
|
||||
activeTab,
|
||||
onActiveTabChange,
|
||||
tags,
|
||||
onTagsChange,
|
||||
searchText,
|
||||
onSelect,
|
||||
availableBlocksTypes,
|
||||
blocks,
|
||||
dataSources = [],
|
||||
tabs = [],
|
||||
filterElem,
|
||||
noBlocks,
|
||||
noTools,
|
||||
forceShowStartContent = false,
|
||||
allowStartNodeSelection = false,
|
||||
hasUserInputNode = false,
|
||||
hasTriggerNode = false,
|
||||
snippetsElem,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const docLink = useDocLink()
|
||||
const { data: buildInTools } = useAllBuiltInTools()
|
||||
const { data: customTools } = useAllCustomTools()
|
||||
const { data: workflowTools } = useAllWorkflowTools()
|
||||
const { data: mcpTools } = useAllMCPTools()
|
||||
const invalidateBuiltInTools = useInvalidateAllBuiltInTools()
|
||||
const { data: enable_marketplace } = useSuspenseQuery({
|
||||
...systemFeaturesQueryOptions(),
|
||||
select: (s) => s.enable_marketplace,
|
||||
})
|
||||
const workflowStore = useWorkflowStore()
|
||||
const inRAGPipeline = dataSources.length > 0
|
||||
const { plugins: featuredPlugins = [], isLoading: isFeaturedLoading } =
|
||||
useFeaturedToolsRecommendations(enable_marketplace && !inRAGPipeline)
|
||||
const normalizedBuiltInTools = useMemo(
|
||||
() => normalizeToolList(buildInTools, basePath),
|
||||
[buildInTools],
|
||||
)
|
||||
const normalizedCustomTools = useMemo(
|
||||
() => normalizeToolList(customTools, basePath),
|
||||
[customTools],
|
||||
)
|
||||
const normalizedWorkflowTools = useMemo(
|
||||
() => normalizeToolList(workflowTools, basePath),
|
||||
[workflowTools],
|
||||
)
|
||||
const normalizedMcpTools = useMemo(() => normalizeToolList(mcpTools, basePath), [mcpTools])
|
||||
const disabledTip = t(($) => $['tabs.startDisabledTip'], { ns: 'workflow' })
|
||||
|
||||
useEffect(() => {
|
||||
workflowStore.setState((state) => {
|
||||
const updates = getStoreToolUpdates({
|
||||
state,
|
||||
buildInTools: normalizedBuiltInTools,
|
||||
customTools: normalizedCustomTools,
|
||||
workflowTools: normalizedWorkflowTools,
|
||||
mcpTools: normalizedMcpTools,
|
||||
})
|
||||
if (!Object.keys(updates).length) return state
|
||||
return {
|
||||
...state,
|
||||
...updates,
|
||||
}
|
||||
})
|
||||
}, [
|
||||
normalizedBuiltInTools,
|
||||
normalizedCustomTools,
|
||||
normalizedMcpTools,
|
||||
normalizedWorkflowTools,
|
||||
workflowStore,
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="w-full min-w-0" onClick={(e) => e.stopPropagation()}>
|
||||
{!noBlocks && (
|
||||
<div className="relative flex w-full min-w-0 bg-background-section-burn pt-1 pl-1">
|
||||
{tabs.map((tab) => (
|
||||
<TabHeaderItem
|
||||
key={tab.key}
|
||||
tab={tab}
|
||||
activeTab={activeTab}
|
||||
onActiveTabChange={onActiveTabChange}
|
||||
disabledTip={tab.disabledTip || disabledTip}
|
||||
disabledTipLinkHref={
|
||||
tab.disabledTipLinkKey === 'startNodesDocs'
|
||||
? docLink('/use-dify/nodes/trigger/overview')
|
||||
: undefined
|
||||
}
|
||||
disabledTipLinkLabel={
|
||||
tab.disabledTipLinkKey === 'startNodesDocs'
|
||||
? t(($) => $['tabs.startDisabledTipLearnMore'], { ns: 'workflow' })
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{filterElem}
|
||||
{activeTab === TabsEnum.Start && (!noBlocks || forceShowStartContent) && (
|
||||
<div className="border-t border-divider-subtle">
|
||||
<AllStartBlocks
|
||||
allowUserInputSelection={allowStartNodeSelection}
|
||||
hasUserInputNode={hasUserInputNode}
|
||||
hasTriggerNode={hasTriggerNode}
|
||||
searchText={searchText}
|
||||
onSelect={onSelect}
|
||||
availableBlocksTypes={availableBlocksTypes}
|
||||
tags={tags}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === TabsEnum.Blocks && !noBlocks && (
|
||||
<div className="border-t border-divider-subtle">
|
||||
<Blocks
|
||||
searchText={searchText}
|
||||
onSelect={onSelect}
|
||||
availableBlocksTypes={availableBlocksTypes}
|
||||
blocks={blocks}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === TabsEnum.Sources && !!dataSources.length && (
|
||||
<div className="border-t border-divider-subtle">
|
||||
<DataSources searchText={searchText} onSelect={onSelect} dataSources={dataSources} />
|
||||
</div>
|
||||
)}
|
||||
{activeTab === TabsEnum.Tools && !noTools && (
|
||||
<AllTools
|
||||
searchText={searchText}
|
||||
onSelect={onSelect}
|
||||
tags={tags}
|
||||
canNotSelectMultiple
|
||||
buildInTools={normalizedBuiltInTools || []}
|
||||
customTools={normalizedCustomTools || []}
|
||||
workflowTools={normalizedWorkflowTools || []}
|
||||
mcpTools={normalizedMcpTools || []}
|
||||
onTagsChange={onTagsChange}
|
||||
isInRAGPipeline={inRAGPipeline}
|
||||
featuredPlugins={featuredPlugins}
|
||||
featuredLoading={isFeaturedLoading}
|
||||
showFeatured={enable_marketplace && !inRAGPipeline}
|
||||
onFeaturedInstallSuccess={async () => {
|
||||
invalidateBuiltInTools()
|
||||
<Tabs defaultValue={defaultTab} className="w-full min-w-0">
|
||||
<TabsList className="relative w-full min-w-0 gap-0 bg-background-section-burn pt-1 pl-1">
|
||||
{tabs.map((tab) => (
|
||||
<TabHeaderItem key={tab.key} tab={tab} fallbackDisabledTip={fallbackDisabledTip} />
|
||||
))}
|
||||
<TabsIndicator
|
||||
className="sm-no-bottom pointer-events-none absolute left-0 rounded-t-lg bg-components-panel-bg transition-[translate,width] duration-150 ease-in-out motion-reduce:transition-none"
|
||||
style={{
|
||||
top: 'var(--active-tab-top)',
|
||||
translate: 'var(--active-tab-left)',
|
||||
width: 'var(--active-tab-width)',
|
||||
height: 'var(--active-tab-height)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{activeTab === TabsEnum.Snippets && Boolean(snippetsElem) ? (
|
||||
<div className="border-t border-divider-subtle">{snippetsElem}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</TabsList>
|
||||
{tabs.map((tab) => (
|
||||
<TabsPanel
|
||||
key={tab.key}
|
||||
value={tab.key}
|
||||
className="focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden focus-visible:ring-inset"
|
||||
>
|
||||
{renderPanel(
|
||||
tab.key,
|
||||
tab.key === defaultTab && tab.key !== TabType.Snippets ? searchInputRef : undefined,
|
||||
)}
|
||||
</TabsPanel>
|
||||
))}
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Tabs)
|
||||
export { SelectorContent }
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { OnSelectBlock, ToolWithProvider } from '../types'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { useFeaturedToolsRecommendations } from '@/service/use-plugins'
|
||||
import {
|
||||
useAllBuiltInTools,
|
||||
useAllCustomTools,
|
||||
useAllMCPTools,
|
||||
useAllWorkflowTools,
|
||||
useInvalidateAllBuiltInTools,
|
||||
} from '@/service/use-tools'
|
||||
import { basePath } from '@/utils/var'
|
||||
import { useWorkflowStore } from '../store'
|
||||
import AllTools from './all-tools'
|
||||
|
||||
function normalizeToolList(list: ToolWithProvider[] | undefined, currentBasePath?: string) {
|
||||
if (!list || !currentBasePath) return list
|
||||
|
||||
let changed = false
|
||||
const normalized = list.map((provider) => {
|
||||
if (typeof provider.icon !== 'string') return provider
|
||||
|
||||
const shouldPrefix =
|
||||
provider.icon.startsWith('/') && !provider.icon.startsWith(`${currentBasePath}/`)
|
||||
|
||||
if (!shouldPrefix) return provider
|
||||
|
||||
changed = true
|
||||
return {
|
||||
...provider,
|
||||
icon: `${currentBasePath}${provider.icon}`,
|
||||
}
|
||||
})
|
||||
|
||||
return changed ? normalized : list
|
||||
}
|
||||
|
||||
function getStoreToolUpdates({
|
||||
state,
|
||||
buildInTools,
|
||||
customTools,
|
||||
workflowTools,
|
||||
mcpTools,
|
||||
}: {
|
||||
state: {
|
||||
buildInTools?: ToolWithProvider[]
|
||||
customTools?: ToolWithProvider[]
|
||||
workflowTools?: ToolWithProvider[]
|
||||
mcpTools?: ToolWithProvider[]
|
||||
}
|
||||
buildInTools?: ToolWithProvider[]
|
||||
customTools?: ToolWithProvider[]
|
||||
workflowTools?: ToolWithProvider[]
|
||||
mcpTools?: ToolWithProvider[]
|
||||
}) {
|
||||
const updates: Partial<typeof state> = {}
|
||||
|
||||
if (buildInTools !== undefined && state.buildInTools !== buildInTools)
|
||||
updates.buildInTools = buildInTools
|
||||
if (customTools !== undefined && state.customTools !== customTools)
|
||||
updates.customTools = customTools
|
||||
if (workflowTools !== undefined && state.workflowTools !== workflowTools)
|
||||
updates.workflowTools = workflowTools
|
||||
if (mcpTools !== undefined && state.mcpTools !== mcpTools) updates.mcpTools = mcpTools
|
||||
|
||||
return updates
|
||||
}
|
||||
|
||||
export function ToolPanel({
|
||||
searchText,
|
||||
tags,
|
||||
onTagsChange,
|
||||
onSelect,
|
||||
dataSources,
|
||||
}: {
|
||||
searchText: string
|
||||
tags: string[]
|
||||
onTagsChange: (tags: string[]) => void
|
||||
onSelect: OnSelectBlock
|
||||
dataSources: ToolWithProvider[]
|
||||
}) {
|
||||
const { data: buildInTools } = useAllBuiltInTools()
|
||||
const { data: customTools } = useAllCustomTools()
|
||||
const { data: workflowTools } = useAllWorkflowTools()
|
||||
const { data: mcpTools } = useAllMCPTools()
|
||||
const invalidateBuiltInTools = useInvalidateAllBuiltInTools()
|
||||
const { data: enableMarketplace } = useSuspenseQuery({
|
||||
...systemFeaturesQueryOptions(),
|
||||
select: (systemFeatures) => systemFeatures.enable_marketplace,
|
||||
})
|
||||
const workflowStore = useWorkflowStore()
|
||||
const inRAGPipeline = dataSources.length > 0
|
||||
const { plugins: featuredPlugins = [], isLoading: isFeaturedLoading } =
|
||||
useFeaturedToolsRecommendations(enableMarketplace && !inRAGPipeline)
|
||||
const normalizedBuiltInTools = useMemo(
|
||||
() => normalizeToolList(buildInTools, basePath),
|
||||
[buildInTools],
|
||||
)
|
||||
const normalizedCustomTools = useMemo(
|
||||
() => normalizeToolList(customTools, basePath),
|
||||
[customTools],
|
||||
)
|
||||
const normalizedWorkflowTools = useMemo(
|
||||
() => normalizeToolList(workflowTools, basePath),
|
||||
[workflowTools],
|
||||
)
|
||||
const normalizedMcpTools = useMemo(() => normalizeToolList(mcpTools, basePath), [mcpTools])
|
||||
|
||||
useEffect(() => {
|
||||
workflowStore.setState((state) => {
|
||||
const updates = getStoreToolUpdates({
|
||||
state,
|
||||
buildInTools: normalizedBuiltInTools,
|
||||
customTools: normalizedCustomTools,
|
||||
workflowTools: normalizedWorkflowTools,
|
||||
mcpTools: normalizedMcpTools,
|
||||
})
|
||||
if (!Object.keys(updates).length) return state
|
||||
return {
|
||||
...state,
|
||||
...updates,
|
||||
}
|
||||
})
|
||||
}, [
|
||||
normalizedBuiltInTools,
|
||||
normalizedCustomTools,
|
||||
normalizedMcpTools,
|
||||
normalizedWorkflowTools,
|
||||
workflowStore,
|
||||
])
|
||||
|
||||
return (
|
||||
<AllTools
|
||||
searchText={searchText}
|
||||
onSelect={onSelect}
|
||||
tags={tags}
|
||||
canNotSelectMultiple
|
||||
buildInTools={normalizedBuiltInTools || []}
|
||||
customTools={normalizedCustomTools || []}
|
||||
workflowTools={normalizedWorkflowTools || []}
|
||||
mcpTools={normalizedMcpTools || []}
|
||||
onTagsChange={onTagsChange}
|
||||
isInRAGPipeline={inRAGPipeline}
|
||||
featuredPlugins={featuredPlugins}
|
||||
featuredLoading={isFeaturedLoading}
|
||||
showFeatured={enableMarketplace && !inRAGPipeline}
|
||||
onFeaturedInstallSuccess={invalidateBuiltInTools}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -45,7 +45,10 @@ describe('ToolActionItem', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Search Tool' }))
|
||||
const toolButton = screen.getByRole('button', { name: 'Search Tool' })
|
||||
expect(toolButton).toHaveAccessibleDescription('Search Tool description')
|
||||
|
||||
await user.click(toolButton)
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(
|
||||
BlockEnum.Tool,
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ToolWithProvider } from '../../types'
|
||||
import type { ToolDefaultValue } from '../types'
|
||||
import type { Tool } from '@/app/components/tools/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { PreviewCardContent, PreviewCardTrigger } from '@langgenius/dify-ui/preview-card'
|
||||
import { PreviewCardTrigger } from '@langgenius/dify-ui/preview-card'
|
||||
import * as React from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -15,6 +15,7 @@ import { Theme } from '@/types/app'
|
||||
import { basePath } from '@/utils/var'
|
||||
import BlockIcon from '../../block-icon'
|
||||
import { BlockEnum } from '../../types'
|
||||
import { BlockSelectorPreviewCardContent } from '../preview-card'
|
||||
|
||||
const normalizeProviderIcon = (icon?: ToolWithProvider['icon']) => {
|
||||
if (!icon) return icon
|
||||
@@ -57,6 +58,8 @@ const ToolItem: FC<Props> = ({
|
||||
const { t } = useTranslation()
|
||||
|
||||
const language = useGetLanguage()
|
||||
const previewDescriptionId = React.useId()
|
||||
const previewDescription = payload.description[language]
|
||||
const { theme } = useTheme()
|
||||
const normalizedIcon = useMemo<ToolWithProvider['icon']>(() => {
|
||||
return normalizeProviderIcon(provider.icon) ?? provider.icon
|
||||
@@ -72,8 +75,8 @@ const ToolItem: FC<Props> = ({
|
||||
|
||||
const row = (
|
||||
<button
|
||||
key={payload.name}
|
||||
type="button"
|
||||
aria-describedby={previewDescription ? previewDescriptionId : undefined}
|
||||
disabled={disabled}
|
||||
className="flex w-full cursor-pointer items-center justify-between rounded-lg border-none bg-transparent pr-1 pl-[21px] text-left hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden disabled:cursor-default"
|
||||
onClick={() => {
|
||||
@@ -108,11 +111,7 @@ const ToolItem: FC<Props> = ({
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'truncate border-l-2 border-divider-subtle py-2 pl-4 system-sm-medium text-text-secondary',
|
||||
)}
|
||||
>
|
||||
<div className="truncate border-l-2 border-divider-subtle py-2 pl-4 system-sm-medium text-text-secondary">
|
||||
<span className={cn(disabled && 'opacity-30')}>{payload.label[language]}</span>
|
||||
</div>
|
||||
{isAdded && (
|
||||
@@ -124,22 +123,24 @@ const ToolItem: FC<Props> = ({
|
||||
)
|
||||
|
||||
return (
|
||||
// Preview is supplementary: provider icon, tool label and description are all
|
||||
// reachable from the node inspector after the row is clicked to add the tool,
|
||||
// so hover/focus-only activation is a11y-safe. See
|
||||
// packages/dify-ui/AGENTS.md → Overlay Primitive Selection.
|
||||
<PreviewCardTrigger
|
||||
key={payload.name}
|
||||
delay={150}
|
||||
closeDelay={150}
|
||||
handle={previewCardHandle}
|
||||
payload={{
|
||||
providerIcon,
|
||||
payload,
|
||||
language,
|
||||
}}
|
||||
render={row}
|
||||
/>
|
||||
<>
|
||||
<PreviewCardTrigger
|
||||
delay={150}
|
||||
closeDelay={150}
|
||||
handle={previewCardHandle}
|
||||
payload={{
|
||||
providerIcon,
|
||||
payload,
|
||||
language,
|
||||
}}
|
||||
render={row}
|
||||
/>
|
||||
{previewDescription && (
|
||||
<span id={previewDescriptionId} className="sr-only">
|
||||
{previewDescription}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -151,22 +152,15 @@ export function ToolActionPreviewCard({ payload }: ToolActionPreviewCardProps) {
|
||||
if (!payload) return null
|
||||
|
||||
return (
|
||||
<PreviewCardContent placement="right" popupClassName="w-[200px] px-3 py-2.5">
|
||||
<div>
|
||||
<BlockIcon
|
||||
size="md"
|
||||
className="mb-2"
|
||||
type={BlockEnum.Tool}
|
||||
toolIcon={payload.providerIcon}
|
||||
/>
|
||||
<div className="mb-1 text-sm/5 text-text-primary">
|
||||
{payload.payload.label[payload.language]}
|
||||
</div>
|
||||
<div className="text-xs leading-[18px] wrap-break-word text-text-secondary">
|
||||
{payload.payload.description[payload.language]}
|
||||
</div>
|
||||
<BlockSelectorPreviewCardContent>
|
||||
<BlockIcon size="md" className="mb-2" type={BlockEnum.Tool} toolIcon={payload.providerIcon} />
|
||||
<div className="mb-1 text-sm/5 wrap-break-word text-text-primary">
|
||||
{payload.payload.label[payload.language]}
|
||||
</div>
|
||||
</PreviewCardContent>
|
||||
<div className="text-xs leading-[18px] wrap-break-word text-text-secondary">
|
||||
{payload.payload.description[payload.language]}
|
||||
</div>
|
||||
</BlockSelectorPreviewCardContent>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { BlockEnum, ToolWithProvider } from '../types'
|
||||
import type { ToolActionPreviewPayload } from './tool/action-item'
|
||||
import type { ToolDefaultValue, ToolTypeEnum, ToolValue } from './types'
|
||||
import type { ToolDefaultValue, ToolType, ToolValue } from './types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { createPreviewCardHandle, PreviewCard } from '@langgenius/dify-ui/preview-card'
|
||||
import { memo, useMemo, useRef } from 'react'
|
||||
@@ -19,7 +19,7 @@ type ToolsProps = {
|
||||
tools: ToolWithProvider[]
|
||||
viewType: ViewType
|
||||
hasSearchText: boolean
|
||||
toolType?: ToolTypeEnum
|
||||
toolType?: ToolType
|
||||
isAgent?: boolean
|
||||
className?: string
|
||||
indexBarClassName?: string
|
||||
|
||||
+4
-1
@@ -111,7 +111,10 @@ describe('trigger plugin selector components', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('On Created'))
|
||||
const triggerButton = screen.getByRole('button', { name: 'On Created' })
|
||||
expect(triggerButton).toHaveAccessibleDescription('On Created description')
|
||||
|
||||
await user.click(triggerButton)
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(
|
||||
BlockEnum.TriggerPlugin,
|
||||
|
||||
@@ -3,12 +3,13 @@ import type { ComponentProps, FC } from 'react'
|
||||
import type { TriggerDefaultValue, TriggerWithProvider } from '../types'
|
||||
import type { Event } from '@/app/components/tools/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { PreviewCardContent, PreviewCardTrigger } from '@langgenius/dify-ui/preview-card'
|
||||
import { PreviewCardTrigger } from '@langgenius/dify-ui/preview-card'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import BlockIcon from '../../block-icon'
|
||||
import { BlockEnum } from '../../types'
|
||||
import { BlockSelectorPreviewCardContent } from '../preview-card'
|
||||
|
||||
type Props = Readonly<{
|
||||
provider: TriggerWithProvider
|
||||
@@ -38,12 +39,14 @@ const TriggerPluginActionItem: FC<Props> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const language = useGetLanguage()
|
||||
const previewDescriptionId = React.useId()
|
||||
const previewDescription = payload.description[language]
|
||||
|
||||
const row = (
|
||||
<button
|
||||
type="button"
|
||||
aria-describedby={previewDescription ? previewDescriptionId : undefined}
|
||||
disabled={disabled}
|
||||
key={payload.name}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between rounded-lg border-0 bg-transparent pr-1 pl-[21px] text-left focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:outline-hidden',
|
||||
disabled ? 'cursor-default' : 'cursor-pointer hover:bg-state-base-hover',
|
||||
@@ -74,11 +77,7 @@ const TriggerPluginActionItem: FC<Props> = ({
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'truncate border-l-2 border-divider-subtle py-2 pl-4 system-sm-medium text-text-secondary',
|
||||
)}
|
||||
>
|
||||
<div className="truncate border-l-2 border-divider-subtle py-2 pl-4 system-sm-medium text-text-secondary">
|
||||
<span className={cn(disabled && 'opacity-30')}>{payload.label[language]}</span>
|
||||
</div>
|
||||
{isAdded && (
|
||||
@@ -90,18 +89,20 @@ const TriggerPluginActionItem: FC<Props> = ({
|
||||
)
|
||||
|
||||
return (
|
||||
// Preview is supplementary: provider icon, event label and description are all
|
||||
// reachable from the node inspector after the row is clicked to add the trigger,
|
||||
// so hover/focus-only activation is a11y-safe. See
|
||||
// packages/dify-ui/AGENTS.md → Overlay Primitive Selection.
|
||||
<PreviewCardTrigger
|
||||
key={payload.name}
|
||||
delay={150}
|
||||
closeDelay={150}
|
||||
handle={previewCardHandle}
|
||||
payload={{ provider, payload, language }}
|
||||
render={row}
|
||||
/>
|
||||
<>
|
||||
<PreviewCardTrigger
|
||||
delay={150}
|
||||
closeDelay={150}
|
||||
handle={previewCardHandle}
|
||||
payload={{ provider, payload, language }}
|
||||
render={row}
|
||||
/>
|
||||
{previewDescription && (
|
||||
<span id={previewDescriptionId} className="sr-only">
|
||||
{previewDescription}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -113,22 +114,20 @@ export function TriggerPluginActionPreviewCard({ payload }: TriggerPluginActionP
|
||||
if (!payload) return null
|
||||
|
||||
return (
|
||||
<PreviewCardContent placement="right" popupClassName="w-[224px] px-3 py-2.5">
|
||||
<div>
|
||||
<BlockIcon
|
||||
size="md"
|
||||
className="mb-2"
|
||||
type={BlockEnum.TriggerPlugin}
|
||||
toolIcon={payload.provider.icon}
|
||||
/>
|
||||
<div className="mb-1 text-sm/5 text-text-primary">
|
||||
{payload.payload.label[payload.language]}
|
||||
</div>
|
||||
<div className="text-xs leading-[18px] wrap-break-word text-text-secondary">
|
||||
{payload.payload.description[payload.language]}
|
||||
</div>
|
||||
<BlockSelectorPreviewCardContent>
|
||||
<BlockIcon
|
||||
size="md"
|
||||
className="mb-2"
|
||||
type={BlockEnum.TriggerPlugin}
|
||||
toolIcon={payload.provider.icon}
|
||||
/>
|
||||
<div className="mb-1 text-sm/5 text-text-primary">
|
||||
{payload.payload.label[payload.language]}
|
||||
</div>
|
||||
</PreviewCardContent>
|
||||
<div className="text-xs leading-[18px] wrap-break-word text-text-secondary">
|
||||
{payload.payload.description[payload.language]}
|
||||
</div>
|
||||
</BlockSelectorPreviewCardContent>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,29 +9,35 @@ import type {
|
||||
import type { Collection, Event } from '../../tools/types'
|
||||
import type { TypeWithI18N } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
|
||||
export enum TabsEnum {
|
||||
Start = 'start',
|
||||
Blocks = 'blocks',
|
||||
Tools = 'tools',
|
||||
Sources = 'sources',
|
||||
Snippets = 'snippets',
|
||||
}
|
||||
export const TabType = {
|
||||
Start: 'start',
|
||||
Blocks: 'blocks',
|
||||
Tools: 'tools',
|
||||
Sources: 'sources',
|
||||
Snippets: 'snippets',
|
||||
} as const
|
||||
|
||||
export enum ToolTypeEnum {
|
||||
All = 'all',
|
||||
BuiltIn = 'built-in',
|
||||
Custom = 'custom',
|
||||
Workflow = 'workflow',
|
||||
MCP = 'mcp',
|
||||
}
|
||||
export type TabType = (typeof TabType)[keyof typeof TabType]
|
||||
|
||||
export enum BlockClassificationEnum {
|
||||
Default = '-',
|
||||
QuestionUnderstand = 'question-understand',
|
||||
Logic = 'logic',
|
||||
Transform = 'transform',
|
||||
Utilities = 'utilities',
|
||||
}
|
||||
export const ToolType = {
|
||||
All: 'all',
|
||||
BuiltIn: 'built-in',
|
||||
Custom: 'custom',
|
||||
Workflow: 'workflow',
|
||||
MCP: 'mcp',
|
||||
} as const
|
||||
|
||||
export type ToolType = (typeof ToolType)[keyof typeof ToolType]
|
||||
|
||||
export const BlockClassification = {
|
||||
Default: '-',
|
||||
QuestionUnderstand: 'question-understand',
|
||||
Logic: 'logic',
|
||||
Transform: 'transform',
|
||||
Utilities: 'utilities',
|
||||
} as const
|
||||
|
||||
export type BlockClassification = (typeof BlockClassification)[keyof typeof BlockClassification]
|
||||
|
||||
type PluginCommonDefaultValue = {
|
||||
provider_id: string
|
||||
@@ -215,17 +221,20 @@ export type TriggerWithProvider = Collection & {
|
||||
|
||||
// Trigger subscription instance types
|
||||
|
||||
export enum TriggerCredentialTypeEnum {
|
||||
ApiKey = 'api-key',
|
||||
Oauth2 = 'oauth2',
|
||||
Unauthorized = 'unauthorized',
|
||||
}
|
||||
export const TriggerCredentialType = {
|
||||
ApiKey: 'api-key',
|
||||
Oauth2: 'oauth2',
|
||||
Unauthorized: 'unauthorized',
|
||||
} as const
|
||||
|
||||
export type TriggerCredentialType =
|
||||
(typeof TriggerCredentialType)[keyof typeof TriggerCredentialType]
|
||||
|
||||
type TriggerSubscriptionStructure = {
|
||||
id: string
|
||||
name: string
|
||||
provider: string
|
||||
credential_type: TriggerCredentialTypeEnum
|
||||
credential_type: TriggerCredentialType
|
||||
credentials: Record<string, unknown>
|
||||
endpoint: string
|
||||
parameters: Record<string, unknown>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NodeDefault } from '../../types'
|
||||
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
|
||||
import { BlockClassificationEnum } from '../../block-selector/types'
|
||||
import { BlockClassification } from '../../block-selector/types'
|
||||
import { BlockEnum } from '../../types'
|
||||
import { useAvailableBlocks } from '../use-available-blocks'
|
||||
|
||||
@@ -29,7 +29,7 @@ const mockNodeTypes = [
|
||||
function createNodeDefault(type: BlockEnum): NodeDefault {
|
||||
return {
|
||||
metaData: {
|
||||
classification: BlockClassificationEnum.Default,
|
||||
classification: BlockClassification.Default,
|
||||
sort: 0,
|
||||
type,
|
||||
title: type,
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
renderWorkflowFlowHook,
|
||||
renderWorkflowHook,
|
||||
} from '../../__tests__/workflow-test-env'
|
||||
import { BlockClassificationEnum } from '../../block-selector/types'
|
||||
import { BlockClassification } from '../../block-selector/types'
|
||||
import { BlockEnum, WorkflowRunningStatus } from '../../types'
|
||||
import {
|
||||
useIsChatMode,
|
||||
@@ -32,7 +32,7 @@ beforeEach(() => {
|
||||
function createNodeDefault(type: BlockEnum): NodeDefault {
|
||||
return {
|
||||
metaData: {
|
||||
classification: BlockClassificationEnum.Default,
|
||||
classification: BlockClassification.Default,
|
||||
sort: 0,
|
||||
type,
|
||||
title: type,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { cloneElement, memo, useMemo, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
|
||||
import BlockIcon from '@/app/components/workflow/block-icon'
|
||||
import { ToolTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { ToolType } from '@/app/components/workflow/block-selector/types'
|
||||
import { useCollaboration } from '@/app/components/workflow/collaboration/hooks/use-collaboration'
|
||||
import { useNodesReadOnly, useToolIcon } from '@/app/components/workflow/hooks'
|
||||
import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud'
|
||||
@@ -271,7 +271,7 @@ const BaseNode: FC<BaseNodeProps> = ({ id, data, children }) => {
|
||||
{hasRetryNode(data.type) && <RetryOnNode id={id} data={data} />}
|
||||
{hasErrorHandleNode(data.type) && <ErrorHandleOnNode id={id} data={data} />}
|
||||
<NodeDescription data={data} />
|
||||
{data.type === BlockEnum.Tool && data.provider_type === ToolTypeEnum.MCP && (
|
||||
{data.type === BlockEnum.Tool && data.provider_type === ToolType.MCP && (
|
||||
<div className="px-3 pb-2">
|
||||
<CopyID content={data.provider_id || ''} />
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { NodeDefault } from '../../types'
|
||||
import type { AssignerNodeType } from './types'
|
||||
import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { BlockClassification } from '@/app/components/workflow/block-selector/types'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { genNodeMetaData } from '@/app/components/workflow/utils'
|
||||
import { WriteMode } from './types'
|
||||
@@ -9,7 +9,7 @@ import { WriteMode } from './types'
|
||||
const i18nPrefix = 'errorMsg'
|
||||
|
||||
const metaData = genNodeMetaData({
|
||||
classification: BlockClassificationEnum.Transform,
|
||||
classification: BlockClassification.Transform,
|
||||
sort: 5,
|
||||
type: BlockEnum.Assigner,
|
||||
helpLinkUri: 'variable-assigner',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { NodeDefault } from '../../types'
|
||||
import type { CodeNodeType } from './types'
|
||||
import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { BlockClassification } from '@/app/components/workflow/block-selector/types'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { genNodeMetaData } from '@/app/components/workflow/utils'
|
||||
import { CodeLanguage } from './types'
|
||||
@@ -9,7 +9,7 @@ import { CodeLanguage } from './types'
|
||||
const i18nPrefix = 'errorMsg'
|
||||
|
||||
const metaData = genNodeMetaData({
|
||||
classification: BlockClassificationEnum.Transform,
|
||||
classification: BlockClassification.Transform,
|
||||
sort: 1,
|
||||
type: BlockEnum.Code,
|
||||
})
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { NodeDefault } from '../../types'
|
||||
import type { DocExtractorNodeType } from './types'
|
||||
import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { BlockClassification } from '@/app/components/workflow/block-selector/types'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { genNodeMetaData } from '@/app/components/workflow/utils'
|
||||
|
||||
const i18nPrefix = 'errorMsg'
|
||||
|
||||
const metaData = genNodeMetaData({
|
||||
classification: BlockClassificationEnum.Transform,
|
||||
classification: BlockClassification.Transform,
|
||||
sort: 4,
|
||||
type: BlockEnum.DocExtractor,
|
||||
helpLinkUri: 'doc-extractor',
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { NodeDefault } from '../../types'
|
||||
import type { BodyPayload, HttpNodeType } from './types'
|
||||
import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { BlockClassification } from '@/app/components/workflow/block-selector/types'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { genNodeMetaData } from '@/app/components/workflow/utils'
|
||||
import { AuthorizationType, BodyType, Method } from './types'
|
||||
|
||||
const metaData = genNodeMetaData({
|
||||
classification: BlockClassificationEnum.Utilities,
|
||||
classification: BlockClassification.Utilities,
|
||||
sort: 1,
|
||||
type: BlockEnum.HttpRequest,
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { NodeDefault, Var } from '../../types'
|
||||
import type { DeliveryMethod, EmailConfig, FormInputItem, HumanInputNodeType } from './types'
|
||||
import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { BlockClassification } from '@/app/components/workflow/block-selector/types'
|
||||
import { BlockEnum, VarType } from '@/app/components/workflow/types'
|
||||
import { genNodeMetaData } from '@/app/components/workflow/utils'
|
||||
import { DeliveryMethodType } from './types'
|
||||
@@ -9,7 +9,7 @@ import { DeliveryMethodType } from './types'
|
||||
const i18nPrefix = 'nodes.humanInput.errorMsg'
|
||||
|
||||
const metaData = genNodeMetaData({
|
||||
classification: BlockClassificationEnum.Logic,
|
||||
classification: BlockClassification.Logic,
|
||||
sort: 1,
|
||||
type: BlockEnum.HumanInput,
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { NodeDefault } from '../../types'
|
||||
import type { IfElseNodeType } from './types'
|
||||
import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { BlockClassification } from '@/app/components/workflow/block-selector/types'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { genNodeMetaData } from '@/app/components/workflow/utils'
|
||||
import { VarType } from '../../types'
|
||||
@@ -11,7 +11,7 @@ import { isEmptyRelatedOperator } from './utils'
|
||||
const i18nPrefix = 'errorMsg'
|
||||
|
||||
const metaData = genNodeMetaData({
|
||||
classification: BlockClassificationEnum.Logic,
|
||||
classification: BlockClassification.Logic,
|
||||
sort: 1,
|
||||
type: BlockEnum.IfElse,
|
||||
helpLinkUri: 'ifelse',
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { NodeDefault } from '../../types'
|
||||
import type { IterationNodeType } from './types'
|
||||
import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { BlockClassification } from '@/app/components/workflow/block-selector/types'
|
||||
import { genNodeMetaData } from '@/app/components/workflow/utils'
|
||||
import { BlockEnum, ErrorHandleMode } from '../../types'
|
||||
|
||||
const i18nPrefix = ''
|
||||
|
||||
const metaData = genNodeMetaData({
|
||||
classification: BlockClassificationEnum.Logic,
|
||||
classification: BlockClassification.Logic,
|
||||
sort: 2,
|
||||
type: BlockEnum.Iteration,
|
||||
isTypeFixed: true,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { NodeDefault } from '../../types'
|
||||
import type { ListFilterNodeType } from './types'
|
||||
import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { BlockClassification } from '@/app/components/workflow/block-selector/types'
|
||||
import { genNodeMetaData } from '@/app/components/workflow/utils'
|
||||
import { BlockEnum, VarType } from '../../types'
|
||||
import { comparisonOperatorNotRequireValue } from '../if-else/utils'
|
||||
@@ -10,7 +10,7 @@ import { OrderBy } from './types'
|
||||
const i18nPrefix = 'errorMsg'
|
||||
|
||||
const metaData = genNodeMetaData({
|
||||
classification: BlockClassificationEnum.Utilities,
|
||||
classification: BlockClassification.Utilities,
|
||||
sort: 2,
|
||||
type: BlockEnum.ListFilter,
|
||||
})
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { NodeDefault } from '../../types'
|
||||
import type { SimpleNodeType } from '@/app/components/workflow/simple-node/types'
|
||||
import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { BlockClassification } from '@/app/components/workflow/block-selector/types'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { genNodeMetaData } from '@/app/components/workflow/utils'
|
||||
|
||||
const metaData = genNodeMetaData({
|
||||
classification: BlockClassificationEnum.Logic,
|
||||
classification: BlockClassification.Logic,
|
||||
sort: 2,
|
||||
type: BlockEnum.LoopEnd,
|
||||
isSingleton: true,
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { TFunction } from 'i18next'
|
||||
import type { NodeDefault } from '../../types'
|
||||
import type { LoopNodeType } from './types'
|
||||
import type { I18nKeysByPrefix } from '@/types/i18n'
|
||||
import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { BlockClassification } from '@/app/components/workflow/block-selector/types'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { genNodeMetaData } from '@/app/components/workflow/utils'
|
||||
import { LOOP_NODE_MAX_COUNT } from '@/config'
|
||||
@@ -14,7 +14,7 @@ import { isEmptyRelatedOperator } from './utils'
|
||||
const i18nPrefix = 'errorMsg'
|
||||
|
||||
const metaData = genNodeMetaData({
|
||||
classification: BlockClassificationEnum.Logic,
|
||||
classification: BlockClassification.Logic,
|
||||
sort: 3,
|
||||
type: BlockEnum.Loop,
|
||||
author: 'AICT-Team',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { NodeDefault } from '../../types'
|
||||
import type { ParameterExtractorNodeType } from './types'
|
||||
import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { BlockClassification } from '@/app/components/workflow/block-selector/types'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { genNodeMetaData } from '@/app/components/workflow/utils'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
@@ -10,7 +10,7 @@ import { ReasoningModeType } from './types'
|
||||
const i18nPrefix = ''
|
||||
|
||||
const metaData = genNodeMetaData({
|
||||
classification: BlockClassificationEnum.Transform,
|
||||
classification: BlockClassification.Transform,
|
||||
sort: 6,
|
||||
type: BlockEnum.ParameterExtractor,
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { NodeDefault } from '../../types'
|
||||
import type { QuestionClassifierNodeType } from './types'
|
||||
import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { BlockClassification } from '@/app/components/workflow/block-selector/types'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { genNodeMetaData } from '@/app/components/workflow/utils'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
@@ -9,7 +9,7 @@ import { AppModeEnum } from '@/types/app'
|
||||
const i18nPrefix = ''
|
||||
|
||||
const metaData = genNodeMetaData({
|
||||
classification: BlockClassificationEnum.QuestionUnderstand,
|
||||
classification: BlockClassification.QuestionUnderstand,
|
||||
sort: 1,
|
||||
type: BlockEnum.QuestionClassifier,
|
||||
})
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { NodeDefault } from '../../types'
|
||||
import type { TemplateTransformNodeType } from './types'
|
||||
import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { BlockClassification } from '@/app/components/workflow/block-selector/types'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { genNodeMetaData } from '@/app/components/workflow/utils'
|
||||
|
||||
const i18nPrefix = 'errorMsg'
|
||||
|
||||
const metaData = genNodeMetaData({
|
||||
classification: BlockClassificationEnum.Transform,
|
||||
classification: BlockClassification.Transform,
|
||||
sort: 2,
|
||||
type: BlockEnum.TemplateTransform,
|
||||
helpLinkUri: 'template',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user