mirror of
https://github.com/langgenius/dify.git
synced 2026-03-10 01:35:14 +00:00
Compare commits
20 Commits
feat/fe-si
...
deploy/cle
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
232b8eb248 | ||
|
|
4c0d81029f | ||
|
|
bacf70c00a | ||
|
|
942729ba48 | ||
|
|
4f5af0b43c | ||
|
|
a28cb993b8 | ||
|
|
0aef09d630 | ||
|
|
c6dd2ef25a | ||
|
|
d2208ad43e | ||
|
|
4a2ba058bb | ||
|
|
654e41d47f | ||
|
|
ec5409756e | ||
|
|
b0e8becd14 | ||
|
|
8b1ea3a8f5 | ||
|
|
f2d3feca66 | ||
|
|
0590b09958 | ||
|
|
66f9fde2fe | ||
|
|
1811a855ab | ||
|
|
322cd37de1 | ||
|
|
f90e0d781a |
6
.github/dependabot.yml
vendored
6
.github/dependabot.yml
vendored
@@ -25,6 +25,10 @@ updates:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 2
|
||||
groups:
|
||||
lexical:
|
||||
patterns:
|
||||
- "lexical"
|
||||
- "@lexical/*"
|
||||
storybook:
|
||||
patterns:
|
||||
- "storybook"
|
||||
@@ -33,5 +37,7 @@ updates:
|
||||
patterns:
|
||||
- "*"
|
||||
exclude-patterns:
|
||||
- "lexical"
|
||||
- "@lexical/*"
|
||||
- "storybook"
|
||||
- "@storybook/*"
|
||||
|
||||
132
api/commands.py
132
api/commands.py
@@ -30,6 +30,7 @@ from extensions.ext_redis import redis_client
|
||||
from extensions.ext_storage import storage
|
||||
from extensions.storage.opendal_storage import OpenDALStorage
|
||||
from extensions.storage.storage_type import StorageType
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from libs.db_migration_lock import DbMigrationAutoRenewLock
|
||||
from libs.helper import email as email_validate
|
||||
from libs.password import hash_password, password_pattern, valid_password
|
||||
@@ -936,6 +937,12 @@ def clear_free_plan_tenant_expired_logs(days: int, batch: int, tenant_ids: list[
|
||||
is_flag=True,
|
||||
help="Preview cleanup results without deleting any workflow run data.",
|
||||
)
|
||||
@click.option(
|
||||
"--task-label",
|
||||
default="daily",
|
||||
show_default=True,
|
||||
help="Stable label value used to distinguish multiple cleanup CronJobs in metrics.",
|
||||
)
|
||||
def clean_workflow_runs(
|
||||
before_days: int,
|
||||
batch_size: int,
|
||||
@@ -944,10 +951,13 @@ def clean_workflow_runs(
|
||||
start_from: datetime.datetime | None,
|
||||
end_before: datetime.datetime | None,
|
||||
dry_run: bool,
|
||||
task_label: str,
|
||||
):
|
||||
"""
|
||||
Clean workflow runs and related workflow data for free tenants.
|
||||
"""
|
||||
from extensions.otel.runtime import flush_telemetry
|
||||
|
||||
if (start_from is None) ^ (end_before is None):
|
||||
raise click.UsageError("--start-from and --end-before must be provided together.")
|
||||
|
||||
@@ -967,13 +977,17 @@ def clean_workflow_runs(
|
||||
start_time = datetime.datetime.now(datetime.UTC)
|
||||
click.echo(click.style(f"Starting workflow run cleanup at {start_time.isoformat()}.", fg="white"))
|
||||
|
||||
WorkflowRunCleanup(
|
||||
days=before_days,
|
||||
batch_size=batch_size,
|
||||
start_from=start_from,
|
||||
end_before=end_before,
|
||||
dry_run=dry_run,
|
||||
).run()
|
||||
try:
|
||||
WorkflowRunCleanup(
|
||||
days=before_days,
|
||||
batch_size=batch_size,
|
||||
start_from=start_from,
|
||||
end_before=end_before,
|
||||
dry_run=dry_run,
|
||||
task_label=task_label,
|
||||
).run()
|
||||
finally:
|
||||
flush_telemetry()
|
||||
|
||||
end_time = datetime.datetime.now(datetime.UTC)
|
||||
elapsed = end_time - start_time
|
||||
@@ -2598,15 +2612,29 @@ def migrate_oss(
|
||||
@click.option(
|
||||
"--start-from",
|
||||
type=click.DateTime(formats=["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"]),
|
||||
required=True,
|
||||
required=False,
|
||||
default=None,
|
||||
help="Lower bound (inclusive) for created_at.",
|
||||
)
|
||||
@click.option(
|
||||
"--end-before",
|
||||
type=click.DateTime(formats=["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"]),
|
||||
required=True,
|
||||
required=False,
|
||||
default=None,
|
||||
help="Upper bound (exclusive) for created_at.",
|
||||
)
|
||||
@click.option(
|
||||
"--from-days-ago",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Relative lower bound in days ago (inclusive). Must be used with --before-days.",
|
||||
)
|
||||
@click.option(
|
||||
"--before-days",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Relative upper bound in days ago (exclusive). Required for relative mode.",
|
||||
)
|
||||
@click.option("--batch-size", default=1000, show_default=True, help="Batch size for selecting messages.")
|
||||
@click.option(
|
||||
"--graceful-period",
|
||||
@@ -2615,33 +2643,99 @@ def migrate_oss(
|
||||
help="Graceful period in days after subscription expiration, will be ignored when billing is disabled.",
|
||||
)
|
||||
@click.option("--dry-run", is_flag=True, default=False, help="Show messages logs would be cleaned without deleting")
|
||||
@click.option(
|
||||
"--task-label",
|
||||
default="daily",
|
||||
show_default=True,
|
||||
help="Stable label value used to distinguish multiple cleanup CronJobs in metrics.",
|
||||
)
|
||||
def clean_expired_messages(
|
||||
batch_size: int,
|
||||
graceful_period: int,
|
||||
start_from: datetime.datetime,
|
||||
end_before: datetime.datetime,
|
||||
start_from: datetime.datetime | None,
|
||||
end_before: datetime.datetime | None,
|
||||
from_days_ago: int | None,
|
||||
before_days: int | None,
|
||||
dry_run: bool,
|
||||
task_label: str,
|
||||
):
|
||||
"""
|
||||
Clean expired messages and related data for tenants based on clean policy.
|
||||
"""
|
||||
from extensions.otel.runtime import flush_telemetry
|
||||
|
||||
click.echo(click.style("clean_messages: start clean messages.", fg="green"))
|
||||
|
||||
start_at = time.perf_counter()
|
||||
|
||||
try:
|
||||
abs_mode = start_from is not None and end_before is not None
|
||||
rel_mode = before_days is not None
|
||||
|
||||
if abs_mode and rel_mode:
|
||||
raise click.UsageError(
|
||||
"Options are mutually exclusive: use either (--start-from,--end-before) "
|
||||
"or (--from-days-ago,--before-days)."
|
||||
)
|
||||
|
||||
if from_days_ago is not None and before_days is None:
|
||||
raise click.UsageError("--from-days-ago must be used together with --before-days.")
|
||||
|
||||
if (start_from is None) ^ (end_before is None):
|
||||
raise click.UsageError("Both --start-from and --end-before are required when using absolute time range.")
|
||||
|
||||
if not abs_mode and not rel_mode:
|
||||
raise click.UsageError(
|
||||
"You must provide either (--start-from,--end-before) or (--before-days [--from-days-ago])."
|
||||
)
|
||||
|
||||
if rel_mode:
|
||||
assert before_days is not None
|
||||
if before_days < 0:
|
||||
raise click.UsageError("--before-days must be >= 0.")
|
||||
if from_days_ago is not None:
|
||||
if from_days_ago < 0:
|
||||
raise click.UsageError("--from-days-ago must be >= 0.")
|
||||
if from_days_ago <= before_days:
|
||||
raise click.UsageError("--from-days-ago must be greater than --before-days.")
|
||||
|
||||
# Create policy based on billing configuration
|
||||
# NOTE: graceful_period will be ignored when billing is disabled.
|
||||
policy = create_message_clean_policy(graceful_period_days=graceful_period)
|
||||
|
||||
# Create and run the cleanup service
|
||||
service = MessagesCleanService.from_time_range(
|
||||
policy=policy,
|
||||
start_from=start_from,
|
||||
end_before=end_before,
|
||||
batch_size=batch_size,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
if abs_mode:
|
||||
assert start_from is not None
|
||||
assert end_before is not None
|
||||
service = MessagesCleanService.from_time_range(
|
||||
policy=policy,
|
||||
start_from=start_from,
|
||||
end_before=end_before,
|
||||
batch_size=batch_size,
|
||||
dry_run=dry_run,
|
||||
task_label=task_label,
|
||||
)
|
||||
elif from_days_ago is None:
|
||||
assert before_days is not None
|
||||
service = MessagesCleanService.from_days(
|
||||
policy=policy,
|
||||
days=before_days,
|
||||
batch_size=batch_size,
|
||||
dry_run=dry_run,
|
||||
task_label=task_label,
|
||||
)
|
||||
else:
|
||||
assert before_days is not None
|
||||
assert from_days_ago is not None
|
||||
now = naive_utc_now()
|
||||
service = MessagesCleanService.from_time_range(
|
||||
policy=policy,
|
||||
start_from=now - datetime.timedelta(days=from_days_ago),
|
||||
end_before=now - datetime.timedelta(days=before_days),
|
||||
batch_size=batch_size,
|
||||
dry_run=dry_run,
|
||||
task_label=task_label,
|
||||
)
|
||||
stats = service.run()
|
||||
|
||||
end_at = time.perf_counter()
|
||||
@@ -2666,6 +2760,8 @@ def clean_expired_messages(
|
||||
)
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
flush_telemetry()
|
||||
|
||||
click.echo(click.style("messages cleanup completed.", fg="green"))
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ VARIABLE_TO_PARAMETER_TYPE_MAPPING = {
|
||||
VariableEntityType.CHECKBOX: ToolParameter.ToolParameterType.BOOLEAN,
|
||||
VariableEntityType.FILE: ToolParameter.ToolParameterType.FILE,
|
||||
VariableEntityType.FILE_LIST: ToolParameter.ToolParameterType.FILES,
|
||||
VariableEntityType.JSON_OBJECT: ToolParameter.ToolParameterType.OBJECT,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import zipfile
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
@@ -82,8 +83,18 @@ class DocumentExtractorNode(Node[DocumentExtractorNodeData]):
|
||||
|
||||
value = variable.value
|
||||
inputs = {"variable_selector": variable_selector}
|
||||
if isinstance(value, list):
|
||||
value = list(filter(lambda x: x, value))
|
||||
process_data = {"documents": value if isinstance(value, list) else [value]}
|
||||
|
||||
if not value:
|
||||
return NodeRunResult(
|
||||
status=WorkflowNodeExecutionStatus.SUCCEEDED,
|
||||
inputs=inputs,
|
||||
process_data=process_data,
|
||||
outputs={"text": ArrayStringSegment(value=[])},
|
||||
)
|
||||
|
||||
try:
|
||||
if isinstance(value, list):
|
||||
extracted_text_list = [
|
||||
@@ -111,6 +122,7 @@ class DocumentExtractorNode(Node[DocumentExtractorNodeData]):
|
||||
else:
|
||||
raise DocumentExtractorError(f"Unsupported variable type: {type(value)}")
|
||||
except DocumentExtractorError as e:
|
||||
logger.warning(e, exc_info=True)
|
||||
return NodeRunResult(
|
||||
status=WorkflowNodeExecutionStatus.FAILED,
|
||||
error=str(e),
|
||||
@@ -385,6 +397,32 @@ def parser_docx_part(block, doc: Document, content_items, i):
|
||||
content_items.append((i, "table", Table(block, doc)))
|
||||
|
||||
|
||||
def _normalize_docx_zip(file_content: bytes) -> bytes:
|
||||
"""
|
||||
Some DOCX files (e.g. exported by Evernote on Windows) are malformed:
|
||||
ZIP entry names use backslash (\\) as path separator instead of the forward
|
||||
slash (/) required by both the ZIP spec and OOXML. On Linux/Mac the entry
|
||||
"word\\document.xml" is never found when python-docx looks for
|
||||
"word/document.xml", which triggers a KeyError about a missing relationship.
|
||||
|
||||
This function rewrites the ZIP in-memory, normalizing all entry names to
|
||||
use forward slashes without touching any actual document content.
|
||||
"""
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(file_content), "r") as zin:
|
||||
out_buf = io.BytesIO()
|
||||
with zipfile.ZipFile(out_buf, "w", compression=zipfile.ZIP_DEFLATED) as zout:
|
||||
for item in zin.infolist():
|
||||
data = zin.read(item.filename)
|
||||
# Normalize backslash path separators to forward slash
|
||||
item.filename = item.filename.replace("\\", "/")
|
||||
zout.writestr(item, data)
|
||||
return out_buf.getvalue()
|
||||
except zipfile.BadZipFile:
|
||||
# Not a valid zip — return as-is and let python-docx report the real error
|
||||
return file_content
|
||||
|
||||
|
||||
def _extract_text_from_docx(file_content: bytes) -> str:
|
||||
"""
|
||||
Extract text from a DOCX file.
|
||||
@@ -392,7 +430,15 @@ def _extract_text_from_docx(file_content: bytes) -> str:
|
||||
"""
|
||||
try:
|
||||
doc_file = io.BytesIO(file_content)
|
||||
doc = docx.Document(doc_file)
|
||||
try:
|
||||
doc = docx.Document(doc_file)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to parse DOCX, attempting to normalize ZIP entry paths: %s", e)
|
||||
# Some DOCX files exported by tools like Evernote on Windows use
|
||||
# backslash path separators in ZIP entries and/or single-quoted XML
|
||||
# attributes, both of which break python-docx on Linux. Normalize and retry.
|
||||
file_content = _normalize_docx_zip(file_content)
|
||||
doc = docx.Document(io.BytesIO(file_content))
|
||||
text = []
|
||||
|
||||
# Keep track of paragraph and table positions
|
||||
|
||||
@@ -23,7 +23,11 @@ from dify_graph.variables import (
|
||||
)
|
||||
from dify_graph.variables.segments import ArrayObjectSegment
|
||||
|
||||
from .entities import KnowledgeRetrievalNodeData
|
||||
from .entities import (
|
||||
Condition,
|
||||
KnowledgeRetrievalNodeData,
|
||||
MetadataFilteringCondition,
|
||||
)
|
||||
from .exc import (
|
||||
KnowledgeRetrievalNodeError,
|
||||
RateLimitExceededError,
|
||||
@@ -171,6 +175,12 @@ class KnowledgeRetrievalNode(LLMUsageTrackingMixin, Node[KnowledgeRetrievalNodeD
|
||||
if node_data.metadata_filtering_mode is not None:
|
||||
metadata_filtering_mode = node_data.metadata_filtering_mode
|
||||
|
||||
resolved_metadata_conditions = (
|
||||
self._resolve_metadata_filtering_conditions(node_data.metadata_filtering_conditions)
|
||||
if node_data.metadata_filtering_conditions
|
||||
else None
|
||||
)
|
||||
|
||||
if str(node_data.retrieval_mode) == DatasetRetrieveConfigEntity.RetrieveStrategy.SINGLE and query:
|
||||
# fetch model config
|
||||
if node_data.single_retrieval_config is None:
|
||||
@@ -189,7 +199,7 @@ class KnowledgeRetrievalNode(LLMUsageTrackingMixin, Node[KnowledgeRetrievalNodeD
|
||||
model_mode=model.mode,
|
||||
model_name=model.name,
|
||||
metadata_model_config=node_data.metadata_model_config,
|
||||
metadata_filtering_conditions=node_data.metadata_filtering_conditions,
|
||||
metadata_filtering_conditions=resolved_metadata_conditions,
|
||||
metadata_filtering_mode=metadata_filtering_mode,
|
||||
query=query,
|
||||
)
|
||||
@@ -247,7 +257,7 @@ class KnowledgeRetrievalNode(LLMUsageTrackingMixin, Node[KnowledgeRetrievalNodeD
|
||||
weights=weights,
|
||||
reranking_enable=node_data.multiple_retrieval_config.reranking_enable,
|
||||
metadata_model_config=node_data.metadata_model_config,
|
||||
metadata_filtering_conditions=node_data.metadata_filtering_conditions,
|
||||
metadata_filtering_conditions=resolved_metadata_conditions,
|
||||
metadata_filtering_mode=metadata_filtering_mode,
|
||||
attachment_ids=[attachment.related_id for attachment in attachments] if attachments else None,
|
||||
)
|
||||
@@ -256,6 +266,48 @@ class KnowledgeRetrievalNode(LLMUsageTrackingMixin, Node[KnowledgeRetrievalNodeD
|
||||
usage = self._rag_retrieval.llm_usage
|
||||
return retrieval_resource_list, usage
|
||||
|
||||
def _resolve_metadata_filtering_conditions(
|
||||
self, conditions: MetadataFilteringCondition
|
||||
) -> MetadataFilteringCondition:
|
||||
if conditions.conditions is None:
|
||||
return MetadataFilteringCondition(
|
||||
logical_operator=conditions.logical_operator,
|
||||
conditions=None,
|
||||
)
|
||||
|
||||
variable_pool = self.graph_runtime_state.variable_pool
|
||||
resolved_conditions: list[Condition] = []
|
||||
for cond in conditions.conditions or []:
|
||||
value = cond.value
|
||||
if isinstance(value, str):
|
||||
segment_group = variable_pool.convert_template(value)
|
||||
if len(segment_group.value) == 1:
|
||||
resolved_value = segment_group.value[0].to_object()
|
||||
else:
|
||||
resolved_value = segment_group.text
|
||||
elif isinstance(value, Sequence) and all(isinstance(v, str) for v in value):
|
||||
resolved_values = []
|
||||
for v in value: # type: ignore
|
||||
segment_group = variable_pool.convert_template(v)
|
||||
if len(segment_group.value) == 1:
|
||||
resolved_values.append(segment_group.value[0].to_object())
|
||||
else:
|
||||
resolved_values.append(segment_group.text)
|
||||
resolved_value = resolved_values
|
||||
else:
|
||||
resolved_value = value
|
||||
resolved_conditions.append(
|
||||
Condition(
|
||||
name=cond.name,
|
||||
comparison_operator=cond.comparison_operator,
|
||||
value=resolved_value,
|
||||
)
|
||||
)
|
||||
return MetadataFilteringCondition(
|
||||
logical_operator=conditions.logical_operator or "and",
|
||||
conditions=resolved_conditions,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _extract_variable_selector_to_variable_mapping(
|
||||
cls,
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Union
|
||||
|
||||
from celery.signals import worker_init
|
||||
from flask_login import user_loaded_from_request, user_logged_in
|
||||
from opentelemetry import trace
|
||||
from opentelemetry import metrics, trace
|
||||
from opentelemetry.propagate import set_global_textmap
|
||||
from opentelemetry.propagators.b3 import B3Format
|
||||
from opentelemetry.propagators.composite import CompositePropagator
|
||||
@@ -31,9 +31,29 @@ def setup_context_propagation() -> None:
|
||||
|
||||
|
||||
def shutdown_tracer() -> None:
|
||||
flush_telemetry()
|
||||
|
||||
|
||||
def flush_telemetry() -> None:
|
||||
"""
|
||||
Best-effort flush for telemetry providers.
|
||||
|
||||
This is mainly used by short-lived command processes (e.g. Kubernetes CronJob)
|
||||
so counters/histograms are exported before the process exits.
|
||||
"""
|
||||
provider = trace.get_tracer_provider()
|
||||
if hasattr(provider, "force_flush"):
|
||||
provider.force_flush()
|
||||
try:
|
||||
provider.force_flush()
|
||||
except Exception:
|
||||
logger.exception("otel: failed to flush trace provider")
|
||||
|
||||
metric_provider = metrics.get_meter_provider()
|
||||
if hasattr(metric_provider, "force_flush"):
|
||||
try:
|
||||
metric_provider.force_flush()
|
||||
except Exception:
|
||||
logger.exception("otel: failed to flush metric provider")
|
||||
|
||||
|
||||
def is_celery_worker():
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
from collections.abc import Sequence
|
||||
from typing import cast
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import delete, select, tuple_
|
||||
from sqlalchemy.engine import CursorResult
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from configs import dify_config
|
||||
from extensions.ext_database import db
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models.model import (
|
||||
App,
|
||||
AppAnnotationHitHistory,
|
||||
@@ -32,6 +33,128 @@ from services.retention.conversation.messages_clean_policy import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from opentelemetry.metrics import Counter, Histogram
|
||||
|
||||
|
||||
class MessagesCleanupMetrics:
|
||||
"""
|
||||
Records low-cardinality OpenTelemetry metrics for expired message cleanup jobs.
|
||||
|
||||
We keep labels stable (dry_run/window_mode/task_label/status) so these metrics remain
|
||||
dashboard-friendly for long-running CronJob executions.
|
||||
"""
|
||||
|
||||
_job_runs_total: "Counter | None"
|
||||
_batches_total: "Counter | None"
|
||||
_messages_scanned_total: "Counter | None"
|
||||
_messages_filtered_total: "Counter | None"
|
||||
_messages_deleted_total: "Counter | None"
|
||||
_job_duration_seconds: "Histogram | None"
|
||||
_batch_duration_seconds: "Histogram | None"
|
||||
_base_attributes: dict[str, str]
|
||||
|
||||
def __init__(self, *, dry_run: bool, has_window: bool, task_label: str) -> None:
|
||||
self._job_runs_total = None
|
||||
self._batches_total = None
|
||||
self._messages_scanned_total = None
|
||||
self._messages_filtered_total = None
|
||||
self._messages_deleted_total = None
|
||||
self._job_duration_seconds = None
|
||||
self._batch_duration_seconds = None
|
||||
self._base_attributes = {
|
||||
"job_name": "messages_cleanup",
|
||||
"dry_run": str(dry_run).lower(),
|
||||
"window_mode": "between" if has_window else "before_cutoff",
|
||||
"task_label": task_label,
|
||||
}
|
||||
self._init_instruments()
|
||||
|
||||
def _init_instruments(self) -> None:
|
||||
try:
|
||||
from opentelemetry.metrics import get_meter
|
||||
|
||||
meter = get_meter("messages_cleanup", version=dify_config.project.version)
|
||||
self._job_runs_total = meter.create_counter(
|
||||
"messages_cleanup_jobs_total",
|
||||
description="Total number of expired message cleanup jobs by status.",
|
||||
unit="{job}",
|
||||
)
|
||||
self._batches_total = meter.create_counter(
|
||||
"messages_cleanup_batches_total",
|
||||
description="Total number of message cleanup batches processed.",
|
||||
unit="{batch}",
|
||||
)
|
||||
self._messages_scanned_total = meter.create_counter(
|
||||
"messages_cleanup_scanned_messages_total",
|
||||
description="Total messages scanned by cleanup jobs.",
|
||||
unit="{message}",
|
||||
)
|
||||
self._messages_filtered_total = meter.create_counter(
|
||||
"messages_cleanup_filtered_messages_total",
|
||||
description="Total messages selected by cleanup policy.",
|
||||
unit="{message}",
|
||||
)
|
||||
self._messages_deleted_total = meter.create_counter(
|
||||
"messages_cleanup_deleted_messages_total",
|
||||
description="Total messages deleted by cleanup jobs.",
|
||||
unit="{message}",
|
||||
)
|
||||
self._job_duration_seconds = meter.create_histogram(
|
||||
"messages_cleanup_job_duration_seconds",
|
||||
description="Duration of expired message cleanup jobs in seconds.",
|
||||
unit="s",
|
||||
)
|
||||
self._batch_duration_seconds = meter.create_histogram(
|
||||
"messages_cleanup_batch_duration_seconds",
|
||||
description="Duration of expired message cleanup batch processing in seconds.",
|
||||
unit="s",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("messages_cleanup_metrics: failed to initialize instruments")
|
||||
|
||||
def _attrs(self, **extra: str) -> dict[str, str]:
|
||||
return {**self._base_attributes, **extra}
|
||||
|
||||
@staticmethod
|
||||
def _add(counter: "Counter | None", value: int, attributes: dict[str, str]) -> None:
|
||||
if not counter or value <= 0:
|
||||
return
|
||||
try:
|
||||
counter.add(value, attributes)
|
||||
except Exception:
|
||||
logger.exception("messages_cleanup_metrics: failed to add counter value")
|
||||
|
||||
@staticmethod
|
||||
def _record(histogram: "Histogram | None", value: float, attributes: dict[str, str]) -> None:
|
||||
if not histogram:
|
||||
return
|
||||
try:
|
||||
histogram.record(value, attributes)
|
||||
except Exception:
|
||||
logger.exception("messages_cleanup_metrics: failed to record histogram value")
|
||||
|
||||
def record_batch(
|
||||
self,
|
||||
*,
|
||||
scanned_messages: int,
|
||||
filtered_messages: int,
|
||||
deleted_messages: int,
|
||||
batch_duration_seconds: float,
|
||||
) -> None:
|
||||
attributes = self._attrs()
|
||||
self._add(self._batches_total, 1, attributes)
|
||||
self._add(self._messages_scanned_total, scanned_messages, attributes)
|
||||
self._add(self._messages_filtered_total, filtered_messages, attributes)
|
||||
self._add(self._messages_deleted_total, deleted_messages, attributes)
|
||||
self._record(self._batch_duration_seconds, batch_duration_seconds, attributes)
|
||||
|
||||
def record_completion(self, *, status: str, job_duration_seconds: float) -> None:
|
||||
attributes = self._attrs(status=status)
|
||||
self._add(self._job_runs_total, 1, attributes)
|
||||
self._record(self._job_duration_seconds, job_duration_seconds, attributes)
|
||||
|
||||
|
||||
class MessagesCleanService:
|
||||
"""
|
||||
Service for cleaning expired messages based on retention policies.
|
||||
@@ -47,6 +170,7 @@ class MessagesCleanService:
|
||||
start_from: datetime.datetime | None = None,
|
||||
batch_size: int = 1000,
|
||||
dry_run: bool = False,
|
||||
task_label: str = "daily",
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the service with cleanup parameters.
|
||||
@@ -57,12 +181,20 @@ class MessagesCleanService:
|
||||
start_from: Optional start time (inclusive) of the range
|
||||
batch_size: Number of messages to process per batch
|
||||
dry_run: Whether to perform a dry run (no actual deletion)
|
||||
task_label: Stable task label to distinguish multiple cleanup CronJobs
|
||||
"""
|
||||
self._policy = policy
|
||||
self._end_before = end_before
|
||||
self._start_from = start_from
|
||||
self._batch_size = batch_size
|
||||
self._dry_run = dry_run
|
||||
normalized_task_label = task_label.strip()
|
||||
self._task_label = normalized_task_label or "daily"
|
||||
self._metrics = MessagesCleanupMetrics(
|
||||
dry_run=dry_run,
|
||||
has_window=bool(start_from),
|
||||
task_label=self._task_label,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_time_range(
|
||||
@@ -72,6 +204,7 @@ class MessagesCleanService:
|
||||
end_before: datetime.datetime,
|
||||
batch_size: int = 1000,
|
||||
dry_run: bool = False,
|
||||
task_label: str = "daily",
|
||||
) -> "MessagesCleanService":
|
||||
"""
|
||||
Create a service instance for cleaning messages within a specific time range.
|
||||
@@ -84,6 +217,7 @@ class MessagesCleanService:
|
||||
end_before: End time (exclusive) of the range
|
||||
batch_size: Number of messages to process per batch
|
||||
dry_run: Whether to perform a dry run (no actual deletion)
|
||||
task_label: Stable task label to distinguish multiple cleanup CronJobs
|
||||
|
||||
Returns:
|
||||
MessagesCleanService instance
|
||||
@@ -111,6 +245,7 @@ class MessagesCleanService:
|
||||
start_from=start_from,
|
||||
batch_size=batch_size,
|
||||
dry_run=dry_run,
|
||||
task_label=task_label,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@@ -120,6 +255,7 @@ class MessagesCleanService:
|
||||
days: int = 30,
|
||||
batch_size: int = 1000,
|
||||
dry_run: bool = False,
|
||||
task_label: str = "daily",
|
||||
) -> "MessagesCleanService":
|
||||
"""
|
||||
Create a service instance for cleaning messages older than specified days.
|
||||
@@ -129,6 +265,7 @@ class MessagesCleanService:
|
||||
days: Number of days to look back from now
|
||||
batch_size: Number of messages to process per batch
|
||||
dry_run: Whether to perform a dry run (no actual deletion)
|
||||
task_label: Stable task label to distinguish multiple cleanup CronJobs
|
||||
|
||||
Returns:
|
||||
MessagesCleanService instance
|
||||
@@ -142,7 +279,7 @@ class MessagesCleanService:
|
||||
if batch_size <= 0:
|
||||
raise ValueError(f"batch_size ({batch_size}) must be greater than 0")
|
||||
|
||||
end_before = datetime.datetime.now() - datetime.timedelta(days=days)
|
||||
end_before = naive_utc_now() - datetime.timedelta(days=days)
|
||||
|
||||
logger.info(
|
||||
"clean_messages: days=%s, end_before=%s, batch_size=%s, policy=%s",
|
||||
@@ -152,7 +289,14 @@ class MessagesCleanService:
|
||||
policy.__class__.__name__,
|
||||
)
|
||||
|
||||
return cls(policy=policy, end_before=end_before, start_from=None, batch_size=batch_size, dry_run=dry_run)
|
||||
return cls(
|
||||
policy=policy,
|
||||
end_before=end_before,
|
||||
start_from=None,
|
||||
batch_size=batch_size,
|
||||
dry_run=dry_run,
|
||||
task_label=task_label,
|
||||
)
|
||||
|
||||
def run(self) -> dict[str, int]:
|
||||
"""
|
||||
@@ -161,7 +305,18 @@ class MessagesCleanService:
|
||||
Returns:
|
||||
Dict with statistics: batches, filtered_messages, total_deleted
|
||||
"""
|
||||
return self._clean_messages_by_time_range()
|
||||
status = "success"
|
||||
run_start = time.monotonic()
|
||||
try:
|
||||
return self._clean_messages_by_time_range()
|
||||
except Exception:
|
||||
status = "failed"
|
||||
raise
|
||||
finally:
|
||||
self._metrics.record_completion(
|
||||
status=status,
|
||||
job_duration_seconds=time.monotonic() - run_start,
|
||||
)
|
||||
|
||||
def _clean_messages_by_time_range(self) -> dict[str, int]:
|
||||
"""
|
||||
@@ -196,11 +351,14 @@ class MessagesCleanService:
|
||||
self._end_before,
|
||||
)
|
||||
|
||||
max_batch_interval_ms = int(os.environ.get("SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL", 200))
|
||||
max_batch_interval_ms = dify_config.SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL
|
||||
|
||||
while True:
|
||||
stats["batches"] += 1
|
||||
batch_start = time.monotonic()
|
||||
batch_scanned_messages = 0
|
||||
batch_filtered_messages = 0
|
||||
batch_deleted_messages = 0
|
||||
|
||||
# Step 1: Fetch a batch of messages using cursor
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
@@ -239,9 +397,16 @@ class MessagesCleanService:
|
||||
|
||||
# Track total messages fetched across all batches
|
||||
stats["total_messages"] += len(messages)
|
||||
batch_scanned_messages = len(messages)
|
||||
|
||||
if not messages:
|
||||
logger.info("clean_messages (batch %s): no more messages to process", stats["batches"])
|
||||
self._metrics.record_batch(
|
||||
scanned_messages=batch_scanned_messages,
|
||||
filtered_messages=batch_filtered_messages,
|
||||
deleted_messages=batch_deleted_messages,
|
||||
batch_duration_seconds=time.monotonic() - batch_start,
|
||||
)
|
||||
break
|
||||
|
||||
# Update cursor to the last message's (created_at, id)
|
||||
@@ -267,6 +432,12 @@ class MessagesCleanService:
|
||||
|
||||
if not apps:
|
||||
logger.info("clean_messages (batch %s): no apps found, skip", stats["batches"])
|
||||
self._metrics.record_batch(
|
||||
scanned_messages=batch_scanned_messages,
|
||||
filtered_messages=batch_filtered_messages,
|
||||
deleted_messages=batch_deleted_messages,
|
||||
batch_duration_seconds=time.monotonic() - batch_start,
|
||||
)
|
||||
continue
|
||||
|
||||
# Build app_id -> tenant_id mapping
|
||||
@@ -285,9 +456,16 @@ class MessagesCleanService:
|
||||
|
||||
if not message_ids_to_delete:
|
||||
logger.info("clean_messages (batch %s): no messages to delete, skip", stats["batches"])
|
||||
self._metrics.record_batch(
|
||||
scanned_messages=batch_scanned_messages,
|
||||
filtered_messages=batch_filtered_messages,
|
||||
deleted_messages=batch_deleted_messages,
|
||||
batch_duration_seconds=time.monotonic() - batch_start,
|
||||
)
|
||||
continue
|
||||
|
||||
stats["filtered_messages"] += len(message_ids_to_delete)
|
||||
batch_filtered_messages = len(message_ids_to_delete)
|
||||
|
||||
# Step 4: Batch delete messages and their relations
|
||||
if not self._dry_run:
|
||||
@@ -308,6 +486,7 @@ class MessagesCleanService:
|
||||
commit_ms = int((time.monotonic() - commit_start) * 1000)
|
||||
|
||||
stats["total_deleted"] += messages_deleted
|
||||
batch_deleted_messages = messages_deleted
|
||||
|
||||
logger.info(
|
||||
"clean_messages (batch %s): processed %s messages, deleted %s messages",
|
||||
@@ -342,6 +521,13 @@ class MessagesCleanService:
|
||||
for msg_id in sampled_ids:
|
||||
logger.info("clean_messages (batch %s, dry_run) sample: message_id=%s", stats["batches"], msg_id)
|
||||
|
||||
self._metrics.record_batch(
|
||||
scanned_messages=batch_scanned_messages,
|
||||
filtered_messages=batch_filtered_messages,
|
||||
deleted_messages=batch_deleted_messages,
|
||||
batch_duration_seconds=time.monotonic() - batch_start,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"clean_messages completed: total batches: %s, total messages: %s, filtered messages: %s, total deleted: %s",
|
||||
stats["batches"],
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
from collections.abc import Iterable, Sequence
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
@@ -20,6 +20,156 @@ from services.billing_service import BillingService, SubscriptionPlan
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from opentelemetry.metrics import Counter, Histogram
|
||||
|
||||
|
||||
class WorkflowRunCleanupMetrics:
|
||||
"""
|
||||
Records low-cardinality OpenTelemetry metrics for workflow run cleanup jobs.
|
||||
|
||||
Metrics are emitted with stable labels only (dry_run/window_mode/task_label/status)
|
||||
to keep dashboard and alert cardinality predictable in production clusters.
|
||||
"""
|
||||
|
||||
_job_runs_total: "Counter | None"
|
||||
_batches_total: "Counter | None"
|
||||
_runs_scanned_total: "Counter | None"
|
||||
_runs_targeted_total: "Counter | None"
|
||||
_runs_deleted_total: "Counter | None"
|
||||
_runs_skipped_total: "Counter | None"
|
||||
_related_records_total: "Counter | None"
|
||||
_job_duration_seconds: "Histogram | None"
|
||||
_batch_duration_seconds: "Histogram | None"
|
||||
_base_attributes: dict[str, str]
|
||||
|
||||
def __init__(self, *, dry_run: bool, has_window: bool, task_label: str) -> None:
|
||||
self._job_runs_total = None
|
||||
self._batches_total = None
|
||||
self._runs_scanned_total = None
|
||||
self._runs_targeted_total = None
|
||||
self._runs_deleted_total = None
|
||||
self._runs_skipped_total = None
|
||||
self._related_records_total = None
|
||||
self._job_duration_seconds = None
|
||||
self._batch_duration_seconds = None
|
||||
self._base_attributes = {
|
||||
"job_name": "workflow_run_cleanup",
|
||||
"dry_run": str(dry_run).lower(),
|
||||
"window_mode": "between" if has_window else "before_cutoff",
|
||||
"task_label": task_label,
|
||||
}
|
||||
self._init_instruments()
|
||||
|
||||
def _init_instruments(self) -> None:
|
||||
try:
|
||||
from opentelemetry.metrics import get_meter
|
||||
|
||||
meter = get_meter("workflow_run_cleanup", version=dify_config.project.version)
|
||||
self._job_runs_total = meter.create_counter(
|
||||
"workflow_run_cleanup_jobs_total",
|
||||
description="Total number of workflow run cleanup jobs by status.",
|
||||
unit="{job}",
|
||||
)
|
||||
self._batches_total = meter.create_counter(
|
||||
"workflow_run_cleanup_batches_total",
|
||||
description="Total number of processed cleanup batches.",
|
||||
unit="{batch}",
|
||||
)
|
||||
self._runs_scanned_total = meter.create_counter(
|
||||
"workflow_run_cleanup_scanned_runs_total",
|
||||
description="Total workflow runs scanned by cleanup jobs.",
|
||||
unit="{run}",
|
||||
)
|
||||
self._runs_targeted_total = meter.create_counter(
|
||||
"workflow_run_cleanup_targeted_runs_total",
|
||||
description="Total workflow runs targeted by cleanup policy.",
|
||||
unit="{run}",
|
||||
)
|
||||
self._runs_deleted_total = meter.create_counter(
|
||||
"workflow_run_cleanup_deleted_runs_total",
|
||||
description="Total workflow runs deleted by cleanup jobs.",
|
||||
unit="{run}",
|
||||
)
|
||||
self._runs_skipped_total = meter.create_counter(
|
||||
"workflow_run_cleanup_skipped_runs_total",
|
||||
description="Total workflow runs skipped because tenant is paid/unknown.",
|
||||
unit="{run}",
|
||||
)
|
||||
self._related_records_total = meter.create_counter(
|
||||
"workflow_run_cleanup_related_records_total",
|
||||
description="Total related records processed by cleanup jobs.",
|
||||
unit="{record}",
|
||||
)
|
||||
self._job_duration_seconds = meter.create_histogram(
|
||||
"workflow_run_cleanup_job_duration_seconds",
|
||||
description="Duration of workflow run cleanup jobs in seconds.",
|
||||
unit="s",
|
||||
)
|
||||
self._batch_duration_seconds = meter.create_histogram(
|
||||
"workflow_run_cleanup_batch_duration_seconds",
|
||||
description="Duration of workflow run cleanup batch processing in seconds.",
|
||||
unit="s",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("workflow_run_cleanup_metrics: failed to initialize instruments")
|
||||
|
||||
def _attrs(self, **extra: str) -> dict[str, str]:
|
||||
return {**self._base_attributes, **extra}
|
||||
|
||||
@staticmethod
|
||||
def _add(counter: "Counter | None", value: int, attributes: dict[str, str]) -> None:
|
||||
if not counter or value <= 0:
|
||||
return
|
||||
try:
|
||||
counter.add(value, attributes)
|
||||
except Exception:
|
||||
logger.exception("workflow_run_cleanup_metrics: failed to add counter value")
|
||||
|
||||
@staticmethod
|
||||
def _record(histogram: "Histogram | None", value: float, attributes: dict[str, str]) -> None:
|
||||
if not histogram:
|
||||
return
|
||||
try:
|
||||
histogram.record(value, attributes)
|
||||
except Exception:
|
||||
logger.exception("workflow_run_cleanup_metrics: failed to record histogram value")
|
||||
|
||||
def record_batch(
|
||||
self,
|
||||
*,
|
||||
batch_rows: int,
|
||||
targeted_runs: int,
|
||||
skipped_runs: int,
|
||||
deleted_runs: int,
|
||||
related_counts: dict[str, int] | None,
|
||||
related_action: str | None,
|
||||
batch_duration_seconds: float,
|
||||
) -> None:
|
||||
attributes = self._attrs()
|
||||
self._add(self._batches_total, 1, attributes)
|
||||
self._add(self._runs_scanned_total, batch_rows, attributes)
|
||||
self._add(self._runs_targeted_total, targeted_runs, attributes)
|
||||
self._add(self._runs_skipped_total, skipped_runs, attributes)
|
||||
self._add(self._runs_deleted_total, deleted_runs, attributes)
|
||||
self._record(self._batch_duration_seconds, batch_duration_seconds, attributes)
|
||||
|
||||
if not related_counts or not related_action:
|
||||
return
|
||||
|
||||
for record_type, count in related_counts.items():
|
||||
self._add(
|
||||
self._related_records_total,
|
||||
count,
|
||||
self._attrs(action=related_action, record_type=record_type),
|
||||
)
|
||||
|
||||
def record_completion(self, *, status: str, job_duration_seconds: float) -> None:
|
||||
attributes = self._attrs(status=status)
|
||||
self._add(self._job_runs_total, 1, attributes)
|
||||
self._record(self._job_duration_seconds, job_duration_seconds, attributes)
|
||||
|
||||
|
||||
class WorkflowRunCleanup:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -29,6 +179,7 @@ class WorkflowRunCleanup:
|
||||
end_before: datetime.datetime | None = None,
|
||||
workflow_run_repo: APIWorkflowRunRepository | None = None,
|
||||
dry_run: bool = False,
|
||||
task_label: str = "daily",
|
||||
):
|
||||
if (start_from is None) ^ (end_before is None):
|
||||
raise ValueError("start_from and end_before must be both set or both omitted.")
|
||||
@@ -46,6 +197,13 @@ class WorkflowRunCleanup:
|
||||
self.batch_size = batch_size
|
||||
self._cleanup_whitelist: set[str] | None = None
|
||||
self.dry_run = dry_run
|
||||
normalized_task_label = task_label.strip()
|
||||
self.task_label = normalized_task_label or "daily"
|
||||
self._metrics = WorkflowRunCleanupMetrics(
|
||||
dry_run=dry_run,
|
||||
has_window=bool(start_from),
|
||||
task_label=self.task_label,
|
||||
)
|
||||
self.free_plan_grace_period_days = dify_config.SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD
|
||||
self.workflow_run_repo: APIWorkflowRunRepository
|
||||
if workflow_run_repo:
|
||||
@@ -74,153 +232,193 @@ class WorkflowRunCleanup:
|
||||
related_totals = self._empty_related_counts() if self.dry_run else None
|
||||
batch_index = 0
|
||||
last_seen: tuple[datetime.datetime, str] | None = None
|
||||
status = "success"
|
||||
run_start = time.monotonic()
|
||||
max_batch_interval_ms = dify_config.SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL
|
||||
|
||||
max_batch_interval_ms = int(os.environ.get("SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL", 200))
|
||||
try:
|
||||
while True:
|
||||
batch_start = time.monotonic()
|
||||
|
||||
while True:
|
||||
batch_start = time.monotonic()
|
||||
|
||||
fetch_start = time.monotonic()
|
||||
run_rows = self.workflow_run_repo.get_runs_batch_by_time_range(
|
||||
start_from=self.window_start,
|
||||
end_before=self.window_end,
|
||||
last_seen=last_seen,
|
||||
batch_size=self.batch_size,
|
||||
)
|
||||
if not run_rows:
|
||||
logger.info("workflow_run_cleanup (batch #%s): no more rows to process", batch_index + 1)
|
||||
break
|
||||
|
||||
batch_index += 1
|
||||
last_seen = (run_rows[-1].created_at, run_rows[-1].id)
|
||||
logger.info(
|
||||
"workflow_run_cleanup (batch #%s): fetched %s rows in %sms",
|
||||
batch_index,
|
||||
len(run_rows),
|
||||
int((time.monotonic() - fetch_start) * 1000),
|
||||
)
|
||||
|
||||
tenant_ids = {row.tenant_id for row in run_rows}
|
||||
|
||||
filter_start = time.monotonic()
|
||||
free_tenants = self._filter_free_tenants(tenant_ids)
|
||||
logger.info(
|
||||
"workflow_run_cleanup (batch #%s): filtered %s free tenants from %s tenants in %sms",
|
||||
batch_index,
|
||||
len(free_tenants),
|
||||
len(tenant_ids),
|
||||
int((time.monotonic() - filter_start) * 1000),
|
||||
)
|
||||
|
||||
free_runs = [row for row in run_rows if row.tenant_id in free_tenants]
|
||||
paid_or_skipped = len(run_rows) - len(free_runs)
|
||||
|
||||
if not free_runs:
|
||||
skipped_message = (
|
||||
f"[batch #{batch_index}] skipped (no sandbox runs in batch, {paid_or_skipped} paid/unknown)"
|
||||
fetch_start = time.monotonic()
|
||||
run_rows = self.workflow_run_repo.get_runs_batch_by_time_range(
|
||||
start_from=self.window_start,
|
||||
end_before=self.window_end,
|
||||
last_seen=last_seen,
|
||||
batch_size=self.batch_size,
|
||||
)
|
||||
click.echo(
|
||||
click.style(
|
||||
skipped_message,
|
||||
fg="yellow",
|
||||
)
|
||||
)
|
||||
continue
|
||||
if not run_rows:
|
||||
logger.info("workflow_run_cleanup (batch #%s): no more rows to process", batch_index + 1)
|
||||
break
|
||||
|
||||
total_runs_targeted += len(free_runs)
|
||||
|
||||
if self.dry_run:
|
||||
count_start = time.monotonic()
|
||||
batch_counts = self.workflow_run_repo.count_runs_with_related(
|
||||
free_runs,
|
||||
count_node_executions=self._count_node_executions,
|
||||
count_trigger_logs=self._count_trigger_logs,
|
||||
)
|
||||
batch_index += 1
|
||||
last_seen = (run_rows[-1].created_at, run_rows[-1].id)
|
||||
logger.info(
|
||||
"workflow_run_cleanup (batch #%s, dry_run): counted related records in %sms",
|
||||
"workflow_run_cleanup (batch #%s): fetched %s rows in %sms",
|
||||
batch_index,
|
||||
int((time.monotonic() - count_start) * 1000),
|
||||
len(run_rows),
|
||||
int((time.monotonic() - fetch_start) * 1000),
|
||||
)
|
||||
if related_totals is not None:
|
||||
for key in related_totals:
|
||||
related_totals[key] += batch_counts.get(key, 0)
|
||||
sample_ids = ", ".join(run.id for run in free_runs[:5])
|
||||
|
||||
tenant_ids = {row.tenant_id for row in run_rows}
|
||||
|
||||
filter_start = time.monotonic()
|
||||
free_tenants = self._filter_free_tenants(tenant_ids)
|
||||
logger.info(
|
||||
"workflow_run_cleanup (batch #%s): filtered %s free tenants from %s tenants in %sms",
|
||||
batch_index,
|
||||
len(free_tenants),
|
||||
len(tenant_ids),
|
||||
int((time.monotonic() - filter_start) * 1000),
|
||||
)
|
||||
|
||||
free_runs = [row for row in run_rows if row.tenant_id in free_tenants]
|
||||
paid_or_skipped = len(run_rows) - len(free_runs)
|
||||
|
||||
if not free_runs:
|
||||
skipped_message = (
|
||||
f"[batch #{batch_index}] skipped (no sandbox runs in batch, {paid_or_skipped} paid/unknown)"
|
||||
)
|
||||
click.echo(
|
||||
click.style(
|
||||
skipped_message,
|
||||
fg="yellow",
|
||||
)
|
||||
)
|
||||
self._metrics.record_batch(
|
||||
batch_rows=len(run_rows),
|
||||
targeted_runs=0,
|
||||
skipped_runs=paid_or_skipped,
|
||||
deleted_runs=0,
|
||||
related_counts=None,
|
||||
related_action=None,
|
||||
batch_duration_seconds=time.monotonic() - batch_start,
|
||||
)
|
||||
continue
|
||||
|
||||
total_runs_targeted += len(free_runs)
|
||||
|
||||
if self.dry_run:
|
||||
count_start = time.monotonic()
|
||||
batch_counts = self.workflow_run_repo.count_runs_with_related(
|
||||
free_runs,
|
||||
count_node_executions=self._count_node_executions,
|
||||
count_trigger_logs=self._count_trigger_logs,
|
||||
)
|
||||
logger.info(
|
||||
"workflow_run_cleanup (batch #%s, dry_run): counted related records in %sms",
|
||||
batch_index,
|
||||
int((time.monotonic() - count_start) * 1000),
|
||||
)
|
||||
if related_totals is not None:
|
||||
for key in related_totals:
|
||||
related_totals[key] += batch_counts.get(key, 0)
|
||||
sample_ids = ", ".join(run.id for run in free_runs[:5])
|
||||
click.echo(
|
||||
click.style(
|
||||
f"[batch #{batch_index}] would delete {len(free_runs)} runs "
|
||||
f"(sample ids: {sample_ids}) and skip {paid_or_skipped} paid/unknown",
|
||||
fg="yellow",
|
||||
)
|
||||
)
|
||||
logger.info(
|
||||
"workflow_run_cleanup (batch #%s, dry_run): batch total %sms",
|
||||
batch_index,
|
||||
int((time.monotonic() - batch_start) * 1000),
|
||||
)
|
||||
self._metrics.record_batch(
|
||||
batch_rows=len(run_rows),
|
||||
targeted_runs=len(free_runs),
|
||||
skipped_runs=paid_or_skipped,
|
||||
deleted_runs=0,
|
||||
related_counts={key: batch_counts.get(key, 0) for key in self._empty_related_counts()},
|
||||
related_action="would_delete",
|
||||
batch_duration_seconds=time.monotonic() - batch_start,
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
delete_start = time.monotonic()
|
||||
counts = self.workflow_run_repo.delete_runs_with_related(
|
||||
free_runs,
|
||||
delete_node_executions=self._delete_node_executions,
|
||||
delete_trigger_logs=self._delete_trigger_logs,
|
||||
)
|
||||
delete_ms = int((time.monotonic() - delete_start) * 1000)
|
||||
except Exception:
|
||||
logger.exception("Failed to delete workflow runs batch ending at %s", last_seen[0])
|
||||
raise
|
||||
|
||||
total_runs_deleted += counts["runs"]
|
||||
click.echo(
|
||||
click.style(
|
||||
f"[batch #{batch_index}] would delete {len(free_runs)} runs "
|
||||
f"(sample ids: {sample_ids}) and skip {paid_or_skipped} paid/unknown",
|
||||
fg="yellow",
|
||||
f"[batch #{batch_index}] deleted runs: {counts['runs']} "
|
||||
f"(nodes {counts['node_executions']}, offloads {counts['offloads']}, "
|
||||
f"app_logs {counts['app_logs']}, trigger_logs {counts['trigger_logs']}, "
|
||||
f"pauses {counts['pauses']}, pause_reasons {counts['pause_reasons']}); "
|
||||
f"skipped {paid_or_skipped} paid/unknown",
|
||||
fg="green",
|
||||
)
|
||||
)
|
||||
logger.info(
|
||||
"workflow_run_cleanup (batch #%s, dry_run): batch total %sms",
|
||||
"workflow_run_cleanup (batch #%s): delete %sms, batch total %sms",
|
||||
batch_index,
|
||||
delete_ms,
|
||||
int((time.monotonic() - batch_start) * 1000),
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
delete_start = time.monotonic()
|
||||
counts = self.workflow_run_repo.delete_runs_with_related(
|
||||
free_runs,
|
||||
delete_node_executions=self._delete_node_executions,
|
||||
delete_trigger_logs=self._delete_trigger_logs,
|
||||
self._metrics.record_batch(
|
||||
batch_rows=len(run_rows),
|
||||
targeted_runs=len(free_runs),
|
||||
skipped_runs=paid_or_skipped,
|
||||
deleted_runs=counts["runs"],
|
||||
related_counts={key: counts.get(key, 0) for key in self._empty_related_counts()},
|
||||
related_action="deleted",
|
||||
batch_duration_seconds=time.monotonic() - batch_start,
|
||||
)
|
||||
delete_ms = int((time.monotonic() - delete_start) * 1000)
|
||||
except Exception:
|
||||
logger.exception("Failed to delete workflow runs batch ending at %s", last_seen[0])
|
||||
raise
|
||||
|
||||
total_runs_deleted += counts["runs"]
|
||||
click.echo(
|
||||
click.style(
|
||||
f"[batch #{batch_index}] deleted runs: {counts['runs']} "
|
||||
f"(nodes {counts['node_executions']}, offloads {counts['offloads']}, "
|
||||
f"app_logs {counts['app_logs']}, trigger_logs {counts['trigger_logs']}, "
|
||||
f"pauses {counts['pauses']}, pause_reasons {counts['pause_reasons']}); "
|
||||
f"skipped {paid_or_skipped} paid/unknown",
|
||||
fg="green",
|
||||
)
|
||||
)
|
||||
logger.info(
|
||||
"workflow_run_cleanup (batch #%s): delete %sms, batch total %sms",
|
||||
batch_index,
|
||||
delete_ms,
|
||||
int((time.monotonic() - batch_start) * 1000),
|
||||
)
|
||||
# Random sleep between batches to avoid overwhelming the database
|
||||
sleep_ms = random.uniform(0, max_batch_interval_ms) # noqa: S311
|
||||
logger.info("workflow_run_cleanup (batch #%s): sleeping for %.2fms", batch_index, sleep_ms)
|
||||
time.sleep(sleep_ms / 1000)
|
||||
|
||||
# Random sleep between batches to avoid overwhelming the database
|
||||
sleep_ms = random.uniform(0, max_batch_interval_ms) # noqa: S311
|
||||
logger.info("workflow_run_cleanup (batch #%s): sleeping for %.2fms", batch_index, sleep_ms)
|
||||
time.sleep(sleep_ms / 1000)
|
||||
|
||||
if self.dry_run:
|
||||
if self.window_start:
|
||||
summary_message = (
|
||||
f"Dry run complete. Would delete {total_runs_targeted} workflow runs "
|
||||
f"between {self.window_start.isoformat()} and {self.window_end.isoformat()}"
|
||||
)
|
||||
if self.dry_run:
|
||||
if self.window_start:
|
||||
summary_message = (
|
||||
f"Dry run complete. Would delete {total_runs_targeted} workflow runs "
|
||||
f"between {self.window_start.isoformat()} and {self.window_end.isoformat()}"
|
||||
)
|
||||
else:
|
||||
summary_message = (
|
||||
f"Dry run complete. Would delete {total_runs_targeted} workflow runs "
|
||||
f"before {self.window_end.isoformat()}"
|
||||
)
|
||||
if related_totals is not None:
|
||||
summary_message = (
|
||||
f"{summary_message}; related records: {self._format_related_counts(related_totals)}"
|
||||
)
|
||||
summary_color = "yellow"
|
||||
else:
|
||||
summary_message = (
|
||||
f"Dry run complete. Would delete {total_runs_targeted} workflow runs "
|
||||
f"before {self.window_end.isoformat()}"
|
||||
)
|
||||
if related_totals is not None:
|
||||
summary_message = f"{summary_message}; related records: {self._format_related_counts(related_totals)}"
|
||||
summary_color = "yellow"
|
||||
else:
|
||||
if self.window_start:
|
||||
summary_message = (
|
||||
f"Cleanup complete. Deleted {total_runs_deleted} workflow runs "
|
||||
f"between {self.window_start.isoformat()} and {self.window_end.isoformat()}"
|
||||
)
|
||||
else:
|
||||
summary_message = (
|
||||
f"Cleanup complete. Deleted {total_runs_deleted} workflow runs before {self.window_end.isoformat()}"
|
||||
)
|
||||
summary_color = "white"
|
||||
if self.window_start:
|
||||
summary_message = (
|
||||
f"Cleanup complete. Deleted {total_runs_deleted} workflow runs "
|
||||
f"between {self.window_start.isoformat()} and {self.window_end.isoformat()}"
|
||||
)
|
||||
else:
|
||||
summary_message = (
|
||||
f"Cleanup complete. Deleted {total_runs_deleted} workflow runs "
|
||||
f"before {self.window_end.isoformat()}"
|
||||
)
|
||||
summary_color = "white"
|
||||
|
||||
click.echo(click.style(summary_message, fg=summary_color))
|
||||
click.echo(click.style(summary_message, fg=summary_color))
|
||||
except Exception:
|
||||
status = "failed"
|
||||
raise
|
||||
finally:
|
||||
self._metrics.record_completion(
|
||||
status=status,
|
||||
job_duration_seconds=time.monotonic() - run_start,
|
||||
)
|
||||
|
||||
def _filter_free_tenants(self, tenant_ids: Iterable[str]) -> set[str]:
|
||||
tenant_id_list = list(tenant_ids)
|
||||
|
||||
188
api/tests/unit_tests/commands/test_clean_expired_messages.py
Normal file
188
api/tests/unit_tests/commands/test_clean_expired_messages.py
Normal file
@@ -0,0 +1,188 @@
|
||||
import datetime
|
||||
import re
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import click
|
||||
import pytest
|
||||
|
||||
from commands import clean_expired_messages
|
||||
|
||||
|
||||
def _mock_service() -> MagicMock:
|
||||
service = MagicMock()
|
||||
service.run.return_value = {
|
||||
"batches": 1,
|
||||
"total_messages": 10,
|
||||
"filtered_messages": 5,
|
||||
"total_deleted": 5,
|
||||
}
|
||||
return service
|
||||
|
||||
|
||||
def test_absolute_mode_calls_from_time_range():
|
||||
policy = object()
|
||||
service = _mock_service()
|
||||
start_from = datetime.datetime(2024, 1, 1, 0, 0, 0)
|
||||
end_before = datetime.datetime(2024, 2, 1, 0, 0, 0)
|
||||
|
||||
with (
|
||||
patch("commands.create_message_clean_policy", return_value=policy),
|
||||
patch("commands.MessagesCleanService.from_time_range", return_value=service) as mock_from_time_range,
|
||||
patch("commands.MessagesCleanService.from_days") as mock_from_days,
|
||||
):
|
||||
clean_expired_messages.callback(
|
||||
batch_size=200,
|
||||
graceful_period=21,
|
||||
start_from=start_from,
|
||||
end_before=end_before,
|
||||
from_days_ago=None,
|
||||
before_days=None,
|
||||
dry_run=True,
|
||||
task_label="daily",
|
||||
)
|
||||
|
||||
mock_from_time_range.assert_called_once_with(
|
||||
policy=policy,
|
||||
start_from=start_from,
|
||||
end_before=end_before,
|
||||
batch_size=200,
|
||||
dry_run=True,
|
||||
task_label="daily",
|
||||
)
|
||||
mock_from_days.assert_not_called()
|
||||
|
||||
|
||||
def test_relative_mode_before_days_only_calls_from_days():
|
||||
policy = object()
|
||||
service = _mock_service()
|
||||
|
||||
with (
|
||||
patch("commands.create_message_clean_policy", return_value=policy),
|
||||
patch("commands.MessagesCleanService.from_days", return_value=service) as mock_from_days,
|
||||
patch("commands.MessagesCleanService.from_time_range") as mock_from_time_range,
|
||||
):
|
||||
clean_expired_messages.callback(
|
||||
batch_size=500,
|
||||
graceful_period=14,
|
||||
start_from=None,
|
||||
end_before=None,
|
||||
from_days_ago=None,
|
||||
before_days=30,
|
||||
dry_run=False,
|
||||
task_label="daily",
|
||||
)
|
||||
|
||||
mock_from_days.assert_called_once_with(
|
||||
policy=policy,
|
||||
days=30,
|
||||
batch_size=500,
|
||||
dry_run=False,
|
||||
task_label="daily",
|
||||
)
|
||||
mock_from_time_range.assert_not_called()
|
||||
|
||||
|
||||
def test_relative_mode_with_from_days_ago_calls_from_time_range():
|
||||
policy = object()
|
||||
service = _mock_service()
|
||||
fixed_now = datetime.datetime(2024, 8, 20, 12, 0, 0)
|
||||
|
||||
with (
|
||||
patch("commands.create_message_clean_policy", return_value=policy),
|
||||
patch("commands.MessagesCleanService.from_time_range", return_value=service) as mock_from_time_range,
|
||||
patch("commands.MessagesCleanService.from_days") as mock_from_days,
|
||||
patch("commands.naive_utc_now", return_value=fixed_now),
|
||||
):
|
||||
clean_expired_messages.callback(
|
||||
batch_size=1000,
|
||||
graceful_period=21,
|
||||
start_from=None,
|
||||
end_before=None,
|
||||
from_days_ago=60,
|
||||
before_days=30,
|
||||
dry_run=False,
|
||||
task_label="daily",
|
||||
)
|
||||
|
||||
mock_from_time_range.assert_called_once_with(
|
||||
policy=policy,
|
||||
start_from=fixed_now - datetime.timedelta(days=60),
|
||||
end_before=fixed_now - datetime.timedelta(days=30),
|
||||
batch_size=1000,
|
||||
dry_run=False,
|
||||
task_label="daily",
|
||||
)
|
||||
mock_from_days.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("kwargs", "message"),
|
||||
[
|
||||
(
|
||||
{
|
||||
"start_from": datetime.datetime(2024, 1, 1),
|
||||
"end_before": datetime.datetime(2024, 2, 1),
|
||||
"from_days_ago": None,
|
||||
"before_days": 30,
|
||||
},
|
||||
"mutually exclusive",
|
||||
),
|
||||
(
|
||||
{
|
||||
"start_from": datetime.datetime(2024, 1, 1),
|
||||
"end_before": None,
|
||||
"from_days_ago": None,
|
||||
"before_days": None,
|
||||
},
|
||||
"Both --start-from and --end-before are required",
|
||||
),
|
||||
(
|
||||
{
|
||||
"start_from": None,
|
||||
"end_before": None,
|
||||
"from_days_ago": 10,
|
||||
"before_days": None,
|
||||
},
|
||||
"--from-days-ago must be used together with --before-days",
|
||||
),
|
||||
(
|
||||
{
|
||||
"start_from": None,
|
||||
"end_before": None,
|
||||
"from_days_ago": None,
|
||||
"before_days": -1,
|
||||
},
|
||||
"--before-days must be >= 0",
|
||||
),
|
||||
(
|
||||
{
|
||||
"start_from": None,
|
||||
"end_before": None,
|
||||
"from_days_ago": 30,
|
||||
"before_days": 30,
|
||||
},
|
||||
"--from-days-ago must be greater than --before-days",
|
||||
),
|
||||
(
|
||||
{
|
||||
"start_from": None,
|
||||
"end_before": None,
|
||||
"from_days_ago": None,
|
||||
"before_days": None,
|
||||
},
|
||||
"You must provide either (--start-from,--end-before) or (--before-days [--from-days-ago])",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_invalid_inputs_raise_usage_error(kwargs: dict, message: str):
|
||||
with pytest.raises(click.UsageError, match=re.escape(message)):
|
||||
clean_expired_messages.callback(
|
||||
batch_size=1000,
|
||||
graceful_period=21,
|
||||
start_from=kwargs["start_from"],
|
||||
end_before=kwargs["end_before"],
|
||||
from_days_ago=kwargs["from_days_ago"],
|
||||
before_days=kwargs["before_days"],
|
||||
dry_run=False,
|
||||
task_label="daily",
|
||||
)
|
||||
@@ -8,7 +8,9 @@ from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
|
||||
from dify_graph.enums import WorkflowNodeExecutionStatus
|
||||
from dify_graph.model_runtime.entities.llm_entities import LLMUsage
|
||||
from dify_graph.nodes.knowledge_retrieval.entities import (
|
||||
Condition,
|
||||
KnowledgeRetrievalNodeData,
|
||||
MetadataFilteringCondition,
|
||||
MultipleRetrievalConfig,
|
||||
RerankingModelConfig,
|
||||
SingleRetrievalConfig,
|
||||
@@ -593,3 +595,106 @@ class TestFetchDatasetRetriever:
|
||||
|
||||
# Assert
|
||||
assert version == "1"
|
||||
|
||||
def test_resolve_metadata_filtering_conditions_templates(
|
||||
self,
|
||||
mock_graph_init_params,
|
||||
mock_graph_runtime_state,
|
||||
mock_rag_retrieval,
|
||||
):
|
||||
"""_resolve_metadata_filtering_conditions should expand {{#...#}} and keep numbers/None unchanged."""
|
||||
# Arrange
|
||||
node_id = str(uuid.uuid4())
|
||||
config = {
|
||||
"id": node_id,
|
||||
"data": {
|
||||
"title": "Knowledge Retrieval",
|
||||
"type": "knowledge-retrieval",
|
||||
"dataset_ids": [str(uuid.uuid4())],
|
||||
"retrieval_mode": "multiple",
|
||||
},
|
||||
}
|
||||
# Variable in pool used by template
|
||||
mock_graph_runtime_state.variable_pool.add(["start", "query"], StringSegment(value="readme"))
|
||||
|
||||
node = KnowledgeRetrievalNode(
|
||||
id=node_id,
|
||||
config=config,
|
||||
graph_init_params=mock_graph_init_params,
|
||||
graph_runtime_state=mock_graph_runtime_state,
|
||||
rag_retrieval=mock_rag_retrieval,
|
||||
)
|
||||
|
||||
conditions = MetadataFilteringCondition(
|
||||
logical_operator="and",
|
||||
conditions=[
|
||||
Condition(name="document_name", comparison_operator="is", value="{{#start.query#}}"),
|
||||
Condition(name="tags", comparison_operator="in", value=["x", "{{#start.query#}}"]),
|
||||
Condition(name="year", comparison_operator="=", value=2025),
|
||||
],
|
||||
)
|
||||
|
||||
# Act
|
||||
resolved = node._resolve_metadata_filtering_conditions(conditions)
|
||||
|
||||
# Assert
|
||||
assert resolved.logical_operator == "and"
|
||||
assert resolved.conditions[0].value == "readme"
|
||||
assert isinstance(resolved.conditions[1].value, list)
|
||||
assert resolved.conditions[1].value[1] == "readme"
|
||||
assert resolved.conditions[2].value == 2025
|
||||
|
||||
def test_fetch_passes_resolved_metadata_conditions(
|
||||
self,
|
||||
mock_graph_init_params,
|
||||
mock_graph_runtime_state,
|
||||
mock_rag_retrieval,
|
||||
):
|
||||
"""_fetch_dataset_retriever should pass resolved metadata conditions into request."""
|
||||
# Arrange
|
||||
query = "hi"
|
||||
variables = {"query": query}
|
||||
mock_graph_runtime_state.variable_pool.add(["start", "q"], StringSegment(value="readme"))
|
||||
|
||||
node_data = KnowledgeRetrievalNodeData(
|
||||
title="Knowledge Retrieval",
|
||||
type="knowledge-retrieval",
|
||||
dataset_ids=[str(uuid.uuid4())],
|
||||
retrieval_mode="multiple",
|
||||
multiple_retrieval_config=MultipleRetrievalConfig(
|
||||
top_k=4,
|
||||
score_threshold=0.0,
|
||||
reranking_mode="reranking_model",
|
||||
reranking_enable=True,
|
||||
reranking_model=RerankingModelConfig(provider="cohere", model="rerank-v2"),
|
||||
),
|
||||
metadata_filtering_mode="manual",
|
||||
metadata_filtering_conditions=MetadataFilteringCondition(
|
||||
logical_operator="and",
|
||||
conditions=[
|
||||
Condition(name="document_name", comparison_operator="is", value="{{#start.q#}}"),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
node_id = str(uuid.uuid4())
|
||||
config = {"id": node_id, "data": node_data.model_dump()}
|
||||
node = KnowledgeRetrievalNode(
|
||||
id=node_id,
|
||||
config=config,
|
||||
graph_init_params=mock_graph_init_params,
|
||||
graph_runtime_state=mock_graph_runtime_state,
|
||||
rag_retrieval=mock_rag_retrieval,
|
||||
)
|
||||
|
||||
mock_rag_retrieval.knowledge_retrieval.return_value = []
|
||||
mock_rag_retrieval.llm_usage = LLMUsage.empty_usage()
|
||||
|
||||
# Act
|
||||
node._fetch_dataset_retriever(node_data=node_data, variables=variables)
|
||||
|
||||
# Assert the passed request has resolved value
|
||||
call_args = mock_rag_retrieval.knowledge_retrieval.call_args
|
||||
request = call_args[1]["request"]
|
||||
assert request.metadata_filtering_conditions is not None
|
||||
assert request.metadata_filtering_conditions.conditions[0].value == "readme"
|
||||
|
||||
@@ -16,6 +16,7 @@ from dify_graph.nodes.document_extractor.node import (
|
||||
_extract_text_from_excel,
|
||||
_extract_text_from_pdf,
|
||||
_extract_text_from_plain_text,
|
||||
_normalize_docx_zip,
|
||||
)
|
||||
from dify_graph.variables import ArrayFileSegment
|
||||
from dify_graph.variables.segments import ArrayStringSegment
|
||||
@@ -86,6 +87,38 @@ def test_run_invalid_variable_type(document_extractor_node, mock_graph_runtime_s
|
||||
assert "is not an ArrayFileSegment" in result.error
|
||||
|
||||
|
||||
def test_run_empty_file_list_returns_succeeded(document_extractor_node, mock_graph_runtime_state):
|
||||
"""Empty file list should return SUCCEEDED with empty documents and ArrayStringSegment([])."""
|
||||
document_extractor_node.graph_runtime_state = mock_graph_runtime_state
|
||||
|
||||
# Provide an actual ArrayFileSegment with an empty list
|
||||
mock_graph_runtime_state.variable_pool.get.return_value = ArrayFileSegment(value=[])
|
||||
|
||||
result = document_extractor_node._run()
|
||||
|
||||
assert isinstance(result, NodeRunResult)
|
||||
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED, result.error
|
||||
assert result.process_data.get("documents") == []
|
||||
assert result.outputs["text"] == ArrayStringSegment(value=[])
|
||||
|
||||
|
||||
def test_run_none_only_file_list_returns_succeeded(document_extractor_node, mock_graph_runtime_state):
|
||||
"""A file list containing only None (e.g., [None]) should be filtered to [] and succeed."""
|
||||
document_extractor_node.graph_runtime_state = mock_graph_runtime_state
|
||||
|
||||
# Use a Mock to bypass type validation for None entries in the list
|
||||
afs = Mock(spec=ArrayFileSegment)
|
||||
afs.value = [None]
|
||||
mock_graph_runtime_state.variable_pool.get.return_value = afs
|
||||
|
||||
result = document_extractor_node._run()
|
||||
|
||||
assert isinstance(result, NodeRunResult)
|
||||
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED, result.error
|
||||
assert result.process_data.get("documents") == []
|
||||
assert result.outputs["text"] == ArrayStringSegment(value=[])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mime_type", "file_content", "expected_text", "transfer_method", "extension"),
|
||||
[
|
||||
@@ -385,3 +418,58 @@ def test_extract_text_from_excel_numeric_type_column(mock_excel_file):
|
||||
expected_manual = "| 1.0 | 1.1 |\n| --- | --- |\n| Test | Test |\n\n"
|
||||
|
||||
assert expected_manual == result
|
||||
|
||||
|
||||
def _make_docx_zip(use_backslash: bool) -> bytes:
|
||||
"""Helper to build a minimal in-memory DOCX zip.
|
||||
|
||||
When use_backslash=True the ZIP entry names use backslash separators
|
||||
(as produced by Evernote on Windows), otherwise forward slashes are used.
|
||||
"""
|
||||
import zipfile
|
||||
|
||||
sep = "\\" if use_backslash else "/"
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr("[Content_Types].xml", b"<Types/>")
|
||||
zf.writestr(f"_rels{sep}.rels", b"<Relationships/>")
|
||||
zf.writestr(f"word{sep}document.xml", b"<w:document/>")
|
||||
zf.writestr(f"word{sep}_rels{sep}document.xml.rels", b"<Relationships/>")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_normalize_docx_zip_replaces_backslashes():
|
||||
"""ZIP entries with backslash separators must be rewritten to forward slashes."""
|
||||
import zipfile
|
||||
|
||||
malformed = _make_docx_zip(use_backslash=True)
|
||||
fixed = _normalize_docx_zip(malformed)
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(fixed)) as zf:
|
||||
names = zf.namelist()
|
||||
|
||||
assert "word/document.xml" in names
|
||||
assert "word/_rels/document.xml.rels" in names
|
||||
# No entry should contain a backslash after normalization
|
||||
assert all("\\" not in name for name in names)
|
||||
|
||||
|
||||
def test_normalize_docx_zip_leaves_forward_slash_unchanged():
|
||||
"""ZIP entries that already use forward slashes must not be modified."""
|
||||
import zipfile
|
||||
|
||||
normal = _make_docx_zip(use_backslash=False)
|
||||
fixed = _normalize_docx_zip(normal)
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(fixed)) as zf:
|
||||
names = zf.namelist()
|
||||
|
||||
assert "word/document.xml" in names
|
||||
assert "word/_rels/document.xml.rels" in names
|
||||
|
||||
|
||||
def test_normalize_docx_zip_returns_original_on_bad_zip():
|
||||
"""Non-zip bytes must be returned as-is without raising."""
|
||||
garbage = b"not a zip file at all"
|
||||
result = _normalize_docx_zip(garbage)
|
||||
assert result == garbage
|
||||
|
||||
@@ -265,6 +265,61 @@ def test_run_exits_on_empty_batch(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
cleanup.run()
|
||||
|
||||
|
||||
def test_run_records_metrics_on_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
cutoff = datetime.datetime.now()
|
||||
repo = FakeRepo(
|
||||
batches=[[FakeRun("run-free", "t_free", cutoff)]],
|
||||
delete_result={
|
||||
"runs": 0,
|
||||
"node_executions": 2,
|
||||
"offloads": 1,
|
||||
"app_logs": 3,
|
||||
"trigger_logs": 4,
|
||||
"pauses": 5,
|
||||
"pause_reasons": 6,
|
||||
},
|
||||
)
|
||||
cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=10)
|
||||
monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", False)
|
||||
|
||||
batch_calls: list[dict[str, object]] = []
|
||||
completion_calls: list[dict[str, object]] = []
|
||||
monkeypatch.setattr(cleanup._metrics, "record_batch", lambda **kwargs: batch_calls.append(kwargs))
|
||||
monkeypatch.setattr(cleanup._metrics, "record_completion", lambda **kwargs: completion_calls.append(kwargs))
|
||||
|
||||
cleanup.run()
|
||||
|
||||
assert len(batch_calls) == 1
|
||||
assert batch_calls[0]["batch_rows"] == 1
|
||||
assert batch_calls[0]["targeted_runs"] == 1
|
||||
assert batch_calls[0]["deleted_runs"] == 1
|
||||
assert batch_calls[0]["related_action"] == "deleted"
|
||||
assert len(completion_calls) == 1
|
||||
assert completion_calls[0]["status"] == "success"
|
||||
|
||||
|
||||
def test_run_records_failed_metrics(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class FailingRepo(FakeRepo):
|
||||
def delete_runs_with_related(
|
||||
self, runs: list[FakeRun], delete_node_executions=None, delete_trigger_logs=None
|
||||
) -> dict[str, int]:
|
||||
raise RuntimeError("delete failed")
|
||||
|
||||
cutoff = datetime.datetime.now()
|
||||
repo = FailingRepo(batches=[[FakeRun("run-free", "t_free", cutoff)]])
|
||||
cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=10)
|
||||
monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", False)
|
||||
|
||||
completion_calls: list[dict[str, object]] = []
|
||||
monkeypatch.setattr(cleanup._metrics, "record_completion", lambda **kwargs: completion_calls.append(kwargs))
|
||||
|
||||
with pytest.raises(RuntimeError, match="delete failed"):
|
||||
cleanup.run()
|
||||
|
||||
assert len(completion_calls) == 1
|
||||
assert completion_calls[0]["status"] == "failed"
|
||||
|
||||
|
||||
def test_run_dry_run_skips_deletions(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
cutoff = datetime.datetime.now()
|
||||
repo = FakeRepo(
|
||||
|
||||
@@ -554,11 +554,9 @@ class TestMessagesCleanServiceFromDays:
|
||||
MessagesCleanService.from_days(policy=policy, days=-1)
|
||||
|
||||
# Act
|
||||
with patch("services.retention.conversation.messages_clean_service.datetime", autospec=True) as mock_datetime:
|
||||
with patch("services.retention.conversation.messages_clean_service.naive_utc_now") as mock_now:
|
||||
fixed_now = datetime.datetime(2024, 6, 15, 14, 0, 0)
|
||||
mock_datetime.datetime.now.return_value = fixed_now
|
||||
mock_datetime.timedelta = datetime.timedelta
|
||||
|
||||
mock_now.return_value = fixed_now
|
||||
service = MessagesCleanService.from_days(policy=policy, days=0)
|
||||
|
||||
# Assert
|
||||
@@ -586,11 +584,9 @@ class TestMessagesCleanServiceFromDays:
|
||||
dry_run = True
|
||||
|
||||
# Act
|
||||
with patch("services.retention.conversation.messages_clean_service.datetime", autospec=True) as mock_datetime:
|
||||
with patch("services.retention.conversation.messages_clean_service.naive_utc_now") as mock_now:
|
||||
fixed_now = datetime.datetime(2024, 6, 15, 10, 30, 0)
|
||||
mock_datetime.datetime.now.return_value = fixed_now
|
||||
mock_datetime.timedelta = datetime.timedelta
|
||||
|
||||
mock_now.return_value = fixed_now
|
||||
service = MessagesCleanService.from_days(
|
||||
policy=policy,
|
||||
days=days,
|
||||
@@ -613,11 +609,9 @@ class TestMessagesCleanServiceFromDays:
|
||||
policy = BillingDisabledPolicy()
|
||||
|
||||
# Act
|
||||
with patch("services.retention.conversation.messages_clean_service.datetime", autospec=True) as mock_datetime:
|
||||
with patch("services.retention.conversation.messages_clean_service.naive_utc_now") as mock_now:
|
||||
fixed_now = datetime.datetime(2024, 6, 15, 10, 30, 0)
|
||||
mock_datetime.datetime.now.return_value = fixed_now
|
||||
mock_datetime.timedelta = datetime.timedelta
|
||||
|
||||
mock_now.return_value = fixed_now
|
||||
service = MessagesCleanService.from_days(policy=policy)
|
||||
|
||||
# Assert
|
||||
@@ -625,3 +619,53 @@ class TestMessagesCleanServiceFromDays:
|
||||
assert service._end_before == expected_end_before
|
||||
assert service._batch_size == 1000 # default
|
||||
assert service._dry_run is False # default
|
||||
|
||||
|
||||
class TestMessagesCleanServiceRun:
|
||||
"""Unit tests for MessagesCleanService.run instrumentation behavior."""
|
||||
|
||||
def test_run_records_completion_metrics_on_success(self):
|
||||
# Arrange
|
||||
service = MessagesCleanService(
|
||||
policy=BillingDisabledPolicy(),
|
||||
start_from=datetime.datetime(2024, 1, 1),
|
||||
end_before=datetime.datetime(2024, 1, 2),
|
||||
batch_size=100,
|
||||
dry_run=False,
|
||||
)
|
||||
expected_stats = {
|
||||
"batches": 1,
|
||||
"total_messages": 10,
|
||||
"filtered_messages": 5,
|
||||
"total_deleted": 5,
|
||||
}
|
||||
service._clean_messages_by_time_range = MagicMock(return_value=expected_stats) # type: ignore[method-assign]
|
||||
completion_calls: list[dict[str, object]] = []
|
||||
service._metrics.record_completion = lambda **kwargs: completion_calls.append(kwargs) # type: ignore[method-assign]
|
||||
|
||||
# Act
|
||||
result = service.run()
|
||||
|
||||
# Assert
|
||||
assert result == expected_stats
|
||||
assert len(completion_calls) == 1
|
||||
assert completion_calls[0]["status"] == "success"
|
||||
|
||||
def test_run_records_completion_metrics_on_failure(self):
|
||||
# Arrange
|
||||
service = MessagesCleanService(
|
||||
policy=BillingDisabledPolicy(),
|
||||
start_from=datetime.datetime(2024, 1, 1),
|
||||
end_before=datetime.datetime(2024, 1, 2),
|
||||
batch_size=100,
|
||||
dry_run=False,
|
||||
)
|
||||
service._clean_messages_by_time_range = MagicMock(side_effect=RuntimeError("clean failed")) # type: ignore[method-assign]
|
||||
completion_calls: list[dict[str, object]] = []
|
||||
service._metrics.record_completion = lambda **kwargs: completion_calls.append(kwargs) # type: ignore[method-assign]
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(RuntimeError, match="clean failed"):
|
||||
service.run()
|
||||
assert len(completion_calls) == 1
|
||||
assert completion_calls[0]["status"] == "failed"
|
||||
|
||||
@@ -6,6 +6,13 @@ from typing import Any
|
||||
|
||||
|
||||
class ConfigHelper:
|
||||
_LEGACY_SECTION_MAP = {
|
||||
"admin_config": "admin",
|
||||
"token_config": "auth",
|
||||
"app_config": "app",
|
||||
"api_key_config": "api_key",
|
||||
}
|
||||
|
||||
"""Helper class for reading and writing configuration files."""
|
||||
|
||||
def __init__(self, base_dir: Path | None = None):
|
||||
@@ -50,14 +57,8 @@ class ConfigHelper:
|
||||
Dictionary containing config data, or None if file doesn't exist
|
||||
"""
|
||||
# Provide backward compatibility for old config names
|
||||
if filename in ["admin_config", "token_config", "app_config", "api_key_config"]:
|
||||
section_map = {
|
||||
"admin_config": "admin",
|
||||
"token_config": "auth",
|
||||
"app_config": "app",
|
||||
"api_key_config": "api_key",
|
||||
}
|
||||
return self.get_state_section(section_map[filename])
|
||||
if filename in self._LEGACY_SECTION_MAP:
|
||||
return self.get_state_section(self._LEGACY_SECTION_MAP[filename])
|
||||
|
||||
config_path = self.get_config_path(filename)
|
||||
|
||||
@@ -85,14 +86,11 @@ class ConfigHelper:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
# Provide backward compatibility for old config names
|
||||
if filename in ["admin_config", "token_config", "app_config", "api_key_config"]:
|
||||
section_map = {
|
||||
"admin_config": "admin",
|
||||
"token_config": "auth",
|
||||
"app_config": "app",
|
||||
"api_key_config": "api_key",
|
||||
}
|
||||
return self.update_state_section(section_map[filename], data)
|
||||
if filename in self._LEGACY_SECTION_MAP:
|
||||
return self.update_state_section(
|
||||
self._LEGACY_SECTION_MAP[filename],
|
||||
data,
|
||||
)
|
||||
|
||||
self.ensure_config_dir()
|
||||
config_path = self.get_config_path(filename)
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
- Refer to the `./docs/test.md` and `./docs/lint.md` for detailed frontend workflow instructions.
|
||||
|
||||
## Overlay Components (Mandatory)
|
||||
|
||||
- `./docs/overlay-migration.md` is the source of truth for overlay-related work.
|
||||
- In new or modified code, use only overlay primitives from `@/app/components/base/ui/*`.
|
||||
- Do not introduce deprecated overlay imports from `@/app/components/base/*`; when touching legacy callers, prefer migrating them and keep the allowlist shrinking (never expanding).
|
||||
|
||||
## Automated Test Generation
|
||||
|
||||
- Use `./docs/test.md` as the canonical instruction set for generating frontend automated tests.
|
||||
|
||||
257
web/app/components/base/ui/context-menu/__tests__/index.spec.tsx
Normal file
257
web/app/components/base/ui/context-menu/__tests__/index.spec.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuLinkItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger,
|
||||
} from '../index'
|
||||
|
||||
describe('context-menu wrapper', () => {
|
||||
describe('ContextMenuContent', () => {
|
||||
it('should position content at bottom-start with default placement when props are omitted', () => {
|
||||
render(
|
||||
<ContextMenu open>
|
||||
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
|
||||
<ContextMenuContent positionerProps={{ 'role': 'group', 'aria-label': 'content positioner' }}>
|
||||
<ContextMenuItem>Content action</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>,
|
||||
)
|
||||
|
||||
const positioner = screen.getByRole('group', { name: 'content positioner' })
|
||||
const popup = screen.getByRole('menu')
|
||||
expect(positioner).toHaveAttribute('data-side', 'bottom')
|
||||
expect(positioner).toHaveAttribute('data-align', 'start')
|
||||
expect(within(popup).getByRole('menuitem', { name: 'Content action' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply custom placement when custom positioning props are provided', () => {
|
||||
render(
|
||||
<ContextMenu open>
|
||||
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
|
||||
<ContextMenuContent
|
||||
placement="top-end"
|
||||
sideOffset={12}
|
||||
alignOffset={-3}
|
||||
positionerProps={{ 'role': 'group', 'aria-label': 'custom content positioner' }}
|
||||
>
|
||||
<ContextMenuItem>Custom content</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>,
|
||||
)
|
||||
|
||||
const positioner = screen.getByRole('group', { name: 'custom content positioner' })
|
||||
const popup = screen.getByRole('menu')
|
||||
expect(positioner).toHaveAttribute('data-side', 'top')
|
||||
expect(positioner).toHaveAttribute('data-align', 'end')
|
||||
expect(within(popup).getByRole('menuitem', { name: 'Custom content' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should forward passthrough attributes and handlers when positionerProps and popupProps are provided', () => {
|
||||
const handlePositionerMouseEnter = vi.fn()
|
||||
const handlePopupClick = vi.fn()
|
||||
|
||||
render(
|
||||
<ContextMenu open>
|
||||
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
|
||||
<ContextMenuContent
|
||||
positionerProps={{
|
||||
'role': 'group',
|
||||
'aria-label': 'context content positioner',
|
||||
'id': 'context-content-positioner',
|
||||
'onMouseEnter': handlePositionerMouseEnter,
|
||||
}}
|
||||
popupProps={{
|
||||
role: 'menu',
|
||||
id: 'context-content-popup',
|
||||
onClick: handlePopupClick,
|
||||
}}
|
||||
>
|
||||
<ContextMenuItem>Passthrough content</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>,
|
||||
)
|
||||
|
||||
const positioner = screen.getByRole('group', { name: 'context content positioner' })
|
||||
const popup = screen.getByRole('menu')
|
||||
fireEvent.mouseEnter(positioner)
|
||||
fireEvent.click(popup)
|
||||
expect(positioner).toHaveAttribute('id', 'context-content-positioner')
|
||||
expect(popup).toHaveAttribute('id', 'context-content-popup')
|
||||
expect(handlePositionerMouseEnter).toHaveBeenCalledTimes(1)
|
||||
expect(handlePopupClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ContextMenuSubContent', () => {
|
||||
it('should position sub-content at right-start with default placement when props are omitted', () => {
|
||||
render(
|
||||
<ContextMenu open>
|
||||
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuSub open>
|
||||
<ContextMenuSubTrigger>More actions</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent positionerProps={{ 'role': 'group', 'aria-label': 'sub positioner' }}>
|
||||
<ContextMenuItem>Sub action</ContextMenuItem>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>,
|
||||
)
|
||||
|
||||
const positioner = screen.getByRole('group', { name: 'sub positioner' })
|
||||
expect(positioner).toHaveAttribute('data-side', 'right')
|
||||
expect(positioner).toHaveAttribute('data-align', 'start')
|
||||
expect(screen.getByRole('menuitem', { name: 'Sub action' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('destructive prop behavior', () => {
|
||||
it.each([true, false])('should remain interactive and not leak destructive prop on item when destructive is %s', (destructive) => {
|
||||
const handleClick = vi.fn()
|
||||
|
||||
render(
|
||||
<ContextMenu open>
|
||||
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
destructive={destructive}
|
||||
aria-label="menu action"
|
||||
id={`context-item-${String(destructive)}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
Item label
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>,
|
||||
)
|
||||
|
||||
const item = screen.getByRole('menuitem', { name: 'menu action' })
|
||||
fireEvent.click(item)
|
||||
expect(item).toHaveAttribute('id', `context-item-${String(destructive)}`)
|
||||
expect(item).not.toHaveAttribute('destructive')
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.each([true, false])('should remain interactive and not leak destructive prop on submenu trigger when destructive is %s', (destructive) => {
|
||||
const handleClick = vi.fn()
|
||||
|
||||
render(
|
||||
<ContextMenu open>
|
||||
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuSub open>
|
||||
<ContextMenuSubTrigger
|
||||
destructive={destructive}
|
||||
aria-label="submenu action"
|
||||
id={`context-sub-${String(destructive)}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
Trigger item
|
||||
</ContextMenuSubTrigger>
|
||||
</ContextMenuSub>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>,
|
||||
)
|
||||
|
||||
const trigger = screen.getByRole('menuitem', { name: 'submenu action' })
|
||||
fireEvent.click(trigger)
|
||||
expect(trigger).toHaveAttribute('id', `context-sub-${String(destructive)}`)
|
||||
expect(trigger).not.toHaveAttribute('destructive')
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.each([true, false])('should remain interactive and not leak destructive prop on link item when destructive is %s', (destructive) => {
|
||||
render(
|
||||
<ContextMenu open>
|
||||
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuLinkItem
|
||||
destructive={destructive}
|
||||
href="https://example.com/docs"
|
||||
aria-label="context docs link"
|
||||
id={`context-link-${String(destructive)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Docs
|
||||
</ContextMenuLinkItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>,
|
||||
)
|
||||
|
||||
const link = screen.getByRole('menuitem', { name: 'context docs link' })
|
||||
expect(link.tagName.toLowerCase()).toBe('a')
|
||||
expect(link).toHaveAttribute('id', `context-link-${String(destructive)}`)
|
||||
expect(link).not.toHaveAttribute('destructive')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ContextMenuLinkItem close behavior', () => {
|
||||
it('should keep link semantics and not leak closeOnClick prop when closeOnClick is false', () => {
|
||||
render(
|
||||
<ContextMenu open>
|
||||
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuLinkItem
|
||||
href="https://example.com/docs"
|
||||
closeOnClick={false}
|
||||
aria-label="docs link"
|
||||
>
|
||||
Docs
|
||||
</ContextMenuLinkItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>,
|
||||
)
|
||||
|
||||
const link = screen.getByRole('menuitem', { name: 'docs link' })
|
||||
expect(link.tagName.toLowerCase()).toBe('a')
|
||||
expect(link).toHaveAttribute('href', 'https://example.com/docs')
|
||||
expect(link).not.toHaveAttribute('closeOnClick')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ContextMenuTrigger interaction', () => {
|
||||
it('should open menu when right-clicking trigger area', () => {
|
||||
render(
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger aria-label="context trigger area">
|
||||
Trigger area
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem>Open on right click</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>,
|
||||
)
|
||||
|
||||
const trigger = screen.getByLabelText('context trigger area')
|
||||
fireEvent.contextMenu(trigger)
|
||||
expect(screen.getByRole('menuitem', { name: 'Open on right click' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ContextMenuSeparator', () => {
|
||||
it('should render separator and keep surrounding rows when separator is between items', () => {
|
||||
render(
|
||||
<ContextMenu open>
|
||||
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem>First action</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem>Second action</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('menuitem', { name: 'First action' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('menuitem', { name: 'Second action' })).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('separator')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
215
web/app/components/base/ui/context-menu/index.stories.tsx
Normal file
215
web/app/components/base/ui/context-menu/index.stories.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuCheckboxItemIndicator,
|
||||
ContextMenuContent,
|
||||
ContextMenuGroup,
|
||||
ContextMenuGroupLabel,
|
||||
ContextMenuItem,
|
||||
ContextMenuLinkItem,
|
||||
ContextMenuRadioGroup,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuRadioItemIndicator,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger,
|
||||
} from '.'
|
||||
|
||||
const TriggerArea = ({ label = 'Right-click inside this area' }: { label?: string }) => (
|
||||
<ContextMenuTrigger
|
||||
aria-label="context menu trigger area"
|
||||
render={<button type="button" className="flex h-44 w-80 select-none items-center justify-center rounded-xl border border-divider-subtle bg-background-default-subtle px-6 text-center text-sm text-text-tertiary" />}
|
||||
>
|
||||
{label}
|
||||
</ContextMenuTrigger>
|
||||
)
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Navigation/ContextMenu',
|
||||
component: ContextMenu,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Compound context menu built on Base UI ContextMenu. Open by right-clicking the trigger area.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof ContextMenu>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<ContextMenu>
|
||||
<TriggerArea />
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem>Edit</ContextMenuItem>
|
||||
<ContextMenuItem>Duplicate</ContextMenuItem>
|
||||
<ContextMenuItem>Archive</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
),
|
||||
}
|
||||
|
||||
export const WithSubmenu: Story = {
|
||||
render: () => (
|
||||
<ContextMenu>
|
||||
<TriggerArea />
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem>Copy</ContextMenuItem>
|
||||
<ContextMenuItem>Paste</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>Share</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
<ContextMenuItem>Email</ContextMenuItem>
|
||||
<ContextMenuItem>Slack</ContextMenuItem>
|
||||
<ContextMenuItem>Copy link</ContextMenuItem>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
),
|
||||
}
|
||||
|
||||
export const WithGroupLabel: Story = {
|
||||
render: () => (
|
||||
<ContextMenu>
|
||||
<TriggerArea />
|
||||
<ContextMenuContent>
|
||||
<ContextMenuGroup>
|
||||
<ContextMenuGroupLabel>Actions</ContextMenuGroupLabel>
|
||||
<ContextMenuItem>Rename</ContextMenuItem>
|
||||
<ContextMenuItem>Duplicate</ContextMenuItem>
|
||||
</ContextMenuGroup>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuGroup>
|
||||
<ContextMenuGroupLabel>Danger Zone</ContextMenuGroupLabel>
|
||||
<ContextMenuItem destructive>Delete</ContextMenuItem>
|
||||
</ContextMenuGroup>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
),
|
||||
}
|
||||
|
||||
const WithRadioItemsDemo = () => {
|
||||
const [value, setValue] = useState('comfortable')
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<TriggerArea label={`Right-click to set density: ${value}`} />
|
||||
<ContextMenuContent>
|
||||
<ContextMenuRadioGroup value={value} onValueChange={setValue}>
|
||||
<ContextMenuRadioItem value="compact">
|
||||
Compact
|
||||
<ContextMenuRadioItemIndicator />
|
||||
</ContextMenuRadioItem>
|
||||
<ContextMenuRadioItem value="comfortable">
|
||||
Comfortable
|
||||
<ContextMenuRadioItemIndicator />
|
||||
</ContextMenuRadioItem>
|
||||
<ContextMenuRadioItem value="spacious">
|
||||
Spacious
|
||||
<ContextMenuRadioItemIndicator />
|
||||
</ContextMenuRadioItem>
|
||||
</ContextMenuRadioGroup>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export const WithRadioItems: Story = {
|
||||
render: () => <WithRadioItemsDemo />,
|
||||
}
|
||||
|
||||
const WithCheckboxItemsDemo = () => {
|
||||
const [showToolbar, setShowToolbar] = useState(true)
|
||||
const [showSidebar, setShowSidebar] = useState(false)
|
||||
const [showStatusBar, setShowStatusBar] = useState(true)
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<TriggerArea label="Right-click to configure panel visibility" />
|
||||
<ContextMenuContent>
|
||||
<ContextMenuCheckboxItem checked={showToolbar} onCheckedChange={setShowToolbar}>
|
||||
Toolbar
|
||||
<ContextMenuCheckboxItemIndicator />
|
||||
</ContextMenuCheckboxItem>
|
||||
<ContextMenuCheckboxItem checked={showSidebar} onCheckedChange={setShowSidebar}>
|
||||
Sidebar
|
||||
<ContextMenuCheckboxItemIndicator />
|
||||
</ContextMenuCheckboxItem>
|
||||
<ContextMenuCheckboxItem checked={showStatusBar} onCheckedChange={setShowStatusBar}>
|
||||
Status bar
|
||||
<ContextMenuCheckboxItemIndicator />
|
||||
</ContextMenuCheckboxItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export const WithCheckboxItems: Story = {
|
||||
render: () => <WithCheckboxItemsDemo />,
|
||||
}
|
||||
|
||||
export const WithLinkItems: Story = {
|
||||
render: () => (
|
||||
<ContextMenu>
|
||||
<TriggerArea label="Right-click to open links" />
|
||||
<ContextMenuContent>
|
||||
<ContextMenuLinkItem href="https://docs.dify.ai" rel="noopener noreferrer" target="_blank">
|
||||
Dify Docs
|
||||
</ContextMenuLinkItem>
|
||||
<ContextMenuLinkItem href="https://roadmap.dify.ai" rel="noopener noreferrer" target="_blank">
|
||||
Product Roadmap
|
||||
</ContextMenuLinkItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuLinkItem destructive href="https://example.com/delete" rel="noopener noreferrer" target="_blank">
|
||||
Dangerous External Action
|
||||
</ContextMenuLinkItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
),
|
||||
}
|
||||
|
||||
export const Complex: Story = {
|
||||
render: () => (
|
||||
<ContextMenu>
|
||||
<TriggerArea label="Right-click to inspect all menu capabilities" />
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem>
|
||||
<span aria-hidden className="i-ri-pencil-line size-4 shrink-0 text-text-tertiary" />
|
||||
Rename
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem>
|
||||
<span aria-hidden className="i-ri-file-copy-line size-4 shrink-0 text-text-tertiary" />
|
||||
Duplicate
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>
|
||||
<span aria-hidden className="i-ri-share-line size-4 shrink-0 text-text-tertiary" />
|
||||
Share
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
<ContextMenuItem>Email</ContextMenuItem>
|
||||
<ContextMenuItem>Slack</ContextMenuItem>
|
||||
<ContextMenuItem>Copy Link</ContextMenuItem>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem destructive>
|
||||
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
),
|
||||
}
|
||||
302
web/app/components/base/ui/context-menu/index.tsx
Normal file
302
web/app/components/base/ui/context-menu/index.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
'use client'
|
||||
|
||||
import type { Placement } from '@/app/components/base/ui/placement'
|
||||
import { ContextMenu as BaseContextMenu } from '@base-ui/react/context-menu'
|
||||
import * as React from 'react'
|
||||
import {
|
||||
menuBackdropClassName,
|
||||
menuGroupLabelClassName,
|
||||
menuIndicatorClassName,
|
||||
menuPopupAnimationClassName,
|
||||
menuPopupBaseClassName,
|
||||
menuRowClassName,
|
||||
menuSeparatorClassName,
|
||||
} from '@/app/components/base/ui/menu-shared'
|
||||
import { parsePlacement } from '@/app/components/base/ui/placement'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
export const ContextMenu = BaseContextMenu.Root
|
||||
export const ContextMenuTrigger = BaseContextMenu.Trigger
|
||||
export const ContextMenuPortal = BaseContextMenu.Portal
|
||||
export const ContextMenuBackdrop = BaseContextMenu.Backdrop
|
||||
export const ContextMenuSub = BaseContextMenu.SubmenuRoot
|
||||
export const ContextMenuGroup = BaseContextMenu.Group
|
||||
export const ContextMenuRadioGroup = BaseContextMenu.RadioGroup
|
||||
|
||||
type ContextMenuContentProps = {
|
||||
children: React.ReactNode
|
||||
placement?: Placement
|
||||
sideOffset?: number
|
||||
alignOffset?: number
|
||||
className?: string
|
||||
popupClassName?: string
|
||||
positionerProps?: Omit<
|
||||
React.ComponentPropsWithoutRef<typeof BaseContextMenu.Positioner>,
|
||||
'children' | 'className' | 'side' | 'align' | 'sideOffset' | 'alignOffset'
|
||||
>
|
||||
popupProps?: Omit<
|
||||
React.ComponentPropsWithoutRef<typeof BaseContextMenu.Popup>,
|
||||
'children' | 'className'
|
||||
>
|
||||
}
|
||||
|
||||
type ContextMenuPopupRenderProps = Required<Pick<ContextMenuContentProps, 'children'>> & {
|
||||
placement: Placement
|
||||
sideOffset: number
|
||||
alignOffset: number
|
||||
className?: string
|
||||
popupClassName?: string
|
||||
positionerProps?: ContextMenuContentProps['positionerProps']
|
||||
popupProps?: ContextMenuContentProps['popupProps']
|
||||
withBackdrop?: boolean
|
||||
}
|
||||
|
||||
function renderContextMenuPopup({
|
||||
children,
|
||||
placement,
|
||||
sideOffset,
|
||||
alignOffset,
|
||||
className,
|
||||
popupClassName,
|
||||
positionerProps,
|
||||
popupProps,
|
||||
withBackdrop = false,
|
||||
}: ContextMenuPopupRenderProps) {
|
||||
const { side, align } = parsePlacement(placement)
|
||||
|
||||
return (
|
||||
<BaseContextMenu.Portal>
|
||||
{withBackdrop && (
|
||||
<BaseContextMenu.Backdrop className={menuBackdropClassName} />
|
||||
)}
|
||||
<BaseContextMenu.Positioner
|
||||
side={side}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
className={cn('z-50 outline-none', className)}
|
||||
{...positionerProps}
|
||||
>
|
||||
<BaseContextMenu.Popup
|
||||
className={cn(
|
||||
menuPopupBaseClassName,
|
||||
menuPopupAnimationClassName,
|
||||
popupClassName,
|
||||
)}
|
||||
{...popupProps}
|
||||
>
|
||||
{children}
|
||||
</BaseContextMenu.Popup>
|
||||
</BaseContextMenu.Positioner>
|
||||
</BaseContextMenu.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export function ContextMenuContent({
|
||||
children,
|
||||
placement = 'bottom-start',
|
||||
sideOffset = 0,
|
||||
alignOffset = 0,
|
||||
className,
|
||||
popupClassName,
|
||||
positionerProps,
|
||||
popupProps,
|
||||
}: ContextMenuContentProps) {
|
||||
return renderContextMenuPopup({
|
||||
children,
|
||||
placement,
|
||||
sideOffset,
|
||||
alignOffset,
|
||||
className,
|
||||
popupClassName,
|
||||
positionerProps,
|
||||
popupProps,
|
||||
withBackdrop: true,
|
||||
})
|
||||
}
|
||||
|
||||
type ContextMenuItemProps = React.ComponentPropsWithoutRef<typeof BaseContextMenu.Item> & {
|
||||
destructive?: boolean
|
||||
}
|
||||
|
||||
export function ContextMenuItem({
|
||||
className,
|
||||
destructive,
|
||||
...props
|
||||
}: ContextMenuItemProps) {
|
||||
return (
|
||||
<BaseContextMenu.Item
|
||||
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type ContextMenuLinkItemProps = React.ComponentPropsWithoutRef<typeof BaseContextMenu.LinkItem> & {
|
||||
destructive?: boolean
|
||||
}
|
||||
|
||||
export function ContextMenuLinkItem({
|
||||
className,
|
||||
destructive,
|
||||
closeOnClick = true,
|
||||
...props
|
||||
}: ContextMenuLinkItemProps) {
|
||||
return (
|
||||
<BaseContextMenu.LinkItem
|
||||
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
|
||||
closeOnClick={closeOnClick}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function ContextMenuRadioItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof BaseContextMenu.RadioItem>) {
|
||||
return (
|
||||
<BaseContextMenu.RadioItem
|
||||
className={cn(menuRowClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function ContextMenuCheckboxItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof BaseContextMenu.CheckboxItem>) {
|
||||
return (
|
||||
<BaseContextMenu.CheckboxItem
|
||||
className={cn(menuRowClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type ContextMenuIndicatorProps = Omit<React.ComponentPropsWithoutRef<'span'>, 'children'> & {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function ContextMenuItemIndicator({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ContextMenuIndicatorProps) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(menuIndicatorClassName, className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <span aria-hidden className="i-ri-check-line h-4 w-4" />}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function ContextMenuCheckboxItemIndicator({
|
||||
className,
|
||||
...props
|
||||
}: Omit<React.ComponentPropsWithoutRef<typeof BaseContextMenu.CheckboxItemIndicator>, 'children'>) {
|
||||
return (
|
||||
<BaseContextMenu.CheckboxItemIndicator
|
||||
className={cn(menuIndicatorClassName, className)}
|
||||
{...props}
|
||||
>
|
||||
<span aria-hidden className="i-ri-check-line h-4 w-4" />
|
||||
</BaseContextMenu.CheckboxItemIndicator>
|
||||
)
|
||||
}
|
||||
|
||||
export function ContextMenuRadioItemIndicator({
|
||||
className,
|
||||
...props
|
||||
}: Omit<React.ComponentPropsWithoutRef<typeof BaseContextMenu.RadioItemIndicator>, 'children'>) {
|
||||
return (
|
||||
<BaseContextMenu.RadioItemIndicator
|
||||
className={cn(menuIndicatorClassName, className)}
|
||||
{...props}
|
||||
>
|
||||
<span aria-hidden className="i-ri-check-line h-4 w-4" />
|
||||
</BaseContextMenu.RadioItemIndicator>
|
||||
)
|
||||
}
|
||||
|
||||
type ContextMenuSubTriggerProps = React.ComponentPropsWithoutRef<typeof BaseContextMenu.SubmenuTrigger> & {
|
||||
destructive?: boolean
|
||||
}
|
||||
|
||||
export function ContextMenuSubTrigger({
|
||||
className,
|
||||
destructive,
|
||||
children,
|
||||
...props
|
||||
}: ContextMenuSubTriggerProps) {
|
||||
return (
|
||||
<BaseContextMenu.SubmenuTrigger
|
||||
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<span aria-hidden className="i-ri-arrow-right-s-line ml-auto size-4 shrink-0 text-text-tertiary" />
|
||||
</BaseContextMenu.SubmenuTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
type ContextMenuSubContentProps = {
|
||||
children: React.ReactNode
|
||||
placement?: Placement
|
||||
sideOffset?: number
|
||||
alignOffset?: number
|
||||
className?: string
|
||||
popupClassName?: string
|
||||
positionerProps?: ContextMenuContentProps['positionerProps']
|
||||
popupProps?: ContextMenuContentProps['popupProps']
|
||||
}
|
||||
|
||||
export function ContextMenuSubContent({
|
||||
children,
|
||||
placement = 'right-start',
|
||||
sideOffset = 4,
|
||||
alignOffset = 0,
|
||||
className,
|
||||
popupClassName,
|
||||
positionerProps,
|
||||
popupProps,
|
||||
}: ContextMenuSubContentProps) {
|
||||
return renderContextMenuPopup({
|
||||
children,
|
||||
placement,
|
||||
sideOffset,
|
||||
alignOffset,
|
||||
className,
|
||||
popupClassName,
|
||||
positionerProps,
|
||||
popupProps,
|
||||
})
|
||||
}
|
||||
|
||||
export function ContextMenuGroupLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof BaseContextMenu.GroupLabel>) {
|
||||
return (
|
||||
<BaseContextMenu.GroupLabel
|
||||
className={cn(menuGroupLabelClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function ContextMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof BaseContextMenu.Separator>) {
|
||||
return (
|
||||
<BaseContextMenu.Separator
|
||||
className={cn(menuSeparatorClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
import { Menu } from '@base-ui/react/menu'
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from 'react'
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import Link from 'next/link'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuLinkItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
@@ -15,18 +14,22 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '../index'
|
||||
|
||||
describe('dropdown-menu wrapper', () => {
|
||||
describe('alias exports', () => {
|
||||
it('should map direct aliases to the corresponding Menu primitive when importing menu roots', () => {
|
||||
expect(DropdownMenu).toBe(Menu.Root)
|
||||
expect(DropdownMenuPortal).toBe(Menu.Portal)
|
||||
expect(DropdownMenuTrigger).toBe(Menu.Trigger)
|
||||
expect(DropdownMenuSub).toBe(Menu.SubmenuRoot)
|
||||
expect(DropdownMenuGroup).toBe(Menu.Group)
|
||||
expect(DropdownMenuRadioGroup).toBe(Menu.RadioGroup)
|
||||
})
|
||||
})
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({
|
||||
href,
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
href: string
|
||||
children?: ReactNode
|
||||
} & Omit<ComponentPropsWithoutRef<'a'>, 'href'>) => (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('dropdown-menu wrapper', () => {
|
||||
describe('DropdownMenuContent', () => {
|
||||
it('should position content at bottom-end with default placement when props are omitted', () => {
|
||||
render(
|
||||
@@ -250,6 +253,99 @@ describe('dropdown-menu wrapper', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('DropdownMenuLinkItem', () => {
|
||||
it('should render as anchor and keep href/target attributes when link props are provided', () => {
|
||||
render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLinkItem href="https://example.com/docs" target="_blank" rel="noopener noreferrer">
|
||||
Docs
|
||||
</DropdownMenuLinkItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
)
|
||||
|
||||
const link = screen.getByRole('menuitem', { name: 'Docs' })
|
||||
expect(link.tagName.toLowerCase()).toBe('a')
|
||||
expect(link).toHaveAttribute('href', 'https://example.com/docs')
|
||||
expect(link).toHaveAttribute('target', '_blank')
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
|
||||
it('should keep link semantics and not leak closeOnClick prop when closeOnClick is false', () => {
|
||||
render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLinkItem
|
||||
href="https://example.com/docs"
|
||||
closeOnClick={false}
|
||||
aria-label="docs link"
|
||||
>
|
||||
Docs
|
||||
</DropdownMenuLinkItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
)
|
||||
|
||||
const link = screen.getByRole('menuitem', { name: 'docs link' })
|
||||
expect(link.tagName.toLowerCase()).toBe('a')
|
||||
expect(link).toHaveAttribute('href', 'https://example.com/docs')
|
||||
expect(link).not.toHaveAttribute('closeOnClick')
|
||||
})
|
||||
|
||||
it('should preserve link semantics when render prop uses a custom link component', () => {
|
||||
render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLinkItem
|
||||
render={<Link href="/account" />}
|
||||
aria-label="account link"
|
||||
>
|
||||
Account settings
|
||||
</DropdownMenuLinkItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
)
|
||||
|
||||
const link = screen.getByRole('menuitem', { name: 'account link' })
|
||||
expect(link.tagName.toLowerCase()).toBe('a')
|
||||
expect(link).toHaveAttribute('href', '/account')
|
||||
expect(link).toHaveTextContent('Account settings')
|
||||
})
|
||||
|
||||
it.each([true, false])('should remain interactive and not leak destructive prop when destructive is %s', (destructive) => {
|
||||
const handleClick = vi.fn()
|
||||
|
||||
render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLinkItem
|
||||
destructive={destructive}
|
||||
href="https://example.com/docs"
|
||||
aria-label="docs link"
|
||||
id={`menu-link-${String(destructive)}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
Docs
|
||||
</DropdownMenuLinkItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
)
|
||||
|
||||
const link = screen.getByRole('menuitem', { name: 'docs link' })
|
||||
fireEvent.click(link)
|
||||
|
||||
expect(link.tagName.toLowerCase()).toBe('a')
|
||||
expect(link).toHaveAttribute('id', `menu-link-${String(destructive)}`)
|
||||
expect(link).not.toHaveAttribute('destructive')
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DropdownMenuSeparator', () => {
|
||||
it('should forward passthrough props and handlers when separator props are provided', () => {
|
||||
const handleMouseEnter = vi.fn()
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuGroupLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLinkItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuRadioItemIndicator,
|
||||
@@ -234,6 +235,22 @@ export const WithIcons: Story = {
|
||||
),
|
||||
}
|
||||
|
||||
export const WithLinkItems: Story = {
|
||||
render: () => (
|
||||
<DropdownMenu>
|
||||
<TriggerButton label="Open links" />
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLinkItem href="https://docs.dify.ai" rel="noopener noreferrer" target="_blank">
|
||||
Dify Docs
|
||||
</DropdownMenuLinkItem>
|
||||
<DropdownMenuLinkItem href="https://roadmap.dify.ai" rel="noopener noreferrer" target="_blank">
|
||||
Product Roadmap
|
||||
</DropdownMenuLinkItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
}
|
||||
|
||||
const ComplexDemo = () => {
|
||||
const [sortOrder, setSortOrder] = useState('newest')
|
||||
const [showArchived, setShowArchived] = useState(false)
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
import type { Placement } from '@/app/components/base/ui/placement'
|
||||
import { Menu } from '@base-ui/react/menu'
|
||||
import * as React from 'react'
|
||||
import {
|
||||
menuGroupLabelClassName,
|
||||
menuIndicatorClassName,
|
||||
menuPopupAnimationClassName,
|
||||
menuPopupBaseClassName,
|
||||
menuRowClassName,
|
||||
menuSeparatorClassName,
|
||||
} from '@/app/components/base/ui/menu-shared'
|
||||
import { parsePlacement } from '@/app/components/base/ui/placement'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
@@ -13,20 +21,13 @@ export const DropdownMenuSub = Menu.SubmenuRoot
|
||||
export const DropdownMenuGroup = Menu.Group
|
||||
export const DropdownMenuRadioGroup = Menu.RadioGroup
|
||||
|
||||
const menuRowBaseClassName = 'mx-1 flex h-8 cursor-pointer select-none items-center gap-1 rounded-lg px-2 outline-none'
|
||||
const menuRowStateClassName = 'data-[highlighted]:bg-state-base-hover data-[disabled]:cursor-not-allowed data-[disabled]:opacity-30'
|
||||
|
||||
export function DropdownMenuRadioItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof Menu.RadioItem>) {
|
||||
return (
|
||||
<Menu.RadioItem
|
||||
className={cn(
|
||||
menuRowBaseClassName,
|
||||
menuRowStateClassName,
|
||||
className,
|
||||
)}
|
||||
className={cn(menuRowClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -38,10 +39,7 @@ export function DropdownMenuRadioItemIndicator({
|
||||
}: Omit<React.ComponentPropsWithoutRef<typeof Menu.RadioItemIndicator>, 'children'>) {
|
||||
return (
|
||||
<Menu.RadioItemIndicator
|
||||
className={cn(
|
||||
'ml-auto flex shrink-0 items-center text-text-accent',
|
||||
className,
|
||||
)}
|
||||
className={cn(menuIndicatorClassName, className)}
|
||||
{...props}
|
||||
>
|
||||
<span aria-hidden className="i-ri-check-line h-4 w-4" />
|
||||
@@ -55,11 +53,7 @@ export function DropdownMenuCheckboxItem({
|
||||
}: React.ComponentPropsWithoutRef<typeof Menu.CheckboxItem>) {
|
||||
return (
|
||||
<Menu.CheckboxItem
|
||||
className={cn(
|
||||
menuRowBaseClassName,
|
||||
menuRowStateClassName,
|
||||
className,
|
||||
)}
|
||||
className={cn(menuRowClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -71,10 +65,7 @@ export function DropdownMenuCheckboxItemIndicator({
|
||||
}: Omit<React.ComponentPropsWithoutRef<typeof Menu.CheckboxItemIndicator>, 'children'>) {
|
||||
return (
|
||||
<Menu.CheckboxItemIndicator
|
||||
className={cn(
|
||||
'ml-auto flex shrink-0 items-center text-text-accent',
|
||||
className,
|
||||
)}
|
||||
className={cn(menuIndicatorClassName, className)}
|
||||
{...props}
|
||||
>
|
||||
<span aria-hidden className="i-ri-check-line h-4 w-4" />
|
||||
@@ -88,10 +79,7 @@ export function DropdownMenuGroupLabel({
|
||||
}: React.ComponentPropsWithoutRef<typeof Menu.GroupLabel>) {
|
||||
return (
|
||||
<Menu.GroupLabel
|
||||
className={cn(
|
||||
'px-3 pb-0.5 pt-1 text-text-tertiary system-xs-medium-uppercase',
|
||||
className,
|
||||
)}
|
||||
className={cn(menuGroupLabelClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -148,8 +136,8 @@ function renderDropdownMenuPopup({
|
||||
>
|
||||
<Menu.Popup
|
||||
className={cn(
|
||||
'max-h-[var(--available-height)] overflow-y-auto overflow-x-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur py-1 text-sm text-text-secondary shadow-lg backdrop-blur-[5px]',
|
||||
'origin-[var(--transform-origin)] transition-[transform,scale,opacity] data-[ending-style]:scale-95 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none',
|
||||
menuPopupBaseClassName,
|
||||
menuPopupAnimationClassName,
|
||||
popupClassName,
|
||||
)}
|
||||
{...popupProps}
|
||||
@@ -195,12 +183,7 @@ export function DropdownMenuSubTrigger({
|
||||
}: DropdownMenuSubTriggerProps) {
|
||||
return (
|
||||
<Menu.SubmenuTrigger
|
||||
className={cn(
|
||||
menuRowBaseClassName,
|
||||
menuRowStateClassName,
|
||||
destructive && 'text-text-destructive',
|
||||
className,
|
||||
)}
|
||||
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -253,12 +236,26 @@ export function DropdownMenuItem({
|
||||
}: DropdownMenuItemProps) {
|
||||
return (
|
||||
<Menu.Item
|
||||
className={cn(
|
||||
menuRowBaseClassName,
|
||||
menuRowStateClassName,
|
||||
destructive && 'text-text-destructive',
|
||||
className,
|
||||
)}
|
||||
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type DropdownMenuLinkItemProps = React.ComponentPropsWithoutRef<typeof Menu.LinkItem> & {
|
||||
destructive?: boolean
|
||||
}
|
||||
|
||||
export function DropdownMenuLinkItem({
|
||||
className,
|
||||
destructive,
|
||||
closeOnClick = true,
|
||||
...props
|
||||
}: DropdownMenuLinkItemProps) {
|
||||
return (
|
||||
<Menu.LinkItem
|
||||
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
|
||||
closeOnClick={closeOnClick}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -270,7 +267,7 @@ export function DropdownMenuSeparator({
|
||||
}: React.ComponentPropsWithoutRef<typeof Menu.Separator>) {
|
||||
return (
|
||||
<Menu.Separator
|
||||
className={cn('my-1 h-px bg-divider-subtle', className)}
|
||||
className={cn(menuSeparatorClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
7
web/app/components/base/ui/menu-shared.ts
Normal file
7
web/app/components/base/ui/menu-shared.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const menuRowClassName = 'mx-1 flex h-8 cursor-pointer select-none items-center gap-1 rounded-lg px-2 outline-none data-[highlighted]:bg-state-base-hover data-[disabled]:cursor-not-allowed data-[disabled]:opacity-30'
|
||||
export const menuIndicatorClassName = 'ml-auto flex shrink-0 items-center text-text-accent'
|
||||
export const menuGroupLabelClassName = 'px-3 pb-0.5 pt-1 text-text-tertiary system-xs-medium-uppercase'
|
||||
export const menuSeparatorClassName = 'my-1 h-px bg-divider-subtle'
|
||||
export const menuPopupBaseClassName = 'max-h-[var(--available-height)] overflow-y-auto overflow-x-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur py-1 text-sm text-text-secondary shadow-lg outline-none focus:outline-none focus-visible:outline-none backdrop-blur-[5px]'
|
||||
export const menuPopupAnimationClassName = 'origin-[var(--transform-origin)] transition-[transform,scale,opacity] data-[ending-style]:scale-95 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none'
|
||||
export const menuBackdropClassName = 'fixed inset-0 z-50 bg-transparent transition-opacity duration-150 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none'
|
||||
@@ -184,7 +184,7 @@ export default function Compliance() {
|
||||
<DropdownMenuSubContent
|
||||
popupClassName="w-[337px] divide-y divide-divider-subtle !bg-components-panel-bg-blur !py-0 backdrop-blur-sm"
|
||||
>
|
||||
<DropdownMenuGroup className="p-1">
|
||||
<DropdownMenuGroup className="py-1">
|
||||
<ComplianceDocRowItem
|
||||
icon={<Soc2 aria-hidden className="size-7 shrink-0" />}
|
||||
label={t('compliance.soc2Type1', { ns: 'common' })}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { resetUser } from '@/app/components/base/amplitude/utils'
|
||||
import Avatar from '@/app/components/base/avatar'
|
||||
import PremiumBadge from '@/app/components/base/premium-badge'
|
||||
import ThemeSwitcher from '@/app/components/base/theme-switcher'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/app/components/base/ui/dropdown-menu'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLinkItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/app/components/base/ui/dropdown-menu'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
import { IS_CLOUD_EDITION } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
@@ -41,12 +41,12 @@ function AccountMenuRouteItem({
|
||||
trailing,
|
||||
}: AccountMenuRouteItemProps) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
<DropdownMenuLinkItem
|
||||
className="justify-between"
|
||||
render={<Link href={href} />}
|
||||
>
|
||||
<MenuItemContent iconClassName={iconClassName} label={label} trailing={trailing} />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuLinkItem>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -64,12 +64,14 @@ function AccountMenuExternalItem({
|
||||
trailing,
|
||||
}: AccountMenuExternalItemProps) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
<DropdownMenuLinkItem
|
||||
className="justify-between"
|
||||
render={<a href={href} rel="noopener noreferrer" target="_blank" />}
|
||||
href={href}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<MenuItemContent iconClassName={iconClassName} label={label} trailing={trailing} />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuLinkItem>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -101,7 +103,7 @@ type AccountMenuSectionProps = {
|
||||
}
|
||||
|
||||
function AccountMenuSection({ children }: AccountMenuSectionProps) {
|
||||
return <DropdownMenuGroup className="p-1">{children}</DropdownMenuGroup>
|
||||
return <DropdownMenuGroup className="py-1">{children}</DropdownMenuGroup>
|
||||
}
|
||||
|
||||
export default function AppSelector() {
|
||||
@@ -144,8 +146,8 @@ export default function AppSelector() {
|
||||
sideOffset={6}
|
||||
popupClassName="w-60 max-w-80 !bg-components-panel-bg-blur !py-0 backdrop-blur-sm"
|
||||
>
|
||||
<DropdownMenuGroup className="px-1 py-1">
|
||||
<div className="flex flex-nowrap items-center py-2 pl-3 pr-2">
|
||||
<DropdownMenuGroup className="py-1">
|
||||
<div className="mx-1 flex flex-nowrap items-center py-2 pl-3 pr-2">
|
||||
<div className="grow">
|
||||
<div className="break-all text-text-primary system-md-medium">
|
||||
{userProfile.name}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger } from '@/app/components/base/ui/dropdown-menu'
|
||||
import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuLinkItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger } from '@/app/components/base/ui/dropdown-menu'
|
||||
import { toggleZendeskWindow } from '@/app/components/base/zendesk/utils'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { SUPPORT_EMAIL_ADDRESS, ZENDESK_WIDGET_KEY } from '@/config'
|
||||
@@ -31,7 +31,7 @@ export default function Support({ closeAccountDropdown }: SupportProps) {
|
||||
<DropdownMenuSubContent
|
||||
popupClassName="w-[216px] divide-y divide-divider-subtle !bg-components-panel-bg-blur !py-0 backdrop-blur-sm"
|
||||
>
|
||||
<DropdownMenuGroup className="p-1">
|
||||
<DropdownMenuGroup className="py-1">
|
||||
{hasDedicatedChannel && hasZendeskWidget && (
|
||||
<DropdownMenuItem
|
||||
className="justify-between"
|
||||
@@ -47,37 +47,43 @@ export default function Support({ closeAccountDropdown }: SupportProps) {
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{hasDedicatedChannel && !hasZendeskWidget && (
|
||||
<DropdownMenuItem
|
||||
<DropdownMenuLinkItem
|
||||
className="justify-between"
|
||||
render={<a href={mailToSupport(userProfile.email, plan.type, langGeniusVersionInfo?.current_version, SUPPORT_EMAIL_ADDRESS)} rel="noopener noreferrer" target="_blank" />}
|
||||
href={mailToSupport(userProfile.email, plan.type, langGeniusVersionInfo?.current_version, SUPPORT_EMAIL_ADDRESS)}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<MenuItemContent
|
||||
iconClassName="i-ri-mail-send-line"
|
||||
label={t('userProfile.emailSupport', { ns: 'common' })}
|
||||
trailing={<ExternalLinkIndicator />}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuLinkItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
<DropdownMenuLinkItem
|
||||
className="justify-between"
|
||||
render={<a href="https://forum.dify.ai/" rel="noopener noreferrer" target="_blank" />}
|
||||
href="https://forum.dify.ai/"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<MenuItemContent
|
||||
iconClassName="i-ri-discuss-line"
|
||||
label={t('userProfile.forum', { ns: 'common' })}
|
||||
trailing={<ExternalLinkIndicator />}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
</DropdownMenuLinkItem>
|
||||
<DropdownMenuLinkItem
|
||||
className="justify-between"
|
||||
render={<a href="https://discord.gg/5AEfbxcd9k" rel="noopener noreferrer" target="_blank" />}
|
||||
href="https://discord.gg/5AEfbxcd9k"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<MenuItemContent
|
||||
iconClassName="i-ri-discord-line"
|
||||
label={t('userProfile.community', { ns: 'common' })}
|
||||
trailing={<ExternalLinkIndicator />}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuLinkItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
@@ -111,11 +111,11 @@ const ToolItem: FC<Props> = ({
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div className={cn('system-sm-medium h-8 truncate border-l-2 border-divider-subtle pl-4 leading-8 text-text-secondary')}>
|
||||
<div className={cn('truncate border-l-2 border-divider-subtle py-2 pl-4 text-text-secondary system-sm-medium')}>
|
||||
<span className={cn(disabled && 'opacity-30')}>{payload.label[language]}</span>
|
||||
</div>
|
||||
{isAdded && (
|
||||
<div className="system-xs-regular mr-4 text-text-tertiary">{t('addToolModal.added', { ns: 'tools' })}</div>
|
||||
<div className="mr-4 text-text-tertiary system-xs-regular">{t('addToolModal.added', { ns: 'tools' })}</div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
@@ -77,11 +77,11 @@ const TriggerPluginActionItem: FC<Props> = ({
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div className={cn('system-sm-medium h-8 truncate border-l-2 border-divider-subtle pl-4 leading-8 text-text-secondary')}>
|
||||
<div className={cn('truncate border-l-2 border-divider-subtle py-2 pl-4 text-text-secondary system-sm-medium')}>
|
||||
<span className={cn(disabled && 'opacity-30')}>{payload.label[language]}</span>
|
||||
</div>
|
||||
{isAdded && (
|
||||
<div className="system-xs-regular mr-4 text-text-tertiary">{t('addToolModal.added', { ns: 'tools' })}</div>
|
||||
<div className="mr-4 text-text-tertiary system-xs-regular">{t('addToolModal.added', { ns: 'tools' })}</div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
@@ -1422,21 +1422,136 @@ export const useNodesInteractions = () => {
|
||||
extent: currentNode.extent,
|
||||
zIndex: currentNode.zIndex,
|
||||
})
|
||||
const nodesConnectedSourceOrTargetHandleIdsMap
|
||||
= getNodesConnectedSourceOrTargetHandleIdsMap(
|
||||
connectedEdges.map(edge => ({ type: 'remove', edge })),
|
||||
nodes,
|
||||
)
|
||||
const newNodes = produce(nodes, (draft) => {
|
||||
const parentNode = nodes.find(node => node.id === currentNode.parentId)
|
||||
const newNodeIsInIteration
|
||||
= !!parentNode && parentNode.data.type === BlockEnum.Iteration
|
||||
const newNodeIsInLoop
|
||||
= !!parentNode && parentNode.data.type === BlockEnum.Loop
|
||||
const outgoingEdges = connectedEdges.filter(
|
||||
edge => edge.source === currentNodeId,
|
||||
)
|
||||
const normalizedSourceHandle = sourceHandle || 'source'
|
||||
const outgoingHandles = new Set(
|
||||
outgoingEdges.map(edge => edge.sourceHandle || 'source'),
|
||||
)
|
||||
const branchSourceHandle = currentNode.data._targetBranches?.[0]?.id
|
||||
let outgoingHandleToPreserve = normalizedSourceHandle
|
||||
if (!outgoingHandles.has(outgoingHandleToPreserve)) {
|
||||
if (branchSourceHandle && outgoingHandles.has(branchSourceHandle))
|
||||
outgoingHandleToPreserve = branchSourceHandle
|
||||
else if (outgoingHandles.has('source'))
|
||||
outgoingHandleToPreserve = 'source'
|
||||
else
|
||||
outgoingHandleToPreserve = outgoingEdges[0]?.sourceHandle || 'source'
|
||||
}
|
||||
const outgoingEdgesToPreserve = outgoingEdges.filter(
|
||||
edge => (edge.sourceHandle || 'source') === outgoingHandleToPreserve,
|
||||
)
|
||||
const outgoingEdgeIds = new Set(
|
||||
outgoingEdgesToPreserve.map(edge => edge.id),
|
||||
)
|
||||
const newNodeSourceHandle = newCurrentNode.data._targetBranches?.[0]?.id || 'source'
|
||||
const reconnectedEdges = connectedEdges.reduce<Edge[]>(
|
||||
(acc, edge) => {
|
||||
if (outgoingEdgeIds.has(edge.id)) {
|
||||
const originalTargetNode = nodes.find(
|
||||
node => node.id === edge.target,
|
||||
)
|
||||
const targetNodeForEdge
|
||||
= originalTargetNode && originalTargetNode.id !== currentNodeId
|
||||
? originalTargetNode
|
||||
: newCurrentNode
|
||||
if (!targetNodeForEdge)
|
||||
return acc
|
||||
|
||||
const targetHandle = edge.targetHandle || 'target'
|
||||
const targetParentNode
|
||||
= targetNodeForEdge.id === newCurrentNode.id
|
||||
? parentNode || null
|
||||
: nodes.find(node => node.id === targetNodeForEdge.parentId)
|
||||
|| null
|
||||
const isInIteration
|
||||
= !!targetParentNode
|
||||
&& targetParentNode.data.type === BlockEnum.Iteration
|
||||
const isInLoop
|
||||
= !!targetParentNode
|
||||
&& targetParentNode.data.type === BlockEnum.Loop
|
||||
|
||||
acc.push({
|
||||
...edge,
|
||||
id: `${newCurrentNode.id}-${newNodeSourceHandle}-${targetNodeForEdge.id}-${targetHandle}`,
|
||||
source: newCurrentNode.id,
|
||||
sourceHandle: newNodeSourceHandle,
|
||||
target: targetNodeForEdge.id,
|
||||
targetHandle,
|
||||
type: CUSTOM_EDGE,
|
||||
data: {
|
||||
...(edge.data || {}),
|
||||
sourceType: newCurrentNode.data.type,
|
||||
targetType: targetNodeForEdge.data.type,
|
||||
isInIteration,
|
||||
iteration_id: isInIteration
|
||||
? targetNodeForEdge.parentId
|
||||
: undefined,
|
||||
isInLoop,
|
||||
loop_id: isInLoop ? targetNodeForEdge.parentId : undefined,
|
||||
_connectedNodeIsSelected: false,
|
||||
},
|
||||
zIndex: targetNodeForEdge.parentId
|
||||
? isInIteration
|
||||
? ITERATION_CHILDREN_Z_INDEX
|
||||
: LOOP_CHILDREN_Z_INDEX
|
||||
: 0,
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
edge.target === currentNodeId
|
||||
&& edge.source !== currentNodeId
|
||||
&& !outgoingEdgeIds.has(edge.id)
|
||||
) {
|
||||
const sourceNode = nodes.find(node => node.id === edge.source)
|
||||
if (!sourceNode)
|
||||
return acc
|
||||
|
||||
const targetHandle = edge.targetHandle || 'target'
|
||||
const sourceHandle = edge.sourceHandle || 'source'
|
||||
|
||||
acc.push({
|
||||
...edge,
|
||||
id: `${sourceNode.id}-${sourceHandle}-${newCurrentNode.id}-${targetHandle}`,
|
||||
source: sourceNode.id,
|
||||
sourceHandle,
|
||||
target: newCurrentNode.id,
|
||||
targetHandle,
|
||||
type: CUSTOM_EDGE,
|
||||
data: {
|
||||
...(edge.data || {}),
|
||||
sourceType: sourceNode.data.type,
|
||||
targetType: newCurrentNode.data.type,
|
||||
isInIteration: newNodeIsInIteration,
|
||||
iteration_id: newNodeIsInIteration
|
||||
? newCurrentNode.parentId
|
||||
: undefined,
|
||||
isInLoop: newNodeIsInLoop,
|
||||
loop_id: newNodeIsInLoop ? newCurrentNode.parentId : undefined,
|
||||
_connectedNodeIsSelected: false,
|
||||
},
|
||||
zIndex: newCurrentNode.parentId
|
||||
? newNodeIsInIteration
|
||||
? ITERATION_CHILDREN_Z_INDEX
|
||||
: LOOP_CHILDREN_Z_INDEX
|
||||
: 0,
|
||||
})
|
||||
}
|
||||
|
||||
return acc
|
||||
},
|
||||
[],
|
||||
)
|
||||
const nodesWithNewNode = produce(nodes, (draft) => {
|
||||
draft.forEach((node) => {
|
||||
node.data.selected = false
|
||||
|
||||
if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) {
|
||||
node.data = {
|
||||
...node.data,
|
||||
...nodesConnectedSourceOrTargetHandleIdsMap[node.id],
|
||||
}
|
||||
}
|
||||
})
|
||||
const index = draft.findIndex(node => node.id === currentNodeId)
|
||||
|
||||
@@ -1446,18 +1561,32 @@ export const useNodesInteractions = () => {
|
||||
if (newLoopStartNode)
|
||||
draft.push(newLoopStartNode)
|
||||
})
|
||||
setNodes(newNodes)
|
||||
const newEdges = produce(edges, (draft) => {
|
||||
const filtered = draft.filter(
|
||||
edge =>
|
||||
!connectedEdges.find(
|
||||
connectedEdge => connectedEdge.id === edge.id,
|
||||
),
|
||||
const nodesConnectedSourceOrTargetHandleIdsMap
|
||||
= getNodesConnectedSourceOrTargetHandleIdsMap(
|
||||
[
|
||||
...connectedEdges.map(edge => ({ type: 'remove', edge })),
|
||||
...reconnectedEdges.map(edge => ({ type: 'add', edge })),
|
||||
],
|
||||
nodesWithNewNode,
|
||||
)
|
||||
|
||||
return filtered
|
||||
const newNodes = produce(nodesWithNewNode, (draft) => {
|
||||
draft.forEach((node) => {
|
||||
if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) {
|
||||
node.data = {
|
||||
...node.data,
|
||||
...nodesConnectedSourceOrTargetHandleIdsMap[node.id],
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
setEdges(newEdges)
|
||||
setNodes(newNodes)
|
||||
const remainingEdges = edges.filter(
|
||||
edge =>
|
||||
!connectedEdges.find(
|
||||
connectedEdge => connectedEdge.id === edge.id,
|
||||
),
|
||||
)
|
||||
setEdges([...remainingEdges, ...reconnectedEdges])
|
||||
if (nodeType === BlockEnum.TriggerWebhook) {
|
||||
handleSyncWorkflowDraft(true, true, {
|
||||
onSuccess: () => autoGenerateWebhookUrl(newCurrentNode.id),
|
||||
@@ -1606,6 +1735,7 @@ export const useNodesInteractions = () => {
|
||||
const offsetX = currentPosition.x - x
|
||||
const offsetY = currentPosition.y - y
|
||||
let idMapping: Record<string, string> = {}
|
||||
const pastedNodesMap: Record<string, Node> = {}
|
||||
const parentChildrenToAppend: { parentId: string, childId: string, childType: BlockEnum }[] = []
|
||||
clipboardElements.forEach((nodeToPaste, index) => {
|
||||
const nodeType = nodeToPaste.data.type
|
||||
@@ -1665,7 +1795,21 @@ export const useNodesInteractions = () => {
|
||||
newLoopStartNode!.parentId = newNode.id;
|
||||
(newNode.data as LoopNodeType).start_node_id = newLoopStartNode!.id
|
||||
|
||||
newChildren = handleNodeLoopChildrenCopy(nodeToPaste.id, newNode.id)
|
||||
const oldLoopStartNode = nodes.find(
|
||||
n =>
|
||||
n.parentId === nodeToPaste.id
|
||||
&& n.type === CUSTOM_LOOP_START_NODE,
|
||||
)
|
||||
idMapping[oldLoopStartNode!.id] = newLoopStartNode!.id
|
||||
|
||||
const { copyChildren, newIdMapping }
|
||||
= handleNodeLoopChildrenCopy(
|
||||
nodeToPaste.id,
|
||||
newNode.id,
|
||||
idMapping,
|
||||
)
|
||||
newChildren = copyChildren
|
||||
idMapping = newIdMapping
|
||||
newChildren.forEach((child) => {
|
||||
newNode.data._children?.push({
|
||||
nodeId: child.id,
|
||||
@@ -1710,18 +1854,31 @@ export const useNodesInteractions = () => {
|
||||
}
|
||||
}
|
||||
|
||||
idMapping[nodeToPaste.id] = newNode.id
|
||||
nodesToPaste.push(newNode)
|
||||
pastedNodesMap[newNode.id] = newNode
|
||||
|
||||
if (newChildren.length)
|
||||
if (newChildren.length) {
|
||||
newChildren.forEach((child) => {
|
||||
pastedNodesMap[child.id] = child
|
||||
})
|
||||
nodesToPaste.push(...newChildren)
|
||||
}
|
||||
})
|
||||
|
||||
// only handle edge when paste nested block
|
||||
// Rebuild edges where both endpoints are part of the pasted set.
|
||||
edges.forEach((edge) => {
|
||||
const sourceId = idMapping[edge.source]
|
||||
const targetId = idMapping[edge.target]
|
||||
|
||||
if (sourceId && targetId) {
|
||||
const sourceNode = pastedNodesMap[sourceId]
|
||||
const targetNode = pastedNodesMap[targetId]
|
||||
const parentNode = sourceNode?.parentId && sourceNode.parentId === targetNode?.parentId
|
||||
? pastedNodesMap[sourceNode.parentId] ?? nodes.find(n => n.id === sourceNode.parentId)
|
||||
: null
|
||||
const isInIteration = parentNode?.data.type === BlockEnum.Iteration
|
||||
const isInLoop = parentNode?.data.type === BlockEnum.Loop
|
||||
const newEdge: Edge = {
|
||||
...edge,
|
||||
id: `${sourceId}-${edge.sourceHandle}-${targetId}-${edge.targetHandle}`,
|
||||
@@ -1729,8 +1886,19 @@ export const useNodesInteractions = () => {
|
||||
target: targetId,
|
||||
data: {
|
||||
...edge.data,
|
||||
isInIteration,
|
||||
iteration_id: isInIteration ? parentNode?.id : undefined,
|
||||
isInLoop,
|
||||
loop_id: isInLoop ? parentNode?.id : undefined,
|
||||
_connectedNodeIsSelected: false,
|
||||
},
|
||||
zIndex: parentNode
|
||||
? isInIteration
|
||||
? ITERATION_CHILDREN_Z_INDEX
|
||||
: isInLoop
|
||||
? LOOP_CHILDREN_Z_INDEX
|
||||
: 0
|
||||
: 0,
|
||||
}
|
||||
edgesToPaste.push(newEdge)
|
||||
}
|
||||
|
||||
@@ -108,12 +108,13 @@ export const useNodeLoopInteractions = () => {
|
||||
handleNodeLoopRerender(parentId)
|
||||
}, [store, handleNodeLoopRerender])
|
||||
|
||||
const handleNodeLoopChildrenCopy = useCallback((nodeId: string, newNodeId: string) => {
|
||||
const handleNodeLoopChildrenCopy = useCallback((nodeId: string, newNodeId: string, idMapping: Record<string, string>) => {
|
||||
const { getNodes } = store.getState()
|
||||
const nodes = getNodes()
|
||||
const childrenNodes = nodes.filter(n => n.parentId === nodeId && n.type !== CUSTOM_LOOP_START_NODE)
|
||||
const newIdMapping = { ...idMapping }
|
||||
|
||||
return childrenNodes.map((child, index) => {
|
||||
const copyChildren = childrenNodes.map((child, index) => {
|
||||
const childNodeType = child.data.type as BlockEnum
|
||||
const { defaultValue } = nodesMetaDataMap![childNodeType]
|
||||
const nodesWithSameType = nodes.filter(node => node.data.type === childNodeType)
|
||||
@@ -139,8 +140,14 @@ export const useNodeLoopInteractions = () => {
|
||||
zIndex: LOOP_CHILDREN_Z_INDEX,
|
||||
})
|
||||
newNode.id = `${newNodeId}${newNode.id + index}`
|
||||
newIdMapping[child.id] = newNode.id
|
||||
return newNode
|
||||
})
|
||||
|
||||
return {
|
||||
copyChildren,
|
||||
newIdMapping,
|
||||
}
|
||||
}, [store, nodesMetaDataMap])
|
||||
|
||||
return {
|
||||
|
||||
@@ -16,6 +16,7 @@ This document tracks the migration away from legacy overlay APIs.
|
||||
- Replacement primitives:
|
||||
- `@/app/components/base/ui/tooltip`
|
||||
- `@/app/components/base/ui/dropdown-menu`
|
||||
- `@/app/components/base/ui/context-menu`
|
||||
- `@/app/components/base/ui/popover`
|
||||
- `@/app/components/base/ui/dialog`
|
||||
- `@/app/components/base/ui/alert-dialog`
|
||||
|
||||
@@ -6554,9 +6554,6 @@
|
||||
"app/components/workflow/block-selector/tool/action-item.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/workflow/block-selector/tool/tool-list-flat-view/list.tsx": {
|
||||
@@ -6576,9 +6573,6 @@
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 2
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
|
||||
@@ -69,13 +69,13 @@
|
||||
"@formatjs/intl-localematcher": "0.5.10",
|
||||
"@headlessui/react": "2.2.1",
|
||||
"@heroicons/react": "2.2.0",
|
||||
"@lexical/code": "0.38.2",
|
||||
"@lexical/link": "0.38.2",
|
||||
"@lexical/list": "0.38.2",
|
||||
"@lexical/react": "0.38.2",
|
||||
"@lexical/selection": "0.38.2",
|
||||
"@lexical/text": "0.38.2",
|
||||
"@lexical/utils": "0.39.0",
|
||||
"@lexical/code": "0.41.0",
|
||||
"@lexical/link": "0.41.0",
|
||||
"@lexical/list": "0.41.0",
|
||||
"@lexical/react": "0.41.0",
|
||||
"@lexical/selection": "0.41.0",
|
||||
"@lexical/text": "0.41.0",
|
||||
"@lexical/utils": "0.41.0",
|
||||
"@monaco-editor/react": "4.7.0",
|
||||
"@octokit/core": "6.1.6",
|
||||
"@octokit/request-error": "6.1.8",
|
||||
@@ -122,7 +122,7 @@
|
||||
"katex": "0.16.25",
|
||||
"ky": "1.12.0",
|
||||
"lamejs": "1.2.1",
|
||||
"lexical": "0.38.2",
|
||||
"lexical": "0.41.0",
|
||||
"mermaid": "11.11.0",
|
||||
"mime": "4.1.0",
|
||||
"mitt": "3.0.1",
|
||||
@@ -216,7 +216,7 @@
|
||||
"@vitejs/plugin-react": "5.1.4",
|
||||
"@vitejs/plugin-rsc": "0.5.21",
|
||||
"@vitest/coverage-v8": "4.0.18",
|
||||
"agentation": "2.2.1",
|
||||
"agentation": "2.3.0",
|
||||
"autoprefixer": "10.4.21",
|
||||
"code-inspector-plugin": "1.4.2",
|
||||
"cross-env": "10.1.0",
|
||||
@@ -243,7 +243,7 @@
|
||||
"tsx": "4.21.0",
|
||||
"typescript": "5.9.3",
|
||||
"uglify-js": "3.19.3",
|
||||
"vinext": "https://pkg.pr.new/hyoban/vinext@556a6d6",
|
||||
"vinext": "https://pkg.pr.new/vinext@1a2fd61",
|
||||
"vite": "8.0.0-beta.16",
|
||||
"vite-plugin-inspect": "11.3.3",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
@@ -252,6 +252,7 @@
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@lexical/code": "npm:lexical-code-no-prism@0.41.0",
|
||||
"@monaco-editor/loader": "1.5.0",
|
||||
"@nolyfill/safe-buffer": "npm:safe-buffer@^5.2.1",
|
||||
"@stylistic/eslint-plugin": "https://pkg.pr.new/@stylistic/eslint-plugin@258f9d8",
|
||||
|
||||
435
web/pnpm-lock.yaml
generated
435
web/pnpm-lock.yaml
generated
@@ -5,6 +5,7 @@ settings:
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
overrides:
|
||||
'@lexical/code': npm:lexical-code-no-prism@0.41.0
|
||||
'@monaco-editor/loader': 1.5.0
|
||||
'@nolyfill/safe-buffer': npm:safe-buffer@^5.2.1
|
||||
'@stylistic/eslint-plugin': https://pkg.pr.new/@stylistic/eslint-plugin@258f9d8
|
||||
@@ -79,26 +80,26 @@ importers:
|
||||
specifier: 2.2.0
|
||||
version: 2.2.0(react@19.2.4)
|
||||
'@lexical/code':
|
||||
specifier: 0.38.2
|
||||
version: 0.38.2
|
||||
specifier: npm:lexical-code-no-prism@0.41.0
|
||||
version: lexical-code-no-prism@0.41.0(@lexical/utils@0.41.0)(lexical@0.41.0)
|
||||
'@lexical/link':
|
||||
specifier: 0.38.2
|
||||
version: 0.38.2
|
||||
specifier: 0.41.0
|
||||
version: 0.41.0
|
||||
'@lexical/list':
|
||||
specifier: 0.38.2
|
||||
version: 0.38.2
|
||||
specifier: 0.41.0
|
||||
version: 0.41.0
|
||||
'@lexical/react':
|
||||
specifier: 0.38.2
|
||||
version: 0.38.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(yjs@13.6.29)
|
||||
specifier: 0.41.0
|
||||
version: 0.41.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(yjs@13.6.29)
|
||||
'@lexical/selection':
|
||||
specifier: 0.38.2
|
||||
version: 0.38.2
|
||||
specifier: 0.41.0
|
||||
version: 0.41.0
|
||||
'@lexical/text':
|
||||
specifier: 0.38.2
|
||||
version: 0.38.2
|
||||
specifier: 0.41.0
|
||||
version: 0.41.0
|
||||
'@lexical/utils':
|
||||
specifier: 0.39.0
|
||||
version: 0.39.0
|
||||
specifier: 0.41.0
|
||||
version: 0.41.0
|
||||
'@monaco-editor/react':
|
||||
specifier: 4.7.0
|
||||
version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@@ -238,8 +239,8 @@ importers:
|
||||
specifier: 1.2.1
|
||||
version: 1.2.1
|
||||
lexical:
|
||||
specifier: 0.38.2
|
||||
version: 0.38.2
|
||||
specifier: 0.41.0
|
||||
version: 0.41.0
|
||||
mermaid:
|
||||
specifier: 11.11.0
|
||||
version: 11.11.0
|
||||
@@ -515,8 +516,8 @@ importers:
|
||||
specifier: 4.0.18
|
||||
version: 4.0.18(vitest@4.0.18(@types/node@24.10.12)(jiti@1.21.7)(jsdom@27.3.0(canvas@3.2.1))(lightningcss@1.31.1)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
agentation:
|
||||
specifier: 2.2.1
|
||||
version: 2.2.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
specifier: 2.3.0
|
||||
version: 2.3.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
autoprefixer:
|
||||
specifier: 10.4.21
|
||||
version: 10.4.21(postcss@8.5.6)
|
||||
@@ -596,8 +597,8 @@ importers:
|
||||
specifier: 3.19.3
|
||||
version: 3.19.3
|
||||
vinext:
|
||||
specifier: https://pkg.pr.new/hyoban/vinext@556a6d6
|
||||
version: https://pkg.pr.new/hyoban/vinext@556a6d6(next@16.1.5(@babel/core@7.28.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.93.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3))
|
||||
specifier: https://pkg.pr.new/vinext@1a2fd61
|
||||
version: https://pkg.pr.new/vinext@1a2fd61(next@16.1.5(@babel/core@7.28.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.93.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3))
|
||||
vite:
|
||||
specifier: 8.0.0-beta.16
|
||||
version: 8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
@@ -1682,98 +1683,74 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@lexical/clipboard@0.38.2':
|
||||
resolution: {integrity: sha512-dDShUplCu8/o6BB9ousr3uFZ9bltR+HtleF/Tl8FXFNPpZ4AXhbLKUoJuucRuIr+zqT7RxEv/3M6pk/HEoE6NQ==}
|
||||
'@lexical/clipboard@0.41.0':
|
||||
resolution: {integrity: sha512-Ex5lPkb4NBBX1DCPzOAIeHBJFH1bJcmATjREaqpnTfxCbuOeQkt44wchezUA0oDl+iAxNZ3+pLLWiUju9icoSA==}
|
||||
|
||||
'@lexical/clipboard@0.39.0':
|
||||
resolution: {integrity: sha512-ylrHy8M+I5EH4utwqivslugqQhvgLTz9VEJdrb2RjbhKQEXwMcqKCRWh6cRfkYx64onE2YQE0nRIdzHhExEpLQ==}
|
||||
|
||||
'@lexical/code@0.38.2':
|
||||
resolution: {integrity: sha512-wpqgbmPsfi/+8SYP0zI2kml09fGPRhzO5litR9DIbbSGvcbawMbRNcKLO81DaTbsJRnBJiQvbBBBJAwZKRqgBw==}
|
||||
|
||||
'@lexical/devtools-core@0.38.2':
|
||||
resolution: {integrity: sha512-hlN0q7taHNzG47xKynQLCAFEPOL8l6IP79C2M18/FE1+htqNP35q4rWhYhsptGlKo4me4PtiME7mskvr7T4yqA==}
|
||||
'@lexical/devtools-core@0.41.0':
|
||||
resolution: {integrity: sha512-FzJtluBhBc8bKS11TUZe72KoZN/hnzIyiiM0SPJAsPwGpoXuM01jqpXQGybWf/1bWB+bmmhOae7O4Nywi/Csuw==}
|
||||
peerDependencies:
|
||||
react: '>=17.x'
|
||||
react-dom: '>=17.x'
|
||||
|
||||
'@lexical/dragon@0.38.2':
|
||||
resolution: {integrity: sha512-riOhgo+l4oN50RnLGhcqeUokVlMZRc+NDrxRNs2lyKSUdC4vAhAmAVUHDqYPyb4K4ZSw4ebZ3j8hI2zO4O3BbA==}
|
||||
'@lexical/dragon@0.41.0':
|
||||
resolution: {integrity: sha512-gBEqkk8Q6ZPruvDaRcOdF1EK9suCVBODzOCcR+EnoJTaTjfDkCM7pkPAm4w90Wa1wCZEtFHvCfas+jU9MDSumg==}
|
||||
|
||||
'@lexical/extension@0.38.2':
|
||||
resolution: {integrity: sha512-qbUNxEVjAC0kxp7hEMTzktj0/51SyJoIJWK6Gm790b4yNBq82fEPkksfuLkRg9VQUteD0RT1Nkjy8pho8nNamw==}
|
||||
'@lexical/extension@0.41.0':
|
||||
resolution: {integrity: sha512-sF4SPiP72yXvIGchmmIZ7Yg2XZTxNLOpFEIIzdqG7X/1fa1Ham9P/T7VbrblWpF6Ei5LJtK9JgNVB0hb4l3o1g==}
|
||||
|
||||
'@lexical/extension@0.39.0':
|
||||
resolution: {integrity: sha512-mp/WcF8E53FWPiUHgHQz382J7u7C4+cELYNkC00dKaymf8NhS6M65Y8tyDikNGNUcLXSzaluwK0HkiKjTYGhVQ==}
|
||||
'@lexical/hashtag@0.41.0':
|
||||
resolution: {integrity: sha512-tFWM74RW4KU0E/sj2aowfWl26vmLUTp331CgVESnhQKcZBfT40KJYd57HEqBDTfQKn4MUhylQCCA0hbpw6EeFQ==}
|
||||
|
||||
'@lexical/hashtag@0.38.2':
|
||||
resolution: {integrity: sha512-jNI4Pv+plth39bjOeeQegMypkjDmoMWBMZtV0lCynBpkkPFlfMnyL9uzW/IxkZnX8LXWSw5mbWk07nqOUNTCrA==}
|
||||
'@lexical/history@0.41.0':
|
||||
resolution: {integrity: sha512-kGoVWsiOn62+RMjRolRa+NXZl8jFwxav6GNDiHH8yzivtoaH8n1SwUfLJELXCzeqzs81HySqD4q30VLJVTGoDg==}
|
||||
|
||||
'@lexical/history@0.38.2':
|
||||
resolution: {integrity: sha512-QWPwoVDMe/oJ0+TFhy78TDi7TWU/8bcDRFUNk1nWgbq7+2m+5MMoj90LmOFwakQHnCVovgba2qj+atZrab1dsQ==}
|
||||
'@lexical/html@0.41.0':
|
||||
resolution: {integrity: sha512-3RyZy+H/IDKz2D66rNN/NqYx87xVFrngfEbyu1OWtbY963RUFnopiVHCQvsge/8kT04QSZ7U/DzjVFqeNS6clg==}
|
||||
|
||||
'@lexical/html@0.38.2':
|
||||
resolution: {integrity: sha512-pC5AV+07bmHistRwgG3NJzBMlIzSdxYO6rJU4eBNzyR4becdiLsI4iuv+aY7PhfSv+SCs7QJ9oc4i5caq48Pkg==}
|
||||
'@lexical/link@0.41.0':
|
||||
resolution: {integrity: sha512-Rjtx5cGWAkKcnacncbVsZ1TqRnUB2Wm4eEVKpaAEG41+kHgqghzM2P+UGT15yROroxJu8KvAC9ISiYFiU4XE1w==}
|
||||
|
||||
'@lexical/html@0.39.0':
|
||||
resolution: {integrity: sha512-7VLWP5DpzBg3kKctpNK6PbhymKAtU6NAnKieopCfCIWlMW+EqpldteiIXGqSqrMRK0JWTmF1gKgr9nnQyOOsXw==}
|
||||
'@lexical/list@0.41.0':
|
||||
resolution: {integrity: sha512-RXvB+xcbzVoQLGRDOBRCacztG7V+bI95tdoTwl8pz5xvgPtAaRnkZWMDP+yMNzMJZsqEChdtpxbf0NgtMkun6g==}
|
||||
|
||||
'@lexical/link@0.38.2':
|
||||
resolution: {integrity: sha512-UOKTyYqrdCR9+7GmH6ZVqJTmqYefKGMUHMGljyGks+OjOGZAQs78S1QgcPEqltDy+SSdPSYK7wAo6gjxZfEq9g==}
|
||||
'@lexical/mark@0.41.0':
|
||||
resolution: {integrity: sha512-UO5WVs9uJAYIKHSlYh4Z1gHrBBchTOi21UCYBIZ7eAs4suK84hPzD+3/LAX5CB7ZltL6ke5Sly3FOwNXv/wfpA==}
|
||||
|
||||
'@lexical/list@0.38.2':
|
||||
resolution: {integrity: sha512-OQm9TzatlMrDZGxMxbozZEHzMJhKxAbH1TOnOGyFfzpfjbnFK2y8oLeVsfQZfZRmiqQS4Qc/rpFnRP2Ax5dsbA==}
|
||||
'@lexical/markdown@0.41.0':
|
||||
resolution: {integrity: sha512-bzI73JMXpjGFhqUWNV6KqfjWcgAWzwFT+J3RHtbCF5rysC8HLldBYojOgAAtPfXqfxyv2mDzsY7SoJ75s9uHZA==}
|
||||
|
||||
'@lexical/list@0.39.0':
|
||||
resolution: {integrity: sha512-mxgSxUrakTCHtC+gF30BChQBJTsCMiMgfC2H5VvhcFwXMgsKE/aK9+a+C/sSvvzCmPXqzYsuAcGkJcrY3e5xlw==}
|
||||
'@lexical/offset@0.41.0':
|
||||
resolution: {integrity: sha512-2RHBXZqC8gm3X9C0AyRb0M8w7zJu5dKiasrif+jSKzsxPjAUeF1m95OtIOsWs1XLNUgASOSUqGovDZxKJslZfA==}
|
||||
|
||||
'@lexical/mark@0.38.2':
|
||||
resolution: {integrity: sha512-U+8KGwc3cP5DxSs15HfkP2YZJDs5wMbWQAwpGqep9bKphgxUgjPViKhdi+PxIt2QEzk7WcoZWUsK1d2ty/vSmg==}
|
||||
'@lexical/overflow@0.41.0':
|
||||
resolution: {integrity: sha512-Iy6ZiJip8X14EBYt1zKPOrXyQ4eG9JLBEoPoSVBTiSbVd+lYicdUvaOThT0k0/qeVTN9nqTaEltBjm56IrVKCQ==}
|
||||
|
||||
'@lexical/markdown@0.38.2':
|
||||
resolution: {integrity: sha512-ykQJ9KUpCs1+Ak6ZhQMP6Slai4/CxfLEGg/rSHNVGbcd7OaH/ICtZN5jOmIe9ExfXMWy1o8PyMu+oAM3+AWFgA==}
|
||||
'@lexical/plain-text@0.41.0':
|
||||
resolution: {integrity: sha512-HIsGgmFUYRUNNyvckun33UQfU7LRzDlxymHUq67+Bxd5bXqdZOrStEKJXuDX+LuLh/GXZbaWNbDLqwLBObfbQg==}
|
||||
|
||||
'@lexical/offset@0.38.2':
|
||||
resolution: {integrity: sha512-uDky2palcY+gE6WTv6q2umm2ioTUnVqcaWlEcchP6A310rI08n6rbpmkaLSIh3mT2GJQN2QcN2x0ct5BQmKIpA==}
|
||||
|
||||
'@lexical/overflow@0.38.2':
|
||||
resolution: {integrity: sha512-f6vkTf+YZF0EuKvUK3goh4jrnF+Z0koiNMO+7rhSMLooc5IlD/4XXix4ZLiIktUWq4BhO84b82qtrO+6oPUxtw==}
|
||||
|
||||
'@lexical/plain-text@0.38.2':
|
||||
resolution: {integrity: sha512-xRYNHJJFCbaQgr0uErW8Im2Phv1nWHIT4VSoAlBYqLuVGZBD4p61dqheBwqXWlGGJFk+MY5C5URLiMicgpol7A==}
|
||||
|
||||
'@lexical/react@0.38.2':
|
||||
resolution: {integrity: sha512-M3z3MkWyw3Msg4Hojr5TnO4TzL71NVPVNGoavESjdgJbTdv1ezcQqjE4feq+qs7H9jytZeuK8wsEOJfSPmNd8w==}
|
||||
'@lexical/react@0.41.0':
|
||||
resolution: {integrity: sha512-7+GUdZUm6sofWm+zdsWAs6cFBwKNsvsHezZTrf6k8jrZxL461ZQmbz/16b4DvjCGL9r5P1fR7md9/LCmk8TiCg==}
|
||||
peerDependencies:
|
||||
react: '>=17.x'
|
||||
react-dom: '>=17.x'
|
||||
|
||||
'@lexical/rich-text@0.38.2':
|
||||
resolution: {integrity: sha512-eFjeOT7YnDZYpty7Zlwlct0UxUSaYu53uLYG+Prs3NoKzsfEK7e7nYsy/BbQFfk5HoM1pYuYxFR2iIX62+YHGw==}
|
||||
'@lexical/rich-text@0.41.0':
|
||||
resolution: {integrity: sha512-yUcr7ZaaVTZNi8bow4CK1M8jy2qyyls1Vr+5dVjwBclVShOL/F/nFyzBOSb6RtXXRbd3Ahuk9fEleppX/RNIdw==}
|
||||
|
||||
'@lexical/selection@0.38.2':
|
||||
resolution: {integrity: sha512-eMFiWlBH6bEX9U9sMJ6PXPxVXTrihQfFeiIlWLuTpEIDF2HRz7Uo1KFRC/yN6q0DQaj7d9NZYA6Mei5DoQuz5w==}
|
||||
'@lexical/selection@0.41.0':
|
||||
resolution: {integrity: sha512-1s7/kNyRzcv5uaTwsUL28NpiisqTf5xZ1zNukLsCN1xY+TWbv9RE9OxIv+748wMm4pxNczQe/UbIBODkbeknLw==}
|
||||
|
||||
'@lexical/selection@0.39.0':
|
||||
resolution: {integrity: sha512-j0cgNuTKDCdf/4MzRnAUwEqG6C/WQp18k2WKmX5KIVZJlhnGIJmlgSBrxjo8AuZ16DIHxTm2XNB4cUDCgZNuPA==}
|
||||
'@lexical/table@0.41.0':
|
||||
resolution: {integrity: sha512-d3SPThBAr+oZ8O74TXU0iXM3rLbrAVC7/HcOnSAq7/AhWQW8yMutT51JQGN+0fMLP9kqoWSAojNtkdvzXfU/+A==}
|
||||
|
||||
'@lexical/table@0.38.2':
|
||||
resolution: {integrity: sha512-uu0i7yz0nbClmHOO5ZFsinRJE6vQnFz2YPblYHAlNigiBedhqMwSv5bedrzDq8nTTHwych3mC63tcyKIrM+I1g==}
|
||||
'@lexical/text@0.41.0':
|
||||
resolution: {integrity: sha512-gGA+Anc7ck110EXo4KVKtq6Ui3M7Vz3OpGJ4QE6zJHWW8nV5h273koUGSutAMeoZgRVb6t01Izh3ORoFt/j1CA==}
|
||||
|
||||
'@lexical/table@0.39.0':
|
||||
resolution: {integrity: sha512-1eH11kV4bJ0fufCYl8DpE19kHwqUI8Ev5CZwivfAtC3ntwyNkeEpjCc0pqeYYIWN/4rTZ5jgB3IJV4FntyfCzw==}
|
||||
'@lexical/utils@0.41.0':
|
||||
resolution: {integrity: sha512-Wlsokr5NQCq83D+7kxZ9qs5yQ3dU3Qaf2M+uXxLRoPoDaXqW8xTWZq1+ZFoEzsHzx06QoPa4Vu/40BZR91uQPg==}
|
||||
|
||||
'@lexical/text@0.38.2':
|
||||
resolution: {integrity: sha512-+juZxUugtC4T37aE3P0l4I9tsWbogDUnTI/mgYk4Ht9g+gLJnhQkzSA8chIyfTxbj5i0A8yWrUUSw+/xA7lKUQ==}
|
||||
|
||||
'@lexical/utils@0.38.2':
|
||||
resolution: {integrity: sha512-y+3rw15r4oAWIEXicUdNjfk8018dbKl7dWHqGHVEtqzAYefnEYdfD2FJ5KOTXfeoYfxi8yOW7FvzS4NZDi8Bfw==}
|
||||
|
||||
'@lexical/utils@0.39.0':
|
||||
resolution: {integrity: sha512-8YChidpMJpwQc4nex29FKUeuZzC++QCS/Jt46lPuy1GS/BZQoPHFKQ5hyVvM9QVhc5CEs4WGNoaCZvZIVN8bQw==}
|
||||
|
||||
'@lexical/yjs@0.38.2':
|
||||
resolution: {integrity: sha512-fg6ZHNrVQmy1AAxaTs8HrFbeNTJCaCoEDPi6pqypHQU3QVfqr4nq0L0EcHU/TRlR1CeduEPvZZIjUUxWTZ0u8g==}
|
||||
'@lexical/yjs@0.41.0':
|
||||
resolution: {integrity: sha512-PaKTxSbVC4fpqUjQ7vUL9RkNF1PjL8TFl5jRe03PqoPYpE33buf3VXX6+cOUEfv9+uknSqLCPHoBS/4jN3a97w==}
|
||||
peerDependencies:
|
||||
yjs: '>=13.5.22'
|
||||
|
||||
@@ -3705,8 +3682,8 @@ packages:
|
||||
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
agentation@2.2.1:
|
||||
resolution: {integrity: sha512-yV9P1DggI7M3SRaRwLwt+xqE5lXqg5l8xtqCr8KzEkbnH8Wa6eRATU97uKnD7cC8FrsJP62Mmw0Xf5Xi5KV50Q==}
|
||||
agentation@2.3.0:
|
||||
resolution: {integrity: sha512-uGcDel78I5UAVSiWnsNv0pHj+ieuHyZ4GCsL6kqEralKeIW32869JlwfsKoy5S71jseyrI6O5duU+AacJs+CmQ==}
|
||||
peerDependencies:
|
||||
react: '>=18.0.0'
|
||||
react-dom: '>=18.0.0'
|
||||
@@ -5632,11 +5609,14 @@ packages:
|
||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
lexical@0.38.2:
|
||||
resolution: {integrity: sha512-JJmfsG3c4gwBHzUGffbV7ifMNkKAWMCnYE3xJl87gty7hjyV5f3xq7eqTjP5HFYvO4XpjJvvWO2/djHp5S10tw==}
|
||||
lexical-code-no-prism@0.41.0:
|
||||
resolution: {integrity: sha512-cFgCC/VMXjch58iod4TIhBHb1bx7Da8IdduUwltua581dhLmugcaFnUvgC0naBaPeYVuirA6cuDsyOdPgEEDLA==}
|
||||
peerDependencies:
|
||||
'@lexical/utils': '>=0.28.0'
|
||||
lexical: '>=0.28.0'
|
||||
|
||||
lexical@0.39.0:
|
||||
resolution: {integrity: sha512-lpLv7MEJH5QDujEDlYqettL3ATVtNYjqyimzqgrm0RvCm3AO9WXSdsgTxuN7IAZRu88xkxCDeYubeUf4mNZVdg==}
|
||||
lexical@0.41.0:
|
||||
resolution: {integrity: sha512-pNIm5+n+hVnJHB9gYPDYsIO5Y59dNaDU9rJmPPsfqQhP2ojKFnUoPbcRnrI9FJLXB14sSumcY8LUw7Sq70TZqA==}
|
||||
|
||||
lib0@0.2.117:
|
||||
resolution: {integrity: sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==}
|
||||
@@ -7524,8 +7504,8 @@ packages:
|
||||
vfile@6.0.3:
|
||||
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
|
||||
|
||||
vinext@https://pkg.pr.new/hyoban/vinext@556a6d6:
|
||||
resolution: {tarball: https://pkg.pr.new/hyoban/vinext@556a6d6}
|
||||
vinext@https://pkg.pr.new/vinext@1a2fd61:
|
||||
resolution: {integrity: sha512-5Q2iQExi1QQ/EpNcJ7TA6U9o4+kxJyaM/Ocobostt9IHqod6TOzhOx+ZSfmZr7eEVZq2joaIGY6Jl3dZ1dGNjg==, tarball: https://pkg.pr.new/vinext@1a2fd61}
|
||||
version: 0.0.5
|
||||
engines: {node: '>=22'}
|
||||
hasBin: true
|
||||
@@ -9119,210 +9099,157 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@lexical/clipboard@0.38.2':
|
||||
'@lexical/clipboard@0.41.0':
|
||||
dependencies:
|
||||
'@lexical/html': 0.38.2
|
||||
'@lexical/list': 0.38.2
|
||||
'@lexical/selection': 0.38.2
|
||||
'@lexical/utils': 0.38.2
|
||||
lexical: 0.38.2
|
||||
'@lexical/html': 0.41.0
|
||||
'@lexical/list': 0.41.0
|
||||
'@lexical/selection': 0.41.0
|
||||
'@lexical/utils': 0.41.0
|
||||
lexical: 0.41.0
|
||||
|
||||
'@lexical/clipboard@0.39.0':
|
||||
'@lexical/devtools-core@0.41.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@lexical/html': 0.39.0
|
||||
'@lexical/list': 0.39.0
|
||||
'@lexical/selection': 0.39.0
|
||||
'@lexical/utils': 0.39.0
|
||||
lexical: 0.39.0
|
||||
|
||||
'@lexical/code@0.38.2':
|
||||
dependencies:
|
||||
'@lexical/utils': 0.38.2
|
||||
lexical: 0.38.2
|
||||
prismjs: 1.30.0
|
||||
|
||||
'@lexical/devtools-core@0.38.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@lexical/html': 0.38.2
|
||||
'@lexical/link': 0.38.2
|
||||
'@lexical/mark': 0.38.2
|
||||
'@lexical/table': 0.38.2
|
||||
'@lexical/utils': 0.38.2
|
||||
lexical: 0.38.2
|
||||
'@lexical/html': 0.41.0
|
||||
'@lexical/link': 0.41.0
|
||||
'@lexical/mark': 0.41.0
|
||||
'@lexical/table': 0.41.0
|
||||
'@lexical/utils': 0.41.0
|
||||
lexical: 0.41.0
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
'@lexical/dragon@0.38.2':
|
||||
'@lexical/dragon@0.41.0':
|
||||
dependencies:
|
||||
'@lexical/extension': 0.38.2
|
||||
lexical: 0.38.2
|
||||
'@lexical/extension': 0.41.0
|
||||
lexical: 0.41.0
|
||||
|
||||
'@lexical/extension@0.38.2':
|
||||
'@lexical/extension@0.41.0':
|
||||
dependencies:
|
||||
'@lexical/utils': 0.38.2
|
||||
'@lexical/utils': 0.41.0
|
||||
'@preact/signals-core': 1.12.2
|
||||
lexical: 0.38.2
|
||||
lexical: 0.41.0
|
||||
|
||||
'@lexical/extension@0.39.0':
|
||||
'@lexical/hashtag@0.41.0':
|
||||
dependencies:
|
||||
'@lexical/utils': 0.39.0
|
||||
'@preact/signals-core': 1.12.2
|
||||
lexical: 0.39.0
|
||||
'@lexical/text': 0.41.0
|
||||
'@lexical/utils': 0.41.0
|
||||
lexical: 0.41.0
|
||||
|
||||
'@lexical/hashtag@0.38.2':
|
||||
'@lexical/history@0.41.0':
|
||||
dependencies:
|
||||
'@lexical/text': 0.38.2
|
||||
'@lexical/utils': 0.38.2
|
||||
lexical: 0.38.2
|
||||
'@lexical/extension': 0.41.0
|
||||
'@lexical/utils': 0.41.0
|
||||
lexical: 0.41.0
|
||||
|
||||
'@lexical/history@0.38.2':
|
||||
'@lexical/html@0.41.0':
|
||||
dependencies:
|
||||
'@lexical/extension': 0.38.2
|
||||
'@lexical/utils': 0.38.2
|
||||
lexical: 0.38.2
|
||||
'@lexical/selection': 0.41.0
|
||||
'@lexical/utils': 0.41.0
|
||||
lexical: 0.41.0
|
||||
|
||||
'@lexical/html@0.38.2':
|
||||
'@lexical/link@0.41.0':
|
||||
dependencies:
|
||||
'@lexical/selection': 0.38.2
|
||||
'@lexical/utils': 0.38.2
|
||||
lexical: 0.38.2
|
||||
'@lexical/extension': 0.41.0
|
||||
'@lexical/utils': 0.41.0
|
||||
lexical: 0.41.0
|
||||
|
||||
'@lexical/html@0.39.0':
|
||||
'@lexical/list@0.41.0':
|
||||
dependencies:
|
||||
'@lexical/selection': 0.39.0
|
||||
'@lexical/utils': 0.39.0
|
||||
lexical: 0.39.0
|
||||
'@lexical/extension': 0.41.0
|
||||
'@lexical/selection': 0.41.0
|
||||
'@lexical/utils': 0.41.0
|
||||
lexical: 0.41.0
|
||||
|
||||
'@lexical/link@0.38.2':
|
||||
'@lexical/mark@0.41.0':
|
||||
dependencies:
|
||||
'@lexical/extension': 0.38.2
|
||||
'@lexical/utils': 0.38.2
|
||||
lexical: 0.38.2
|
||||
'@lexical/utils': 0.41.0
|
||||
lexical: 0.41.0
|
||||
|
||||
'@lexical/list@0.38.2':
|
||||
'@lexical/markdown@0.41.0':
|
||||
dependencies:
|
||||
'@lexical/extension': 0.38.2
|
||||
'@lexical/selection': 0.38.2
|
||||
'@lexical/utils': 0.38.2
|
||||
lexical: 0.38.2
|
||||
'@lexical/code': lexical-code-no-prism@0.41.0(@lexical/utils@0.41.0)(lexical@0.41.0)
|
||||
'@lexical/link': 0.41.0
|
||||
'@lexical/list': 0.41.0
|
||||
'@lexical/rich-text': 0.41.0
|
||||
'@lexical/text': 0.41.0
|
||||
'@lexical/utils': 0.41.0
|
||||
lexical: 0.41.0
|
||||
|
||||
'@lexical/list@0.39.0':
|
||||
'@lexical/offset@0.41.0':
|
||||
dependencies:
|
||||
'@lexical/extension': 0.39.0
|
||||
'@lexical/selection': 0.39.0
|
||||
'@lexical/utils': 0.39.0
|
||||
lexical: 0.39.0
|
||||
lexical: 0.41.0
|
||||
|
||||
'@lexical/mark@0.38.2':
|
||||
'@lexical/overflow@0.41.0':
|
||||
dependencies:
|
||||
'@lexical/utils': 0.38.2
|
||||
lexical: 0.38.2
|
||||
lexical: 0.41.0
|
||||
|
||||
'@lexical/markdown@0.38.2':
|
||||
'@lexical/plain-text@0.41.0':
|
||||
dependencies:
|
||||
'@lexical/code': 0.38.2
|
||||
'@lexical/link': 0.38.2
|
||||
'@lexical/list': 0.38.2
|
||||
'@lexical/rich-text': 0.38.2
|
||||
'@lexical/text': 0.38.2
|
||||
'@lexical/utils': 0.38.2
|
||||
lexical: 0.38.2
|
||||
'@lexical/clipboard': 0.41.0
|
||||
'@lexical/dragon': 0.41.0
|
||||
'@lexical/selection': 0.41.0
|
||||
'@lexical/utils': 0.41.0
|
||||
lexical: 0.41.0
|
||||
|
||||
'@lexical/offset@0.38.2':
|
||||
dependencies:
|
||||
lexical: 0.38.2
|
||||
|
||||
'@lexical/overflow@0.38.2':
|
||||
dependencies:
|
||||
lexical: 0.38.2
|
||||
|
||||
'@lexical/plain-text@0.38.2':
|
||||
dependencies:
|
||||
'@lexical/clipboard': 0.38.2
|
||||
'@lexical/dragon': 0.38.2
|
||||
'@lexical/selection': 0.38.2
|
||||
'@lexical/utils': 0.38.2
|
||||
lexical: 0.38.2
|
||||
|
||||
'@lexical/react@0.38.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(yjs@13.6.29)':
|
||||
'@lexical/react@0.41.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(yjs@13.6.29)':
|
||||
dependencies:
|
||||
'@floating-ui/react': 0.27.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@lexical/devtools-core': 0.38.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@lexical/dragon': 0.38.2
|
||||
'@lexical/extension': 0.38.2
|
||||
'@lexical/hashtag': 0.38.2
|
||||
'@lexical/history': 0.38.2
|
||||
'@lexical/link': 0.38.2
|
||||
'@lexical/list': 0.38.2
|
||||
'@lexical/mark': 0.38.2
|
||||
'@lexical/markdown': 0.38.2
|
||||
'@lexical/overflow': 0.38.2
|
||||
'@lexical/plain-text': 0.38.2
|
||||
'@lexical/rich-text': 0.38.2
|
||||
'@lexical/table': 0.38.2
|
||||
'@lexical/text': 0.38.2
|
||||
'@lexical/utils': 0.38.2
|
||||
'@lexical/yjs': 0.38.2(yjs@13.6.29)
|
||||
lexical: 0.38.2
|
||||
'@lexical/devtools-core': 0.41.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@lexical/dragon': 0.41.0
|
||||
'@lexical/extension': 0.41.0
|
||||
'@lexical/hashtag': 0.41.0
|
||||
'@lexical/history': 0.41.0
|
||||
'@lexical/link': 0.41.0
|
||||
'@lexical/list': 0.41.0
|
||||
'@lexical/mark': 0.41.0
|
||||
'@lexical/markdown': 0.41.0
|
||||
'@lexical/overflow': 0.41.0
|
||||
'@lexical/plain-text': 0.41.0
|
||||
'@lexical/rich-text': 0.41.0
|
||||
'@lexical/table': 0.41.0
|
||||
'@lexical/text': 0.41.0
|
||||
'@lexical/utils': 0.41.0
|
||||
'@lexical/yjs': 0.41.0(yjs@13.6.29)
|
||||
lexical: 0.41.0
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
react-error-boundary: 6.1.0(react@19.2.4)
|
||||
transitivePeerDependencies:
|
||||
- yjs
|
||||
|
||||
'@lexical/rich-text@0.38.2':
|
||||
'@lexical/rich-text@0.41.0':
|
||||
dependencies:
|
||||
'@lexical/clipboard': 0.38.2
|
||||
'@lexical/dragon': 0.38.2
|
||||
'@lexical/selection': 0.38.2
|
||||
'@lexical/utils': 0.38.2
|
||||
lexical: 0.38.2
|
||||
'@lexical/clipboard': 0.41.0
|
||||
'@lexical/dragon': 0.41.0
|
||||
'@lexical/selection': 0.41.0
|
||||
'@lexical/utils': 0.41.0
|
||||
lexical: 0.41.0
|
||||
|
||||
'@lexical/selection@0.38.2':
|
||||
'@lexical/selection@0.41.0':
|
||||
dependencies:
|
||||
lexical: 0.38.2
|
||||
lexical: 0.41.0
|
||||
|
||||
'@lexical/selection@0.39.0':
|
||||
'@lexical/table@0.41.0':
|
||||
dependencies:
|
||||
lexical: 0.39.0
|
||||
'@lexical/clipboard': 0.41.0
|
||||
'@lexical/extension': 0.41.0
|
||||
'@lexical/utils': 0.41.0
|
||||
lexical: 0.41.0
|
||||
|
||||
'@lexical/table@0.38.2':
|
||||
'@lexical/text@0.41.0':
|
||||
dependencies:
|
||||
'@lexical/clipboard': 0.38.2
|
||||
'@lexical/extension': 0.38.2
|
||||
'@lexical/utils': 0.38.2
|
||||
lexical: 0.38.2
|
||||
lexical: 0.41.0
|
||||
|
||||
'@lexical/table@0.39.0':
|
||||
'@lexical/utils@0.41.0':
|
||||
dependencies:
|
||||
'@lexical/clipboard': 0.39.0
|
||||
'@lexical/extension': 0.39.0
|
||||
'@lexical/utils': 0.39.0
|
||||
lexical: 0.39.0
|
||||
'@lexical/selection': 0.41.0
|
||||
lexical: 0.41.0
|
||||
|
||||
'@lexical/text@0.38.2':
|
||||
'@lexical/yjs@0.41.0(yjs@13.6.29)':
|
||||
dependencies:
|
||||
lexical: 0.38.2
|
||||
|
||||
'@lexical/utils@0.38.2':
|
||||
dependencies:
|
||||
'@lexical/list': 0.38.2
|
||||
'@lexical/selection': 0.38.2
|
||||
'@lexical/table': 0.38.2
|
||||
lexical: 0.38.2
|
||||
|
||||
'@lexical/utils@0.39.0':
|
||||
dependencies:
|
||||
'@lexical/list': 0.39.0
|
||||
'@lexical/selection': 0.39.0
|
||||
'@lexical/table': 0.39.0
|
||||
lexical: 0.39.0
|
||||
|
||||
'@lexical/yjs@0.38.2(yjs@13.6.29)':
|
||||
dependencies:
|
||||
'@lexical/offset': 0.38.2
|
||||
'@lexical/selection': 0.38.2
|
||||
lexical: 0.38.2
|
||||
'@lexical/offset': 0.41.0
|
||||
'@lexical/selection': 0.41.0
|
||||
lexical: 0.41.0
|
||||
yjs: 13.6.29
|
||||
|
||||
'@mdx-js/loader@3.1.1(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3))':
|
||||
@@ -11372,7 +11299,7 @@ snapshots:
|
||||
|
||||
agent-base@7.1.4: {}
|
||||
|
||||
agentation@2.2.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
agentation@2.3.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
optionalDependencies:
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
@@ -13529,9 +13456,12 @@ snapshots:
|
||||
prelude-ls: 1.2.1
|
||||
type-check: 0.4.0
|
||||
|
||||
lexical@0.38.2: {}
|
||||
lexical-code-no-prism@0.41.0(@lexical/utils@0.41.0)(lexical@0.41.0):
|
||||
dependencies:
|
||||
'@lexical/utils': 0.41.0
|
||||
lexical: 0.41.0
|
||||
|
||||
lexical@0.39.0: {}
|
||||
lexical@0.41.0: {}
|
||||
|
||||
lib0@0.2.117:
|
||||
dependencies:
|
||||
@@ -15884,10 +15814,11 @@ snapshots:
|
||||
'@types/unist': 3.0.3
|
||||
vfile-message: 4.0.3
|
||||
|
||||
vinext@https://pkg.pr.new/hyoban/vinext@556a6d6(next@16.1.5(@babel/core@7.28.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.93.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3)):
|
||||
vinext@https://pkg.pr.new/vinext@1a2fd61(next@16.1.5(@babel/core@7.28.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.93.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3)):
|
||||
dependencies:
|
||||
'@unpic/react': 1.0.2(next@16.1.5(@babel/core@7.28.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.93.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@vercel/og': 0.8.6
|
||||
'@vitejs/plugin-react': 5.1.4(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@vitejs/plugin-rsc': 0.5.21(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3)))(react@19.2.4)(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
magic-string: 0.30.21
|
||||
react: 19.2.4
|
||||
|
||||
@@ -46,7 +46,6 @@ export default defineConfig(({ mode }) => {
|
||||
injectTarget: browserInitializerInjectTarget,
|
||||
projectRoot,
|
||||
}),
|
||||
react(),
|
||||
vinext(),
|
||||
customI18nHmrPlugin({ injectTarget: browserInitializerInjectTarget }),
|
||||
// reactGrabOpenFilePlugin({
|
||||
@@ -65,13 +64,6 @@ export default defineConfig(({ mode }) => {
|
||||
? {
|
||||
optimizeDeps: {
|
||||
exclude: ['nuqs'],
|
||||
// Make Prism in lexical works
|
||||
// https://github.com/vitejs/rolldown-vite/issues/396
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
strictExecutionOrder: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
@@ -80,15 +72,6 @@ export default defineConfig(({ mode }) => {
|
||||
// SyntaxError: Named export not found. The requested module is a CommonJS module, which may not support all module.exports as named exports
|
||||
noExternal: ['emoji-mart'],
|
||||
},
|
||||
// Make Prism in lexical works
|
||||
// https://github.com/vitejs/rolldown-vite/issues/396
|
||||
build: {
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
strictExecutionOrder: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
||||
|
||||
@@ -80,6 +80,16 @@ if (typeof globalThis.IntersectionObserver === 'undefined') {
|
||||
if (typeof Element !== 'undefined' && !Element.prototype.scrollIntoView)
|
||||
Element.prototype.scrollIntoView = function () { /* noop */ }
|
||||
|
||||
// Mock DOMRect.fromRect for tests (not available in jsdom)
|
||||
if (typeof DOMRect !== 'undefined' && typeof (DOMRect as typeof DOMRect & { fromRect?: unknown }).fromRect !== 'function') {
|
||||
(DOMRect as typeof DOMRect & { fromRect: (rect?: DOMRectInit) => DOMRect }).fromRect = (rect = {}) => new DOMRect(
|
||||
rect.x ?? 0,
|
||||
rect.y ?? 0,
|
||||
rect.width ?? 0,
|
||||
rect.height ?? 0,
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
// Wrap cleanup in act() to flush pending React scheduler work
|
||||
// This prevents "window is not defined" errors from React 19's scheduler
|
||||
|
||||
Reference in New Issue
Block a user