Compare commits

..
Author SHA1 Message Date
zhaohao1004 1236d2b063 fix: harden workflow archive delete resume 2026-07-20 12:06:54 +08:00
zhaohao1004 0a80c3f8e3 feat: support sharded workflow archive deletion 2026-07-20 11:34:01 +08:00
Ingram ZandGitHub c4bc9abc2c Merge branch 'main' into zhaohao1004/workflow-archive-catalog-discovery 2026-07-20 09:48:53 +08:00
ChecoandGitHub 5ea884f799 fix(dataset_service): use flush() instead of commit() to preserve caller transaction (#39223) 2026-07-19 07:00:50 +00:00
yyhGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2ec34b2cfb feat(workflow): improve block selector navigation and previews (#39212)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-18 09:56:36 +00:00
Ingram ZandGitHub 0c30918760 Merge branch 'main' into zhaohao1004/workflow-archive-catalog-discovery 2026-07-18 10:07:22 +08:00
fa65f45bff refactor(web): align integrations sidebar components (#39202)
Co-authored-by: Jingyi <[email protected]>
2026-07-17 22:52:47 +00:00
yyhGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
63ca2b94b5 refactor(web): use generated OAuth query options (#39204)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-17 14:50:56 +00:00
yyhandGitHub a7ef41a8f5 refactor(web): use generated dataset card queries (#39203) 2026-07-17 14:50:40 +00:00
zhaohao1004 b3c7a706cd fix: handle partially deleted workflow archive bundles 2026-07-16 15:05:24 +08:00
zhaohao1004 9554037fec fix: harden workflow archive maintenance 2026-07-15 16:51:42 +08:00
zhaohao1004 a813e093d6 chore: remove archive discovery design doc 2026-07-15 16:02:31 +08:00
zhaohao1004 f7a3b9b283 refactor: use catalog for workflow archive maintenance 2026-07-15 15:52:07 +08:00
109 changed files with 4203 additions and 2485 deletions
+209 -63
View File
@@ -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
View File
@@ -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,
@@ -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)
@@ -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)
+2
View File
@@ -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)
+15 -4
View File
@@ -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"):
@@ -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")
-99
View File
@@ -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>
+7 -5
View File
@@ -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',
),
)
})
})
+24 -13
View File
@@ -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
@@ -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)
})
})
+24 -30
View File
@@ -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,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: {},
@@ -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: {},
@@ -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: {},
@@ -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: {},
@@ -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: {},
@@ -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: {},
@@ -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: {},
@@ -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: {},
@@ -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({
@@ -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: {},
@@ -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<
@@ -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 -7
View File
@@ -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">
+6 -6
View File
@@ -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
}
}
@@ -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
@@ -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