merge evaluation fe

This commit is contained in:
JzoNg 2026-05-05 20:54:02 +08:00
commit e1e17d8a51
76 changed files with 7534 additions and 1214 deletions

View File

@ -60,7 +60,8 @@ _file_access_controller = DatabaseFileAccessController()
LISTENING_RETRY_IN = 2000
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
RESTORE_SOURCE_WORKFLOW_MUST_BE_PUBLISHED_MESSAGE = "source workflow must be published"
MAX_WORKFLOW_ONLINE_USERS_QUERY_IDS = 50
MAX_WORKFLOW_ONLINE_USERS_REQUEST_IDS = 1000
WORKFLOW_ONLINE_USERS_REDIS_BATCH_SIZE = 50
# Register models for flask_restx to avoid dict type issues in Swagger
# Register in dependency order: base models first, then dependent models
@ -175,8 +176,13 @@ class WorkflowFeaturesPayload(BaseModel):
features: dict[str, Any] = Field(..., description="Workflow feature configuration")
class WorkflowOnlineUsersQuery(BaseModel):
app_ids: str = Field(..., description="Comma-separated app IDs")
class WorkflowOnlineUsersPayload(BaseModel):
app_ids: list[str] = Field(default_factory=list, description="App IDs")
@field_validator("app_ids")
@classmethod
def normalize_app_ids(cls, app_ids: list[str]) -> list[str]:
return list(dict.fromkeys(app_id.strip() for app_id in app_ids if app_id.strip()))
class DraftWorkflowTriggerRunPayload(BaseModel):
@ -204,7 +210,7 @@ reg(WorkflowListQuery)
reg(WorkflowUpdatePayload)
reg(WorkflowTypeConvertQuery)
reg(WorkflowFeaturesPayload)
reg(WorkflowOnlineUsersQuery)
reg(WorkflowOnlineUsersPayload)
reg(DraftWorkflowTriggerRunPayload)
reg(DraftWorkflowTriggerRunAllPayload)
@ -1496,19 +1502,19 @@ class DraftWorkflowTriggerRunAllApi(Resource):
@console_ns.route("/apps/workflows/online-users")
class WorkflowOnlineUsersApi(Resource):
@console_ns.expect(console_ns.models[WorkflowOnlineUsersQuery.__name__])
@console_ns.expect(console_ns.models[WorkflowOnlineUsersPayload.__name__])
@console_ns.doc("get_workflow_online_users")
@console_ns.doc(description="Get workflow online users")
@setup_required
@login_required
@account_initialization_required
@marshal_with(online_user_list_fields)
def get(self):
args = WorkflowOnlineUsersQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
def post(self):
args = WorkflowOnlineUsersPayload.model_validate(console_ns.payload or {})
app_ids = list(dict.fromkeys(app_id.strip() for app_id in args.app_ids.split(",") if app_id.strip()))
if len(app_ids) > MAX_WORKFLOW_ONLINE_USERS_QUERY_IDS:
raise BadRequest(f"Maximum {MAX_WORKFLOW_ONLINE_USERS_QUERY_IDS} app_ids are allowed per request.")
app_ids = args.app_ids
if len(app_ids) > MAX_WORKFLOW_ONLINE_USERS_REQUEST_IDS:
raise BadRequest(f"Maximum {MAX_WORKFLOW_ONLINE_USERS_REQUEST_IDS} app_ids are allowed per request.")
if not app_ids:
return {"data": []}
@ -1516,13 +1522,24 @@ class WorkflowOnlineUsersApi(Resource):
_, current_tenant_id = current_account_with_tenant()
workflow_service = WorkflowService()
accessible_app_ids = workflow_service.get_accessible_app_ids(app_ids, current_tenant_id)
ordered_accessible_app_ids = [app_id for app_id in app_ids if app_id in accessible_app_ids]
users_json_by_app_id: dict[str, Any] = {}
for start_index in range(0, len(ordered_accessible_app_ids), WORKFLOW_ONLINE_USERS_REDIS_BATCH_SIZE):
app_id_batch = ordered_accessible_app_ids[
start_index : start_index + WORKFLOW_ONLINE_USERS_REDIS_BATCH_SIZE
]
pipe = redis_client.pipeline(transaction=False)
for app_id in app_id_batch:
pipe.hgetall(f"{WORKFLOW_ONLINE_USERS_PREFIX}{app_id}")
users_json_batch = pipe.execute()
for app_id, users_json in zip(app_id_batch, users_json_batch):
users_json_by_app_id[app_id] = users_json
results = []
for app_id in app_ids:
if app_id not in accessible_app_ids:
continue
users_json = redis_client.hgetall(f"{WORKFLOW_ONLINE_USERS_PREFIX}{app_id}")
for app_id in ordered_accessible_app_ids:
users_json = users_json_by_app_id.get(app_id, {})
users = []
for _, user_info_json in users_json.items():

View File

@ -200,10 +200,11 @@ class RetrievalService:
"""Deduplicate documents in O(n) while preserving first-seen order.
Rules:
- For provider == "dify" and metadata["doc_id"] exists: keep the doc with the highest
metadata["score"] among duplicates; if a later duplicate has no score, ignore it.
- For non-dify documents (or dify without doc_id): deduplicate by content key
(provider, page_content), keeping the first occurrence.
- If metadata["doc_id"] exists (any provider): deduplicate by (provider, doc_id) key;
keep the doc with the highest metadata["score"] among duplicates. If a later duplicate
has no score, ignore it.
- If metadata["doc_id"] is absent: deduplicate by content key (provider, page_content),
keeping the first occurrence.
"""
if not documents:
return documents
@ -214,11 +215,10 @@ class RetrievalService:
order: list[tuple] = []
for doc in documents:
is_dify = doc.provider == "dify"
doc_id = (doc.metadata or {}).get("doc_id") if is_dify else None
doc_id = (doc.metadata or {}).get("doc_id")
if is_dify and doc_id:
key = ("dify", doc_id)
if doc_id:
key = (doc.provider or "dify", doc_id)
if key not in chosen:
chosen[key] = doc
order.append(key)

View File

@ -6,7 +6,7 @@ requires-python = "~=3.12.0"
dependencies = [
# Legacy: mature and widely deployed
"bleach>=6.3.0",
"boto3>=1.42.96",
"boto3>=1.43.3",
"celery>=5.6.3",
"croniter>=6.2.2",
"flask>=3.1.3,<4.0.0",
@ -127,7 +127,7 @@ dev = [
"testcontainers>=4.14.2",
"types-aiofiles>=25.1.0",
"types-beautifulsoup4>=4.12.0",
"types-cachetools>=6.2.0",
"types-cachetools>=7.0.0.20260503",
"types-colorama>=0.4.15",
"types-defusedxml>=0.7.0",
"types-deprecated>=1.3.1",
@ -135,7 +135,7 @@ dev = [
"types-flask-cors>=6.0.0",
"types-flask-migrate>=4.1.0",
"types-gevent>=26.4.0",
"types-greenlet>=3.4.0",
"types-greenlet>=3.5.0.20260428",
"types-html5lib>=1.1.11",
"types-markdown>=3.10.2",
"types-oauthlib>=3.3.0",
@ -143,7 +143,7 @@ dev = [
"types-olefile>=0.47.0",
"types-openpyxl>=3.1.5",
"types-pexpect>=4.9.0",
"types-protobuf>=7.34.1",
"types-protobuf>=7.34.1.20260503",
"types-psutil>=7.2.2",
"types-psycopg2>=2.9.21.20260422",
"types-pygments>=2.20.0",
@ -158,11 +158,11 @@ dev = [
"types-tensorflow>=2.18.0.20260408",
"types-tqdm>=4.67.3.20260408",
"types-ujson>=5.10.0",
"boto3-stubs>=1.42.96",
"boto3-stubs>=1.43.2",
"types-jmespath>=1.1.0.20260408",
"hypothesis>=6.152.3",
"hypothesis>=6.152.4",
"types_pyOpenSSL>=24.1.0",
"types_cffi>=2.0.0.20260408",
"types_cffi>=2.0.0.20260429",
"types_setuptools>=82.0.0.20260408",
"pandas-stubs>=3.0.0",
"scipy-stubs>=1.17.1.4",
@ -184,7 +184,7 @@ dev = [
############################################################
storage = [
"azure-storage-blob>=12.28.0",
"bce-python-sdk>=0.9.70",
"bce-python-sdk>=0.9.71",
"cos-python-sdk-v5>=1.9.42",
"esdk-obs-python>=3.22.2",
"google-cloud-storage>=3.10.1",

View File

@ -470,7 +470,8 @@ def test_workflow_online_users_filters_inaccessible_workflow(app, monkeypatch: p
)
monkeypatch.setattr(workflow_module.file_helpers, "get_signed_file_url", sign_avatar)
workflow_module.redis_client.hgetall.side_effect = lambda key: (
redis_pipeline = Mock()
redis_pipeline.execute.return_value = [
{
b"sid-1": json.dumps(
{
@ -481,16 +482,16 @@ def test_workflow_online_users_filters_inaccessible_workflow(app, monkeypatch: p
}
)
}
if key == f"{workflow_module.WORKFLOW_ONLINE_USERS_PREFIX}{app_id_1}"
else {}
)
]
workflow_module.redis_client.pipeline.return_value = redis_pipeline
api = workflow_module.WorkflowOnlineUsersApi()
handler = _unwrap(api.get)
handler = _unwrap(api.post)
with app.test_request_context(
f"/apps/workflows/online-users?app_ids={app_id_1},{app_id_2}",
method="GET",
"/apps/workflows/online-users",
method="POST",
json={"app_ids": [app_id_1, app_id_2]},
):
response = handler(api)
@ -509,12 +510,43 @@ def test_workflow_online_users_filters_inaccessible_workflow(app, monkeypatch: p
}
]
}
workflow_module.redis_client.hgetall.assert_called_once_with(
f"{workflow_module.WORKFLOW_ONLINE_USERS_PREFIX}{app_id_1}"
)
workflow_module.redis_client.pipeline.assert_called_once_with(transaction=False)
redis_pipeline.hgetall.assert_called_once_with(f"{workflow_module.WORKFLOW_ONLINE_USERS_PREFIX}{app_id_1}")
redis_pipeline.execute.assert_called_once_with()
sign_avatar.assert_called_once_with("avatar-file-id")
def test_workflow_online_users_batches_redis_reads(app, monkeypatch: pytest.MonkeyPatch) -> None:
app_ids = [f"wf-{index}" for index in range(workflow_module.WORKFLOW_ONLINE_USERS_REDIS_BATCH_SIZE + 1)]
monkeypatch.setattr(workflow_module, "current_account_with_tenant", lambda: (SimpleNamespace(), "tenant-1"))
monkeypatch.setattr(
workflow_module,
"WorkflowService",
lambda: SimpleNamespace(get_accessible_app_ids=lambda app_ids, tenant_id: set(app_ids)),
)
first_pipeline = Mock()
first_pipeline.execute.return_value = [{} for _ in range(workflow_module.WORKFLOW_ONLINE_USERS_REDIS_BATCH_SIZE)]
second_pipeline = Mock()
second_pipeline.execute.return_value = [{}]
workflow_module.redis_client.pipeline.side_effect = [first_pipeline, second_pipeline]
api = workflow_module.WorkflowOnlineUsersApi()
handler = _unwrap(api.post)
with app.test_request_context(
"/apps/workflows/online-users",
method="POST",
json={"app_ids": app_ids},
):
response = handler(api)
assert len(response["data"]) == len(app_ids)
assert workflow_module.redis_client.pipeline.call_count == 2
assert first_pipeline.hgetall.call_count == workflow_module.WORKFLOW_ONLINE_USERS_REDIS_BATCH_SIZE
assert second_pipeline.hgetall.call_count == 1
def test_workflow_online_users_rejects_excessive_workflow_ids(app, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(workflow_module, "current_account_with_tenant", lambda: (SimpleNamespace(), "tenant-1"))
accessible_app_ids = Mock(return_value=set())
@ -524,14 +556,15 @@ def test_workflow_online_users_rejects_excessive_workflow_ids(app, monkeypatch:
lambda: SimpleNamespace(get_accessible_app_ids=accessible_app_ids),
)
excessive_ids = ",".join(f"wf-{index}" for index in range(workflow_module.MAX_WORKFLOW_ONLINE_USERS_QUERY_IDS + 1))
excessive_ids = [f"wf-{index}" for index in range(workflow_module.MAX_WORKFLOW_ONLINE_USERS_REQUEST_IDS + 1)]
api = workflow_module.WorkflowOnlineUsersApi()
handler = _unwrap(api.get)
handler = _unwrap(api.post)
with app.test_request_context(
f"/apps/workflows/online-users?app_ids={excessive_ids}",
method="GET",
"/apps/workflows/online-users",
method="POST",
json={"app_ids": excessive_ids},
):
with pytest.raises(HTTPException) as exc:
handler(api)

View File

@ -1106,11 +1106,11 @@ class TestRetrievalService:
def test_deduplicate_documents_non_dify_provider(self):
"""
Test deduplication with non-dify provider documents.
Test deduplication with non-dify provider documents that have no doc_id.
Verifies:
- External provider documents use content-based deduplication
- Different providers are handled correctly
- External provider documents without doc_id use content-based deduplication
- Identical content from the same provider is collapsed to one result
"""
# Arrange
doc1 = Document(
@ -1131,7 +1131,96 @@ class TestRetrievalService:
# Assert
# External documents without doc_id should use content-based dedup
assert len(result) >= 1
assert len(result) == 1
def test_deduplicate_documents_non_dify_provider_with_doc_id_different_sources(self):
"""
Regression test for issue #35707.
Two chunks from different source documents share identical text content but carry
different doc_ids. Before the fix, non-dify providers were forced into content-based
deduplication and the second chunk was silently dropped. After the fix, doc_id is used
as the dedup key for any provider that exposes it, so both chunks must be retained.
Verifies:
- Non-dify provider documents with different doc_ids are NOT deduplicated even when
their page_content is identical.
"""
# Arrange — same content, different doc_ids, non-dify provider (e.g. Weaviate / Qdrant)
doc_a = Document(
page_content="Shared identical content",
metadata={"doc_id": "doc-from-file-a", "score": 0.85},
provider="weaviate",
)
doc_b = Document(
page_content="Shared identical content",
metadata={"doc_id": "doc-from-file-b", "score": 0.82},
provider="weaviate",
)
# Act
result = RetrievalService._deduplicate_documents([doc_a, doc_b])
# Assert — both documents must be kept; losing either silently drops a source citation
assert len(result) == 2
doc_ids = {doc.metadata["doc_id"] for doc in result}
assert doc_ids == {"doc-from-file-a", "doc-from-file-b"}
def test_deduplicate_documents_non_dify_provider_with_same_doc_id(self):
"""
Test that non-dify provider documents sharing the same doc_id are deduplicated by
doc_id key (not by content), and the higher-scored duplicate is retained.
Verifies:
- doc_id-based deduplication now applies to any provider, not only "dify"
- The document with the highest score wins when doc_ids collide
"""
# Arrange
doc_low = Document(
page_content="Content A",
metadata={"doc_id": "chunk-1", "score": 0.5},
provider="qdrant",
)
doc_high = Document(
page_content="Content A",
metadata={"doc_id": "chunk-1", "score": 0.9},
provider="qdrant",
)
# Act
result = RetrievalService._deduplicate_documents([doc_low, doc_high])
# Assert
assert len(result) == 1
assert result[0].metadata["score"] == 0.9
def test_deduplicate_documents_dify_provider_without_doc_id_falls_back_to_content(self):
"""
Test that a dify provider document without doc_id still falls back to content-based
deduplication (no regression from original behaviour).
Verifies:
- Absence of doc_id triggers content-based dedup regardless of provider
- First occurrence is kept when content is identical
"""
# Arrange — dify docs with no doc_id, same content
doc1 = Document(
page_content="Same content",
metadata={"score": 0.8},
provider="dify",
)
doc2 = Document(
page_content="Same content",
metadata={"score": 0.9},
provider="dify",
)
# Act
result = RetrievalService._deduplicate_documents([doc1, doc2])
# Assert — collapsed to one; first-seen wins (no score comparison in content branch)
assert len(result) == 1
assert result[0].metadata["score"] == 0.8
# ==================== Metadata Filtering Tests ====================

82
api/uv.lock generated
View File

@ -490,7 +490,7 @@ wheels = [
[[package]]
name = "bce-python-sdk"
version = "0.9.70"
version = "0.9.71"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "crc32c" },
@ -498,9 +498,9 @@ dependencies = [
{ name = "pycryptodome" },
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f7/a9/7c21a9073eb9ad7e8cacf6f8a0e47c0d01ad7bf8fd8e0dc42164b117d60b/bce_python_sdk-0.9.70.tar.gz", hash = "sha256:3b37fd7448278dd33f745a6a23198a2cc2490fded9cb8d59b72500784853df4e", size = 299967, upload-time = "2026-04-14T12:02:42.034Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/74/72058f098b9e7184376f2b3d4c1d233ca7fdc52d0f527078f3ce4d9828b9/bce_python_sdk-0.9.71.tar.gz", hash = "sha256:7a917edaee39082694776e25a9e6556ec8072400a3be649f28eb13f9c7a0b5b5", size = 301508, upload-time = "2026-04-28T06:23:21.061Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/2d/70fc866ff98d1f6bd75b0a4235694129b3c519b014254d7bcfc02ffe1bee/bce_python_sdk-0.9.70-py3-none-any.whl", hash = "sha256:fd1f31113e4a8dca314f040662b7caf07ec11cf896c5da232627a9a2c9d2e3a1", size = 415660, upload-time = "2026-04-14T12:02:40.034Z" },
{ url = "https://files.pythonhosted.org/packages/2d/2d/821ae8878dc36b77e56bb7e5dbf9a8e73209c11d38c0ba6b38b5778668ae/bce_python_sdk-0.9.71-py3-none-any.whl", hash = "sha256:9f64a99267616456bac487983d92cc778720bf4f102c8931e8e38aea3cb63268", size = 417000, upload-time = "2026-04-28T06:23:19.078Z" },
]
[[package]]
@ -613,29 +613,29 @@ wheels = [
[[package]]
name = "boto3"
version = "1.42.96"
version = "1.43.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a6/2d/69fb3acd50bab83fb295c167d33c4b653faeb5fb0f42bfca4d9b69d6fb68/boto3-1.42.96.tar.gz", hash = "sha256:b38a9e4a3fbbee9017252576f1379780d0a5814768676c08df2f539d31fcdd68", size = 113203, upload-time = "2026-04-24T19:47:18.677Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f2/50/ea184e159c4ac64fef816a72094fb8656eb071361a39ed22c0e3b15a35b4/boto3-1.43.3.tar.gz", hash = "sha256:7c7777862ffc898f05efa566032bbabfe226dbb810e35ec11125817f128bc5c5", size = 113111, upload-time = "2026-05-04T19:34:09.731Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2b/9d/b3f617d011c42eb804d993103b8fa9acdce153e181a3042f58bfe33d7cb4/boto3-1.42.96-py3-none-any.whl", hash = "sha256:2f4566da2c209a98bdbfc874d813ef231c84ad24e4f815e9bc91de5f63351a24", size = 140557, upload-time = "2026-04-24T19:47:15.824Z" },
{ url = "https://files.pythonhosted.org/packages/c8/ad/8a6946a329f0127322108e537dc1c0d9f8eea4f1d1231702c073d2e85f46/boto3-1.43.3-py3-none-any.whl", hash = "sha256:fb9fe51849ef2a78198d582756fc06f14f7de27f73e0fa90275d6aa4171eb4d0", size = 140501, upload-time = "2026-05-04T19:34:07.991Z" },
]
[[package]]
name = "boto3-stubs"
version = "1.42.96"
version = "1.43.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore-stubs" },
{ name = "types-s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/77/86/65f45f84621cccc2471871088bab8fe515b4346ba9e48d9001484ec440d6/boto3_stubs-1.42.96.tar.gz", hash = "sha256:1e7819c34d1eae8e5e3cfaf9d144fdcad65aad184b380488871de1d0b2851879", size = 102691, upload-time = "2026-04-24T20:25:13.984Z" }
sdist = { url = "https://files.pythonhosted.org/packages/8a/7f/399bcdeaa60a89aafe5292c8364c313177d22b886dffc1bd7b56fe817900/boto3_stubs-1.43.2.tar.gz", hash = "sha256:0d46636f3e761a92070114b39a76b154c5da6c5794c890e1440a7f191bf1ff2e", size = 102658, upload-time = "2026-05-01T20:31:36.963Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/51/bdac1ff9fd4321091183776c5adffce5fc7b4d0fec7e38af9064e24a2497/boto3_stubs-1.42.96-py3-none-any.whl", hash = "sha256:2c112e257f40006147a53f6f62075804689154271973b2807f5656feaa804216", size = 70668, upload-time = "2026-04-24T20:25:09.736Z" },
{ url = "https://files.pythonhosted.org/packages/da/df/17647562444b2047ca325eaaf2fea738571822b7b4efdaa6bacf0fd4fff9/boto3_stubs-1.43.2-py3-none-any.whl", hash = "sha256:941f2907236223a1209704eaf708d3cdf1ecc8695618c558f9fb9e23e90c513b", size = 70653, upload-time = "2026-05-01T20:31:30.057Z" },
]
[package.optional-dependencies]
@ -645,16 +645,16 @@ bedrock-runtime = [
[[package]]
name = "botocore"
version = "1.42.96"
version = "1.43.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jmespath" },
{ name = "python-dateutil" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/77/2c333622a1d47cf5bf73cdcab0cb6c92addafbef2ec05f81b9f75687d9e5/botocore-1.42.96.tar.gz", hash = "sha256:75b3b841ffacaa944f645196655a21ca777591dd8911e732bfb6614545af0250", size = 15263344, upload-time = "2026-04-24T19:47:05.283Z" }
sdist = { url = "https://files.pythonhosted.org/packages/74/ac/cd55f886e17b6b952dbc95b792d3645a73d58586a1400ababe54406073bd/botocore-1.43.3.tar.gz", hash = "sha256:eac6da0fffccf87888ebf4d89f0b2378218a707efa748cd955b838995e944695", size = 15308705, upload-time = "2026-05-04T19:33:56.28Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/45/56/152c3a859ca1b9d77ed16deac3cf81682013677c68cf5715698781fc81bd/botocore-1.42.96-py3-none-any.whl", hash = "sha256:db2c3e2006628be6fde81a24124a6563c363d6982fb92728837cf174bad9d98a", size = 14945920, upload-time = "2026-04-24T19:47:00.323Z" },
{ url = "https://files.pythonhosted.org/packages/be/99/1d9e296edf244f47e0508032f20999f8fd40704dd3c5b601fed099424eb6/botocore-1.43.3-py3-none-any.whl", hash = "sha256:ec0769eb0f7c5034856bb406a92698dbc02a3d4be0f78a384747106b161d8ea3", size = 14989027, upload-time = "2026-05-04T19:33:50.81Z" },
]
[[package]]
@ -1669,7 +1669,7 @@ requires-dist = [
{ name = "aliyun-log-python-sdk", specifier = ">=0.9.44,<1.0.0" },
{ name = "azure-identity", specifier = ">=1.25.3,<2.0.0" },
{ name = "bleach", specifier = ">=6.3.0" },
{ name = "boto3", specifier = ">=1.42.96" },
{ name = "boto3", specifier = ">=1.43.3" },
{ name = "celery", specifier = ">=5.6.3" },
{ name = "croniter", specifier = ">=6.2.2" },
{ name = "fastopenapi", extras = ["flask"], specifier = "~=0.7.0" },
@ -1710,12 +1710,12 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
{ name = "basedpyright", specifier = ">=1.39.3" },
{ name = "boto3-stubs", specifier = ">=1.42.96" },
{ name = "boto3-stubs", specifier = ">=1.43.2" },
{ name = "celery-types", specifier = ">=0.23.0" },
{ name = "coverage", specifier = ">=7.13.4" },
{ name = "dotenv-linter", specifier = ">=0.7.0" },
{ name = "faker", specifier = ">=40.15.0" },
{ name = "hypothesis", specifier = ">=6.152.3" },
{ name = "hypothesis", specifier = ">=6.152.4" },
{ name = "import-linter", specifier = ">=2.3" },
{ name = "lxml-stubs", specifier = ">=0.5.1" },
{ name = "mypy", specifier = ">=1.20.2" },
@ -1733,8 +1733,8 @@ dev = [
{ name = "testcontainers", specifier = ">=4.14.2" },
{ name = "types-aiofiles", specifier = ">=25.1.0" },
{ name = "types-beautifulsoup4", specifier = ">=4.12.0" },
{ name = "types-cachetools", specifier = ">=6.2.0" },
{ name = "types-cffi", specifier = ">=2.0.0.20260408" },
{ name = "types-cachetools", specifier = ">=7.0.0.20260503" },
{ name = "types-cffi", specifier = ">=2.0.0.20260429" },
{ name = "types-colorama", specifier = ">=0.4.15" },
{ name = "types-defusedxml", specifier = ">=0.7.0" },
{ name = "types-deprecated", specifier = ">=1.3.1" },
@ -1742,7 +1742,7 @@ dev = [
{ name = "types-flask-cors", specifier = ">=6.0.0" },
{ name = "types-flask-migrate", specifier = ">=4.1.0" },
{ name = "types-gevent", specifier = ">=26.4.0" },
{ name = "types-greenlet", specifier = ">=3.4.0" },
{ name = "types-greenlet", specifier = ">=3.5.0.20260428" },
{ name = "types-html5lib", specifier = ">=1.1.11" },
{ name = "types-jmespath", specifier = ">=1.1.0.20260408" },
{ name = "types-markdown", specifier = ">=3.10.2" },
@ -1751,7 +1751,7 @@ dev = [
{ name = "types-olefile", specifier = ">=0.47.0" },
{ name = "types-openpyxl", specifier = ">=3.1.5" },
{ name = "types-pexpect", specifier = ">=4.9.0" },
{ name = "types-protobuf", specifier = ">=7.34.1" },
{ name = "types-protobuf", specifier = ">=7.34.1.20260503" },
{ name = "types-psutil", specifier = ">=7.2.2" },
{ name = "types-psycopg2", specifier = ">=2.9.21.20260422" },
{ name = "types-pygments", specifier = ">=2.20.0" },
@ -1778,7 +1778,7 @@ evaluation = [
]
storage = [
{ name = "azure-storage-blob", specifier = ">=12.28.0" },
{ name = "bce-python-sdk", specifier = ">=0.9.70" },
{ name = "bce-python-sdk", specifier = ">=0.9.71" },
{ name = "cos-python-sdk-v5", specifier = ">=1.9.42" },
{ name = "esdk-obs-python", specifier = ">=3.22.2" },
{ name = "google-cloud-storage", specifier = ">=3.10.1" },
@ -3437,14 +3437,14 @@ wheels = [
[[package]]
name = "hypothesis"
version = "6.152.3"
version = "6.152.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "sortedcontainers" },
]
sdist = { url = "https://files.pythonhosted.org/packages/70/90/fc0b263b6f2622e5f8d2aa93f2e95ba79718a5faa7d2a74bfab10d6b0905/hypothesis-6.152.3.tar.gz", hash = "sha256:c4e5300d3755b6c8a270a28fe5abff40153e927328e89d2bb0229c1384618998", size = 466478, upload-time = "2026-04-26T17:31:07.657Z" }
sdist = { url = "https://files.pythonhosted.org/packages/fa/c7/3147bd903d6b18324a016d43a259cf5b4bb4545e1ead6773dc8a0374e70a/hypothesis-6.152.4.tar.gz", hash = "sha256:31c8f9ce619716f543e2710b489b1633c833586641d9e6c94cee03f109a5afc4", size = 466444, upload-time = "2026-04-27T20:18:37.594Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/90/38/15475b91a4c12721d2be3349e9d6cf8649c76ed9bc1287e2de7c8d06c261/hypothesis-6.152.3-py3-none-any.whl", hash = "sha256:4b47f00916c858ed49cf870a2f08b04e5fff5afae0bb78f3b4a6d9c74fd6c7bc", size = 532154, upload-time = "2026-04-26T17:31:04.42Z" },
{ url = "https://files.pythonhosted.org/packages/19/89/0f50dd0d92e8a7dffc24f69ab910ff81db89b2f082ba42682bd57695e4d2/hypothesis-6.152.4-py3-none-any.whl", hash = "sha256:e730fd93c7578182efadc7f90b3c5437ee4d55edf738930eb5043c81ac1d97e8", size = 532145, upload-time = "2026-04-27T20:18:35.043Z" },
]
[[package]]
@ -4314,11 +4314,11 @@ wheels = [
[[package]]
name = "mypy-boto3-bedrock-runtime"
version = "1.42.42"
version = "1.43.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/46/bb/65dc1b2c5796a6ab5f60bdb57343bd6c3ecb82251c580eca415c8548333e/mypy_boto3_bedrock_runtime-1.42.42.tar.gz", hash = "sha256:3a4088218478b6fbbc26055c03c95bee4fc04624a801090b3cce3037e8275c8d", size = 29840, upload-time = "2026-02-04T20:53:05.999Z" }
sdist = { url = "https://files.pythonhosted.org/packages/21/f2/61519c0162307b1e4d47f63ed8b25390874640934f3d2d25c5d6c5078dd8/mypy_boto3_bedrock_runtime-1.43.0.tar.gz", hash = "sha256:19fc3167de6e66dd7a0ab293adc55c93e2fd67be35e8ab4fc3a7523a380752ce", size = 29903, upload-time = "2026-04-29T22:57:57.561Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/43/7ea062f2228f47b5779dcfa14dab48d6e29f979b35d1a5102b0ba80b9c1b/mypy_boto3_bedrock_runtime-1.42.42-py3-none-any.whl", hash = "sha256:b2d16eae22607d0685f90796b3a0afc78c0b09d45872e00eafd634a31dd9358f", size = 36077, upload-time = "2026-02-04T20:53:01.768Z" },
{ url = "https://files.pythonhosted.org/packages/40/4d/7e4c4d55af23b2b1304d6814db8c406beab7977056963200230417c1a2db/mypy_boto3_bedrock_runtime-1.43.0-py3-none-any.whl", hash = "sha256:a125296f992093d58bdcd95176002680fa81ca8a8b8bdf02afad7e5f2d8966aa", size = 36172, upload-time = "2026-04-29T22:57:54.777Z" },
]
[[package]]
@ -6369,14 +6369,14 @@ wheels = [
[[package]]
name = "s3transfer"
version = "0.16.0"
version = "0.17.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
]
sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" }
sdist = { url = "https://files.pythonhosted.org/packages/9b/ec/7c692cde9125b77e84b307354d4fb705f98b8ccad59a036d5957ca75bfc3/s3transfer-0.17.0.tar.gz", hash = "sha256:9edeb6d1c3c2f89d6050348548834ad8289610d886e5bf7b7207728bd43ce33a", size = 155337, upload-time = "2026-04-29T22:07:36.33Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" },
{ url = "https://files.pythonhosted.org/packages/87/72/c6c32d2b657fa3dad1de340254e14390b1e334ce38268b7ad51abda3c8c2/s3transfer-0.17.0-py3-none-any.whl", hash = "sha256:ce3801712acf4ad3e89fb9990df97b4972e93f4b3b0004d214be5bce12814c20", size = 86811, upload-time = "2026-04-29T22:07:34.966Z" },
]
[[package]]
@ -7049,23 +7049,23 @@ wheels = [
[[package]]
name = "types-cachetools"
version = "6.2.0.20260408"
version = "7.0.0.20260503"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ec/61/475b0e8f4a92e5e33affcc6f4e6344c6dee540824021d22f695ea170da63/types_cachetools-6.2.0.20260408.tar.gz", hash = "sha256:0d8ae2dd5ba0b4cfe6a55c34396dd0415f1be07d0033d84781cdc4ed9c2ebc6b", size = 9854, upload-time = "2026-04-08T04:31:49.665Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ec/57/5d3b8b3e66b002911ec1274e87f904eeee1d843c8713d95476c25c29cf31/types_cachetools-7.0.0.20260503.tar.gz", hash = "sha256:dfa4dcdf453f397dfc6d69fc0a57423ac1f248393f70aa56b5d05fac2df7a96c", size = 10033, upload-time = "2026-05-03T05:19:54.128Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bb/7d/579f50f4f004ee93c7d1baa95339591cac1fe02f4e3fb8fc0f900ee4a80f/types_cachetools-6.2.0.20260408-py3-none-any.whl", hash = "sha256:470e0b274737feae74beed3d764885bf4664002ecc393fba3778846b13ce92cb", size = 9350, upload-time = "2026-04-08T04:31:48.826Z" },
{ url = "https://files.pythonhosted.org/packages/3d/a8/84562723d9a3572e0851d82bdea6bed5a7dc033c6bd648f492c76b8c4ac8/types_cachetools-7.0.0.20260503-py3-none-any.whl", hash = "sha256:011b4fe0e85ef05c4a2471a4fda40254a78746b501cc1727359233872bb3a4e9", size = 9493, upload-time = "2026-05-03T05:19:53.124Z" },
]
[[package]]
name = "types-cffi"
version = "2.0.0.20260408"
version = "2.0.0.20260429"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "types-setuptools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/64/67/eb4ef3408fdc0b4e5af38b30c0e6ad4663b41bdae9fb85a9f09a8db61a99/types_cffi-2.0.0.20260408.tar.gz", hash = "sha256:aa8b9c456ab715c079fc655929811f21f331bfb940f4a821987c581bf4e36230", size = 17541, upload-time = "2026-04-08T04:36:03.918Z" }
sdist = { url = "https://files.pythonhosted.org/packages/0c/7d/56b9be8b0f9dfbffb7c73e248aacf178693ff3c6cf765b77c43a1e886e04/types_cffi-2.0.0.20260429.tar.gz", hash = "sha256:afe7d9777a2921139623af0b94647637a5bd0b938b77ec125e5e5e068a1727bd", size = 17562, upload-time = "2026-04-29T05:16:43.29Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c3/a3/7fbd93ededcc7c77e9e5948b9794161733ebdbf618a27965b1bea0e728a4/types_cffi-2.0.0.20260408-py3-none-any.whl", hash = "sha256:68bd296742b4ff7c0afe3547f50bd0acc55416ecf322ffefd2b7344ef6388a42", size = 20101, upload-time = "2026-04-08T04:36:02.995Z" },
{ url = "https://files.pythonhosted.org/packages/b8/2c/79fa47a70d534f63a54b6d22e28cc842f8c6d9ebec93048355b0020bc7a9/types_cffi-2.0.0.20260429-py3-none-any.whl", hash = "sha256:6a4237bfdbd50e4d0726929070d8b9983bde541726a5a6fe0e8e24e78c1b3826", size = 20103, upload-time = "2026-04-29T05:16:42.155Z" },
]
[[package]]
@ -7144,11 +7144,11 @@ wheels = [
[[package]]
name = "types-greenlet"
version = "3.4.0.20260409"
version = "3.5.0.20260428"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/27/a6/668751bc864efe820e1eb12c2a77f9e62537f433cc002e483ad01badb04b/types_greenlet-3.4.0.20260409.tar.gz", hash = "sha256:81d2cf628934a16856bb9e54136def8de5356e934f0ad5d5474f219a0c5cb205", size = 8976, upload-time = "2026-04-09T04:22:31.693Z" }
sdist = { url = "https://files.pythonhosted.org/packages/79/50/d255c0e068679d7b9441d9408424ddf9e1f35620548e121003b3660af526/types_greenlet-3.5.0.20260428.tar.gz", hash = "sha256:6c188f5e9c5775d50bd00780a3eb1fb3cde17c396cf9703e3d417936e9e7a082", size = 9003, upload-time = "2026-04-28T05:19:43.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4f/3f/c8a4d8782f78fccb4b5fe91c5eae2efce6648072754bc7096b1e3b5407ad/types_greenlet-3.4.0.20260409-py3-none-any.whl", hash = "sha256:cbceadb4594eccd95b57b3f7fa8a9b851488f5e6c05026f4a3db9aac02ec8333", size = 8812, upload-time = "2026-04-09T04:22:30.734Z" },
{ url = "https://files.pythonhosted.org/packages/30/e5/5ff280f02392ced53cb5e866b660b492b4245b1395a61e57d2a6dc02977b/types_greenlet-3.5.0.20260428-py3-none-any.whl", hash = "sha256:7b0f23ce84ee93474d4aa8058920f0578181e11431be92ce9a4ad4123de2c41b", size = 8809, upload-time = "2026-04-28T05:19:41.976Z" },
]
[[package]]
@ -7228,11 +7228,11 @@ wheels = [
[[package]]
name = "types-protobuf"
version = "7.34.1.20260408"
version = "7.34.1.20260503"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5b/b1/4521e68c2cc17703d80eb42796751345376dd4c706f84007ef5e7c707774/types_protobuf-7.34.1.20260408.tar.gz", hash = "sha256:e2c0a0430e08c75b52671a6f0035abfdcc791aad12af16274282de1b721758ab", size = 68835, upload-time = "2026-04-08T04:26:43.613Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a0/31/87969cb3e62287bde7598b78b3c098d2873d54f5fb5a7cfbcaa73b8c965e/types_protobuf-7.34.1.20260503.tar.gz", hash = "sha256:effbc819aa17e02448dde99f089c6794662d66f4b2797e922f185ffe0b24e766", size = 68830, upload-time = "2026-05-03T05:19:50.739Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/b5/0bc9874d89c58fb0ce851e150055ce732d254dbb10b06becbc7635d0d635/types_protobuf-7.34.1.20260408-py3-none-any.whl", hash = "sha256:ebbcd4e27b145aef6a59bc0cb6c013b3528151c1ba5e7f7337aeee355d276a5e", size = 86012, upload-time = "2026-04-08T04:26:42.566Z" },
{ url = "https://files.pythonhosted.org/packages/f9/67/a33fb18090a927794a5ee4b1a30730b528ace0dad6b18932540d21258184/types_protobuf-7.34.1.20260503-py3-none-any.whl", hash = "sha256:75fd66121d56785c91828b8bf7b511f39ba847f11e682573e41847f01e9cd1de", size = 86019, upload-time = "2026-05-03T05:19:49.486Z" },
]
[[package]]

5672
eslint-suppressions.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -9,7 +9,7 @@ import Billing from '@/app/components/billing/billing-page'
import { defaultPlan, NUM_INFINITE } from '@/app/components/billing/config'
import HeaderBillingBtn from '@/app/components/billing/header-billing-btn'
import PlanComp from '@/app/components/billing/plan'
import PlanUpgradeModal from '@/app/components/billing/plan-upgrade-modal'
import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal'
import PriorityLabel from '@/app/components/billing/priority-label'
import TriggerEventsLimitModal from '@/app/components/billing/trigger-events-limit-modal'
import { Plan } from '@/app/components/billing/type'

View File

@ -232,15 +232,16 @@ const List: FC<Props> = ({
? t('newApp.noAppsFound', { ns: 'app' })
: t('tabs.noSnippetsFound', { ns: 'workflow' })
const pages = useMemo(() => data?.pages ?? [], [data?.pages])
const appIds = useMemo(() => {
const ids = new Set<string>()
pages.forEach((page) => {
page.data?.forEach((app) => {
if (app.id)
ids.add(app.id)
const workflowOnlineUserAppIds = useMemo(() => {
const appIds = new Set<string>()
pages.forEach(({ data: apps }) => {
apps.forEach((app) => {
if (app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT)
appIds.add(app.id)
})
})
return Array.from(ids)
return Array.from(appIds)
}, [pages])
const refreshWorkflowOnlineUsers = useCallback(async () => {
@ -249,19 +250,19 @@ const List: FC<Props> = ({
return
}
if (!appIds.length) {
if (!workflowOnlineUserAppIds.length) {
setWorkflowOnlineUsersMap({})
return
}
try {
const onlineUsersMap = await fetchWorkflowOnlineUsers({ appIds })
const onlineUsersMap = await fetchWorkflowOnlineUsers({ appIds: workflowOnlineUserAppIds })
setWorkflowOnlineUsersMap(onlineUsersMap)
}
catch {
setWorkflowOnlineUsersMap({})
}
}, [appIds, systemFeatures.enable_collaboration_mode])
}, [systemFeatures.enable_collaboration_mode, workflowOnlineUserAppIds])
useEffect(() => {
void refreshWorkflowOnlineUsers()

View File

@ -0,0 +1,75 @@
'use client'
import type { ComponentType, ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import styles from './style.module.css'
type UpgradeModalClassNames = {
content?: string
heroOverlay?: string
body?: string
icon?: string
copy?: string
title?: string
description?: string
footer?: string
}
type UpgradeModalProps = {
open: boolean
onOpenChange?: (open: boolean) => void
Icon?: ComponentType<{ className?: string }>
title: ReactNode
description: ReactNode
extraInfo?: ReactNode
footer: ReactNode
classNames?: UpgradeModalClassNames
}
export function UpgradeModal({
open,
onOpenChange,
Icon,
title,
description,
extraInfo,
footer,
classNames,
}: UpgradeModalProps) {
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
>
<DialogContent className={cn(styles.surface, 'w-[580px] max-w-[480px] overflow-hidden rounded-2xl p-0', classNames?.content)}>
<div className="relative">
<div
aria-hidden
className={cn(styles.heroOverlay, 'pointer-events-none absolute inset-0', classNames?.heroOverlay)}
/>
<div className={cn('px-8 pt-8', classNames?.body)}>
{Icon && (
<div className={cn(styles.icon, 'flex size-12 items-center justify-center rounded-xl shadow-lg backdrop-blur-[5px]', classNames?.icon)}>
<Icon className="size-6 text-text-primary-on-surface" />
</div>
)}
<div className={cn('mt-6 space-y-2', classNames?.copy)}>
<DialogTitle className={cn(styles.highlight, 'title-3xl-semi-bold', classNames?.title)}>
{title}
</DialogTitle>
<DialogDescription className={cn('system-md-regular text-text-tertiary', classNames?.description)}>
{description}
</DialogDescription>
</div>
{extraInfo}
</div>
</div>
<div className={cn('mt-10 mb-8 flex justify-end space-x-2 px-8', classNames?.footer)}>
{footer}
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -1,19 +1,9 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import PlanUpgradeModal from '../index'
import { PlanUpgradeModal } from '../index'
const mockSetShowPricingModal = vi.fn()
vi.mock('@/app/components/base/modal', () => {
const MockModal = ({ isShow, children }: { isShow: boolean, children: React.ReactNode }) => (
isShow ? <div data-testid="plan-upgrade-modal">{children}</div> : null
)
return {
default: MockModal,
}
})
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowPricingModal: mockSetShowPricingModal,
@ -70,6 +60,16 @@ describe('PlanUpgradeModal', () => {
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should call onClose when dialog requests close', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
renderComponent({ onClose })
await user.keyboard('{Escape}')
expect(onClose).toHaveBeenCalledTimes(1)
})
// Upgrade path uses provided callback over pricing modal
it('should call onUpgrade and onClose when upgrade button is clicked with onUpgrade provided', async () => {
const user = userEvent.setup()

View File

@ -1,26 +1,24 @@
'use client'
import type { FC } from 'react'
import type { ComponentType, ReactNode } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import Modal from '@/app/components/base/modal'
import { UpgradeModal } from '@/app/components/base/upgrade-modal'
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
import { useModalContext } from '@/context/modal-context'
import { SquareChecklist } from '../../base/icons/src/vender/other'
import styles from './style.module.css'
type Props = {
Icon?: React.ComponentType<React.SVGProps<SVGSVGElement>>
Icon?: ComponentType<{ className?: string }>
title: string
description: string
extraInfo?: React.ReactNode
extraInfo?: ReactNode
show: boolean
onClose: () => void
onUpgrade?: () => void
}
const PlanUpgradeModal: FC<Props> = ({
export function PlanUpgradeModal({
Icon = SquareChecklist,
title,
description,
@ -28,7 +26,7 @@ const PlanUpgradeModal: FC<Props> = ({
show,
onClose,
onUpgrade,
}) => {
}: Props) {
const { t } = useTranslation()
const { setShowPricingModal } = useModalContext()
@ -41,51 +39,30 @@ const PlanUpgradeModal: FC<Props> = ({
}, [onClose, onUpgrade, setShowPricingModal])
return (
<Modal
isShow={show}
onClose={onClose}
closable={false}
clickOutsideNotClose
className={`${styles.surface} w-[580px] rounded-2xl p-0!`}
>
<div className="relative">
<div
aria-hidden
className={`${styles.heroOverlay} pointer-events-none absolute inset-0`}
/>
<div className="px-8 pt-8">
<div className={`${styles.icon} flex size-12 items-center justify-center rounded-xl shadow-lg backdrop-blur-[5px]`}>
<Icon className="size-6 text-text-primary-on-surface" />
</div>
<div className="mt-6 space-y-2">
<div className={`${styles.highlight} title-3xl-semi-bold`}>
{title}
</div>
<div className="system-md-regular text-text-tertiary">
{description}
</div>
</div>
{extraInfo}
</div>
</div>
<div className="mt-10 mb-8 flex justify-end space-x-2 px-8">
<Button
onClick={onClose}
>
{t('triggerLimitModal.dismiss', { ns: 'billing' })}
</Button>
<UpgradeBtn
size="custom"
isShort
onClick={handleUpgrade}
className="h-8! rounded-lg! px-2"
labelKey="triggerLimitModal.upgrade"
loc="trigger-events-limit-modal"
/>
</div>
</Modal>
<UpgradeModal
open={show}
onOpenChange={open => !open && onClose()}
Icon={Icon}
title={title}
description={description}
extraInfo={extraInfo}
footer={(
<>
<Button
onClick={onClose}
>
{t('triggerLimitModal.dismiss', { ns: 'billing' })}
</Button>
<UpgradeBtn
size="custom"
isShort
onClick={handleUpgrade}
className="h-8! rounded-lg! px-2"
labelKey="triggerLimitModal.upgrade"
loc="trigger-events-limit-modal"
/>
</>
)}
/>
)
}
export default React.memo(PlanUpgradeModal)

View File

@ -4,21 +4,6 @@ import TriggerEventsLimitModal from '../index'
const mockOnClose = vi.fn()
const mockOnUpgrade = vi.fn()
const planUpgradeModalMock = vi.fn((props: { show: boolean, title: string, description: string, extraInfo?: React.ReactNode, onClose: () => void, onUpgrade: () => void }) => (
<div
data-testid="plan-upgrade-modal"
data-show={props.show}
data-title={props.title}
data-description={props.description}
>
{props.extraInfo}
</div>
))
vi.mock('@/app/components/billing/plan-upgrade-modal', () => ({
default: (props: { show: boolean, title: string, description: string, extraInfo?: React.ReactNode, onClose: () => void, onUpgrade: () => void }) => planUpgradeModalMock(props),
}))
describe('TriggerEventsLimitModal', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -36,16 +21,9 @@ describe('TriggerEventsLimitModal', () => {
/>,
)
const modal = screen.getByTestId('plan-upgrade-modal')
expect(modal.getAttribute('data-show')).toBe('true')
expect(modal.getAttribute('data-title')).toContain('billing.triggerLimitModal.title')
expect(modal.getAttribute('data-description')).toContain('billing.triggerLimitModal.description')
expect(planUpgradeModalMock).toHaveBeenCalled()
const passedProps = planUpgradeModalMock.mock.calls[0]![0]
expect(passedProps.onClose).toBe(mockOnClose)
expect(passedProps.onUpgrade).toBe(mockOnUpgrade)
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(screen.getByText('billing.triggerLimitModal.title')).toBeInTheDocument()
expect(screen.getByText('billing.triggerLimitModal.description')).toBeInTheDocument()
expect(screen.getByText('billing.triggerLimitModal.usageTitle'))!.toBeInTheDocument()
expect(screen.getByText('12'))!.toBeInTheDocument()
expect(screen.getByText('20'))!.toBeInTheDocument()
@ -62,8 +40,7 @@ describe('TriggerEventsLimitModal', () => {
/>,
)
expect(planUpgradeModalMock).toHaveBeenCalled()
expect(screen.getByTestId('plan-upgrade-modal').getAttribute('data-show')).toBe('false')
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('renders reset info when resetInDays is provided', () => {
@ -94,9 +71,8 @@ describe('TriggerEventsLimitModal', () => {
/>,
)
const modal = screen.getByTestId('plan-upgrade-modal')
expect(modal.getAttribute('data-title')).toBe('billing.triggerLimitModal.title')
expect(modal.getAttribute('data-description')).toBe('billing.triggerLimitModal.description')
expect(screen.getByText('billing.triggerLimitModal.title')).toBeInTheDocument()
expect(screen.getByText('billing.triggerLimitModal.description')).toBeInTheDocument()
})
it('passes onClose and onUpgrade callbacks to PlanUpgradeModal', () => {
@ -110,8 +86,10 @@ describe('TriggerEventsLimitModal', () => {
/>,
)
const passedProps = planUpgradeModalMock.mock.calls[0]![0]
expect(passedProps.onClose).toBe(mockOnClose)
expect(passedProps.onUpgrade).toBe(mockOnUpgrade)
screen.getByText('billing.triggerLimitModal.dismiss').click()
expect(mockOnClose).toHaveBeenCalledTimes(1)
screen.getByText('billing.triggerLimitModal.upgrade').click()
expect(mockOnUpgrade).toHaveBeenCalledTimes(1)
})
})

View File

@ -1,9 +1,7 @@
'use client'
import type { FC } from 'react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { TriggerAll } from '@/app/components/base/icons/src/vender/workflow'
import PlanUpgradeModal from '@/app/components/billing/plan-upgrade-modal'
import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal'
import UsageInfo from '@/app/components/billing/usage-info'
type Props = {
@ -15,14 +13,14 @@ type Props = {
resetInDays?: number
}
const TriggerEventsLimitModal: FC<Props> = ({
export default function TriggerEventsLimitModal({
show,
onClose,
onUpgrade,
usage,
total,
resetInDays,
}) => {
}: Props) {
const { t } = useTranslation()
return (
@ -30,7 +28,7 @@ const TriggerEventsLimitModal: FC<Props> = ({
show={show}
onClose={onClose}
onUpgrade={onUpgrade}
Icon={TriggerAll as React.ComponentType<React.SVGProps<SVGSVGElement>>}
Icon={TriggerAll}
title={t('triggerLimitModal.title', { ns: 'billing' })}
description={t('triggerLimitModal.description', { ns: 'billing' })}
extraInfo={(
@ -47,5 +45,3 @@ const TriggerEventsLimitModal: FC<Props> = ({
/>
)
}
export default React.memo(TriggerEventsLimitModal)

View File

@ -92,18 +92,6 @@ vi.mock('@/app/components/billing/vector-space-full', () => ({
default: () => <div data-testid="vector-space-full">Vector Space Full</div>,
}))
vi.mock('@/app/components/billing/plan-upgrade-modal', () => ({
default: ({ show, onClose }: { show: boolean, onClose: () => void }) => (
show
? (
<div data-testid="plan-upgrade-modal">
<button data-testid="close-upgrade-modal" onClick={onClose}>Close</button>
</div>
)
: null
),
}))
vi.mock('../../file-preview', () => ({
default: ({ file, hidePreview }: { file: File, hidePreview: () => void }) => (
<div data-testid="file-preview">
@ -388,7 +376,7 @@ describe('StepOne', () => {
fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }))
expect(screen.getByTestId('plan-upgrade-modal')).toBeInTheDocument()
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('should show upgrade card when in sandbox plan with files', () => {

View File

@ -31,17 +31,6 @@ vi.mock('../../../website/preview', () => ({
),
}))
vi.mock('@/app/components/billing/plan-upgrade-modal', () => ({
default: ({ show, onClose, title }: { show: boolean, onClose: () => void, title: string }) => show
? (
<div data-testid="plan-upgrade-modal">
<span>{title}</span>
<button data-testid="close-modal" onClick={onClose}>close-modal</button>
</div>
)
: null,
}))
const { default: PreviewPanel } = await import('../preview-panel')
describe('PreviewPanel', () => {
@ -87,7 +76,7 @@ describe('PreviewPanel', () => {
it('should render plan upgrade modal when isShowPlanUpgradeModal is true', () => {
render(<PreviewPanel {...defaultProps} isShowPlanUpgradeModal />)
expect(screen.getByTestId('plan-upgrade-modal')).toBeInTheDocument()
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
})
@ -100,7 +89,7 @@ describe('PreviewPanel', () => {
it('should call hidePlanUpgradeModal when modal close clicked', () => {
render(<PreviewPanel {...defaultProps} isShowPlanUpgradeModal />)
fireEvent.click(screen.getByTestId('close-modal'))
fireEvent.click(screen.getByRole('button', { name: 'billing.triggerLimitModal.dismiss' }))
expect(defaultProps.hidePlanUpgradeModal).toHaveBeenCalledOnce()
})

View File

@ -3,7 +3,7 @@
import type { NotionPage } from '@/models/common'
import type { CrawlResultItem } from '@/models/datasets'
import { useTranslation } from 'react-i18next'
import PlanUpgradeModal from '@/app/components/billing/plan-upgrade-modal'
import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal'
import FilePreview from '../../file-preview'
import NotionPagePreview from '../../notion-page-preview'
import WebsitePreview from '../../website/preview'

View File

@ -112,18 +112,6 @@ vi.mock('@/app/components/billing/vector-space-full', () => ({
default: () => <div data-testid="vector-space-full">Vector Space Full</div>,
}))
vi.mock('@/app/components/billing/plan-upgrade-modal', () => ({
default: ({ show, onClose }: { show: boolean, onClose: () => void }) => (
show
? (
<div data-testid="plan-upgrade-modal">
<button data-testid="close-modal" onClick={onClose}>Close</button>
</div>
)
: null
),
}))
vi.mock('@/app/components/datasets/create/step-one/upgrade-card', () => ({
default: () => <div data-testid="upgrade-card">Upgrade Card</div>,
}))

View File

@ -8,7 +8,7 @@ import { useBoolean } from 'ahooks'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import PlanUpgradeModal from '@/app/components/billing/plan-upgrade-modal'
import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useProviderContextSelector } from '@/context/provider-context'
import { DatasourceType } from '@/models/pipeline'

View File

@ -14,21 +14,6 @@ vi.mock('@/context/provider-context', () => ({
}),
}))
// Mock PlanUpgradeModal
vi.mock('@/app/components/billing/plan-upgrade-modal', () => ({
default: ({ show, onClose, title, description }: { show: boolean, onClose: () => void, title?: string, description?: string }) => (
show
? (
<div data-testid="plan-upgrade-modal">
<span data-testid="modal-title">{title}</span>
<span data-testid="modal-description">{description}</span>
<button onClick={onClose} data-testid="close-modal">Close</button>
</div>
)
: null
),
}))
describe('SegmentAdd', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -189,7 +174,7 @@ describe('SegmentAdd', () => {
fireEvent.click(screen.getByText(/list\.action\.addButton/i))
expect(screen.getByTestId('plan-upgrade-modal')).toBeInTheDocument()
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('should not call showNewSegmentModal for sandbox users', () => {
@ -219,11 +204,11 @@ describe('SegmentAdd', () => {
// Show modal
fireEvent.click(screen.getByText(/list\.action\.addButton/i))
expect(screen.getByTestId('plan-upgrade-modal')).toBeInTheDocument()
expect(screen.getByRole('dialog')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('close-modal'))
fireEvent.click(screen.getByRole('button', { name: 'billing.triggerLimitModal.dismiss' }))
expect(screen.queryByTestId('plan-upgrade-modal')).not.toBeInTheDocument()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
})

View File

@ -11,7 +11,7 @@ import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import PlanUpgradeModal from '@/app/components/billing/plan-upgrade-modal'
import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal'
import { Plan } from '@/app/components/billing/type'
import { useProviderContext } from '@/context/provider-context'

View File

@ -11,6 +11,7 @@ const mockUseDefaultEvaluationMetrics = vi.hoisted(() => vi.fn())
const mockUseEvaluationConfig = vi.hoisted(() => vi.fn())
const mockUseSaveEvaluationConfigMutation = vi.hoisted(() => vi.fn())
const mockUseStartEvaluationRunMutation = vi.hoisted(() => vi.fn())
const mockUseEvaluationTemplateColumns = vi.hoisted(() => vi.fn())
const mockUsePublishedPipelineInfo = vi.hoisted(() => vi.fn())
const mockUseSnippetPublishedWorkflow = vi.hoisted(() => vi.fn())
@ -55,6 +56,7 @@ vi.mock('@/service/use-evaluation', () => ({
useDefaultEvaluationMetrics: (...args: unknown[]) => mockUseDefaultEvaluationMetrics(...args),
useSaveEvaluationConfigMutation: (...args: unknown[]) => mockUseSaveEvaluationConfigMutation(...args),
useStartEvaluationRunMutation: (...args: unknown[]) => mockUseStartEvaluationRunMutation(...args),
useEvaluationTemplateColumns: (...args: unknown[]) => mockUseEvaluationTemplateColumns(...args),
}))
vi.mock('@/service/use-pipeline', () => ({
@ -170,6 +172,18 @@ describe('Evaluation', () => {
isPending: false,
mutate: vi.fn(),
})
mockUseEvaluationTemplateColumns.mockReturnValue({
data: {
columns: [
{ name: 'index', type: 'number' },
{ name: 'query', type: 'string' },
{ name: 'expected_output', type: 'string' },
],
},
isError: false,
isFetching: false,
isPending: false,
})
mockUsePublishedPipelineInfo.mockReturnValue({
data: {
graph: {
@ -326,72 +340,61 @@ describe('Evaluation', () => {
expect(screen.queryByText('evaluation.batch.noticeDescription')).not.toBeInTheDocument()
})
it('should use published snippet input fields for snippet batch templates', () => {
mockUseSnippetPublishedWorkflow.mockReturnValue({
it('should use template columns for snippet batch templates', () => {
const store = useEvaluationStore.getState()
act(() => {
store.ensureResource('snippets', 'snippet-fields')
store.setJudgeModel('snippets', 'snippet-fields', 'openai::gpt-4o-mini')
store.addBuiltinMetric('snippets', 'snippet-fields', 'answer-correctness', [
{ node_id: 'node-answer', title: 'Answer Node', type: 'llm' },
])
})
mockUseEvaluationTemplateColumns.mockReturnValue({
data: {
graph: {
nodes: [{
id: 'start',
data: {
type: 'start',
variables: [{
variable: 'graph_only',
type: 'text-input',
}],
},
}],
},
input_fields: [
{
label: 'Snippet Topic',
variable: 'snippet_topic',
type: 'text-input',
required: true,
},
{
label: 'Need Summary',
variable: 'need_summary',
type: 'checkbox',
required: false,
},
columns: [
{ name: 'index', type: 'number' },
{ name: 'snippet_topic', type: 'string' },
{ name: 'need_summary', type: 'boolean' },
],
},
isLoading: false,
isError: false,
isFetching: false,
isPending: false,
})
renderWithQueryClient(<Evaluation resourceType="snippets" resourceId="snippet-fields" />)
expect(mockUseSnippetPublishedWorkflow).toHaveBeenCalledWith('snippet-fields')
expect(mockUseEvaluationTemplateColumns).toHaveBeenCalledWith(
'snippets',
'snippet-fields',
expect.any(Object),
true,
)
expect(screen.getByText('snippet_topic')).toBeInTheDocument()
expect(screen.getByText('need_summary')).toBeInTheDocument()
expect(screen.queryByText('graph_only')).not.toBeInTheDocument()
})
it('should show snippet-specific empty input fields copy', () => {
mockUseSnippetPublishedWorkflow.mockReturnValue({
it('should show empty template columns copy', () => {
const store = useEvaluationStore.getState()
act(() => {
store.ensureResource('snippets', 'snippet-empty-fields')
store.setJudgeModel('snippets', 'snippet-empty-fields', 'openai::gpt-4o-mini')
store.addBuiltinMetric('snippets', 'snippet-empty-fields', 'answer-correctness', [
{ node_id: 'node-answer', title: 'Answer Node', type: 'llm' },
])
})
mockUseEvaluationTemplateColumns.mockReturnValue({
data: {
graph: {
nodes: [{
id: 'start',
data: {
type: 'start',
variables: [{
variable: 'graph_only',
type: 'text-input',
}],
},
}],
},
input_fields: [],
columns: [],
},
isLoading: false,
isError: false,
isFetching: false,
isPending: false,
})
renderWithQueryClient(<Evaluation resourceType="snippets" resourceId="snippet-empty-fields" />)
expect(screen.getByText('evaluation.batch.noSnippetInputFields')).toBeInTheDocument()
expect(screen.queryByText('evaluation.batch.noInputFields')).not.toBeInTheDocument()
expect(screen.queryByText('graph_only')).not.toBeInTheDocument()
expect(screen.getByText('evaluation.batch.noTemplateColumns')).toBeInTheDocument()
})
it('should hide the value row for empty operators', () => {
@ -624,6 +627,18 @@ describe('Evaluation', () => {
it('should download the fixed pipeline template columns', () => {
const createElement = document.createElement.bind(document)
mockUseEvaluationTemplateColumns.mockReturnValue({
data: {
columns: [
{ name: 'index', type: 'number' },
{ name: 'query', type: 'string' },
{ name: 'expected_output', type: 'string' },
],
},
isError: false,
isFetching: false,
isPending: false,
})
let downloadLink: HTMLAnchorElement | undefined
const createElementSpy = vi.spyOn(document, 'createElement').mockImplementation((tagName, options) => {
const element = createElement(tagName, options)
@ -645,6 +660,15 @@ describe('Evaluation', () => {
const templateContent = decodeURIComponent(downloadLink?.href ?? '').replace('data:text/csv;charset=utf-8,', '')
expect(downloadLink?.download).toBe('pipeline-evaluation-template.csv')
expect(templateContent.trim().split(',')).toEqual(['index', 'query', 'expected_output'])
expect(mockUseEvaluationTemplateColumns).toHaveBeenLastCalledWith(
'datasets',
'dataset-template',
expect.objectContaining({
evaluation_model: 'gpt-4o-mini',
evaluation_model_provider: 'openai',
}),
true,
)
createElementSpy.mockRestore()
})

View File

@ -5,7 +5,6 @@ import { EVALUATION_TEMPLATE_FILE_NAMES } from '../../store-utils'
import InputFieldsRequirements from './input-fields/input-fields-requirements'
import UploadRunPopover from './input-fields/upload-run-popover'
import { useInputFieldsActions } from './input-fields/use-input-fields-actions'
import { usePublishedInputFields } from './input-fields/use-published-input-fields'
type InputFieldsTabProps = EvaluationResourceProps & {
isPanelReady: boolean
@ -19,12 +18,9 @@ const InputFieldsTab = ({
isRunnable,
}: InputFieldsTabProps) => {
const { t } = useTranslation('evaluation')
const { inputFields, isInputFieldsLoading } = usePublishedInputFields(resourceType, resourceId)
const actions = useInputFieldsActions({
resourceType,
resourceId,
inputFields,
isInputFieldsLoading,
isPanelReady,
isRunnable,
templateFileName: EVALUATION_TEMPLATE_FILE_NAMES[resourceType],
@ -33,9 +29,8 @@ const InputFieldsTab = ({
return (
<div className="space-y-5">
<InputFieldsRequirements
resourceType={resourceType}
inputFields={inputFields}
isLoading={isInputFieldsLoading}
inputFields={actions.templateColumns}
isLoading={actions.isTemplateColumnsLoading}
/>
<div className="space-y-3">
<Button variant="secondary" className="w-full justify-center" disabled={!actions.canDownloadTemplate} onClick={actions.handleDownloadTemplate}>
@ -46,7 +41,7 @@ const InputFieldsTab = ({
open={actions.isUploadPopoverOpen}
onOpenChange={actions.setIsUploadPopoverOpen}
triggerDisabled={actions.uploadButtonDisabled}
inputFields={inputFields}
inputFields={actions.templateColumns}
currentFileName={actions.currentFileName}
currentFileExtension={actions.currentFileExtension}
currentFileSize={actions.currentFileSize}

View File

@ -2,32 +2,19 @@ import { buildTemplateCsvContent, getExampleValue } from '../input-fields-utils'
describe('input fields utils', () => {
describe('buildTemplateCsvContent', () => {
it('should use index as the first CSV column and append expected_output as the last CSV column', () => {
expect(buildTemplateCsvContent([
{ name: 'query', type: 'string' },
{ name: 'context', type: 'string' },
])).toBe('index,query,context,expected_output\n')
})
it('should not duplicate expected_output when it already exists', () => {
it('should build CSV content from API columns without injecting columns', () => {
expect(buildTemplateCsvContent([
{ name: 'index', type: 'number' },
{ name: 'query', type: 'string' },
{ name: 'expected_output', type: 'string' },
])).toBe('index,query,expected_output\n')
})
it('should not duplicate index when it already exists', () => {
expect(buildTemplateCsvContent([
{ name: 'query', type: 'string' },
{ name: 'index', type: 'number' },
])).toBe('index,query,expected_output\n')
})
it('should escape CSV column names before appending expected_output', () => {
it('should escape CSV column names', () => {
expect(buildTemplateCsvContent([
{ name: 'query,text', type: 'string' },
{ name: 'answer "draft"', type: 'string' },
])).toBe('index,"query,text","answer ""draft""",expected_output\n')
])).toBe('"query,text","answer ""draft"""\n')
})
})

View File

@ -1,22 +1,16 @@
import type { EvaluationResourceType } from '../../../types'
import type { InputField } from './input-fields-utils'
import { useTranslation } from 'react-i18next'
type InputFieldsRequirementsProps = {
resourceType: EvaluationResourceType
inputFields: InputField[]
isLoading: boolean
}
const InputFieldsRequirements = ({
resourceType,
inputFields,
isLoading,
}: InputFieldsRequirementsProps) => {
const { t } = useTranslation('evaluation')
const emptyDescription = resourceType === 'snippets'
? t('batch.noSnippetInputFields')
: t('batch.noInputFields')
return (
<div>
@ -30,7 +24,7 @@ const InputFieldsRequirements = ({
)}
{!isLoading && inputFields.length === 0 && (
<div className="px-1 py-0.5 system-xs-regular text-text-tertiary">
{emptyDescription}
{t('batch.noTemplateColumns')}
</div>
)}
{!isLoading && inputFields.map(field => (

View File

@ -1,5 +1,6 @@
import type { StartNodeType } from '@/app/components/workflow/nodes/start/types'
import type { InputVar, Node } from '@/app/components/workflow/types'
import type { EvaluationTemplateColumn } from '@/types/evaluation'
import type { SnippetInputField } from '@/types/snippet'
import { inputVarTypeToVarType } from '@/app/components/workflow/nodes/_base/components/variable/utils'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
@ -11,7 +12,6 @@ export type InputField = {
}
export const INDEX_FIELD_NAME = 'index'
export const EXPECTED_OUTPUT_FIELD_NAME = 'expected_output'
export const getGraphNodes = (graph?: Record<string, unknown>) => {
return Array.isArray(graph?.nodes) ? graph.nodes as Node[] : []
@ -65,15 +65,8 @@ const escapeCsvCell = (value: string) => {
return `"${value.replace(/"/g, '""')}"`
}
export const buildTemplateCsvContent = (inputFields: InputField[]) => {
const fieldNames = inputFields
.map(field => field.name)
.filter(name => name !== INDEX_FIELD_NAME)
const templateFieldNames = fieldNames.includes(EXPECTED_OUTPUT_FIELD_NAME)
? [INDEX_FIELD_NAME, ...fieldNames]
: [INDEX_FIELD_NAME, ...fieldNames, EXPECTED_OUTPUT_FIELD_NAME]
return `${templateFieldNames.map(escapeCsvCell).join(',')}\n`
export const buildTemplateCsvContent = (columns: EvaluationTemplateColumn[]) => {
return `${columns.map(column => escapeCsvCell(column.name)).join(',')}\n`
}
export const getFileExtension = (fileName: string) => {

View File

@ -47,7 +47,7 @@ const UploadRunPopover = ({
const { t } = useTranslation('evaluation')
const { t: tCommon } = useTranslation('common')
const fileInputRef = useRef<HTMLInputElement>(null)
const previewFields = inputFields.slice(0, 3)
const previewFields = inputFields
const booleanExampleValue = t('conditions.boolean.true')
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {

View File

@ -1,14 +1,13 @@
import type { EvaluationResourceProps } from '../../../types'
import type { InputField } from './input-fields-utils'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation } from '@tanstack/react-query'
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { upload } from '@/service/base'
import { useStartEvaluationRunMutation } from '@/service/use-evaluation'
import { useEvaluationTemplateColumns, useStartEvaluationRunMutation } from '@/service/use-evaluation'
import { formatFileSize } from '@/utils/format'
import { useEvaluationResource, useEvaluationStore } from '../../../store'
import { buildEvaluationRunRequest } from '../../../store-utils'
import { buildEvaluationConfigPayload, buildEvaluationRunRequest } from '../../../store-utils'
import { buildTemplateCsvContent, getFileExtension } from './input-fields-utils'
type UploadedFileMeta = {
@ -17,22 +16,16 @@ type UploadedFileMeta = {
}
type UseInputFieldsActionsParams = EvaluationResourceProps & {
inputFields: InputField[]
isInputFieldsLoading: boolean
isPanelReady: boolean
isRunnable: boolean
templateContent?: string
templateFileName: string
}
export const useInputFieldsActions = ({
resourceType,
resourceId,
inputFields,
isInputFieldsLoading,
isPanelReady,
isRunnable,
templateContent,
templateFileName,
}: UseInputFieldsActionsParams) => {
const { t } = useTranslation('evaluation')
@ -42,6 +35,10 @@ export const useInputFieldsActions = ({
const setUploadedFile = useEvaluationStore(state => state.setUploadedFile)
const setUploadedFileName = useEvaluationStore(state => state.setUploadedFileName)
const startRunMutation = useStartEvaluationRunMutation()
const templateConfigPayload = useMemo(() => {
return isPanelReady ? buildEvaluationConfigPayload(resource, resourceType) : null
}, [isPanelReady, resource, resourceType])
const templateColumnsQuery = useEvaluationTemplateColumns(resourceType, resourceId, templateConfigPayload, isPanelReady)
const [isUploadPopoverOpen, setIsUploadPopoverOpen] = useState(false)
const [uploadedFileMeta, setUploadedFileMeta] = useState<UploadedFileMeta | null>(null)
const uploadMutation = useMutation({
@ -69,19 +66,26 @@ export const useInputFieldsActions = ({
const isFileUploading = uploadMutation.isPending
const isRunning = startRunMutation.isPending
const isTemplateColumnsLoading = templateColumnsQuery.isPending || templateColumnsQuery.isFetching
const templateColumns = templateColumnsQuery.data?.columns ?? []
const uploadedFileId = resource.uploadedFileId
const currentFileName = uploadedFileMeta?.name ?? resource.uploadedFileName
const canDownloadTemplate = isPanelReady && !isInputFieldsLoading && inputFields.length > 0
const canDownloadTemplate = isPanelReady && !isTemplateColumnsLoading && templateColumns.length > 0
const isRunDisabled = !isRunnable || !uploadedFileId || isFileUploading || isRunning
const uploadButtonDisabled = !isPanelReady || isInputFieldsLoading || isRunning
const uploadButtonDisabled = !isPanelReady || isTemplateColumnsLoading || isRunning
const handleDownloadTemplate = () => {
if (!inputFields.length) {
toast.warning(t('batch.noInputFields'))
if (templateColumnsQuery.isError) {
toast.error(t('batch.templateColumnsError'))
return
}
const content = templateContent ?? buildTemplateCsvContent(inputFields)
if (!templateColumns.length) {
toast.warning(t('batch.noTemplateColumns'))
return
}
const content = buildTemplateCsvContent(templateColumns)
const link = document.createElement('a')
link.href = `data:text/csv;charset=utf-8,${encodeURIComponent(content)}`
link.download = templateFileName
@ -162,8 +166,10 @@ export const useInputFieldsActions = ({
isFileUploading,
isRunning,
isRunDisabled,
isTemplateColumnsLoading,
isUploadPopoverOpen,
setIsUploadPopoverOpen,
templateColumns,
uploadButtonDisabled,
}
}

View File

@ -1,7 +1,6 @@
'use client'
import type { EvaluationResourceProps } from '../../types'
import type { InputField } from '../batch-test-panel/input-fields/input-fields-utils'
import { Button } from '@langgenius/dify-ui/button'
import { useTranslation } from 'react-i18next'
import { isEvaluationRunnable, useEvaluationResource } from '../../store'
@ -9,13 +8,6 @@ import { EVALUATION_TEMPLATE_FILE_NAMES } from '../../store-utils'
import UploadRunPopover from '../batch-test-panel/input-fields/upload-run-popover'
import { useInputFieldsActions } from '../batch-test-panel/input-fields/use-input-fields-actions'
const PIPELINE_INPUT_FIELDS: InputField[] = [
{ name: 'index', type: 'number' },
{ name: 'query', type: 'string' },
{ name: 'expected_output', type: 'string' },
]
const PIPELINE_TEMPLATE_CONTENT = 'index,query,expected_output\n'
const PipelineBatchActions = ({
resourceType,
resourceId,
@ -27,11 +19,8 @@ const PipelineBatchActions = ({
const actions = useInputFieldsActions({
resourceType,
resourceId,
inputFields: PIPELINE_INPUT_FIELDS,
isInputFieldsLoading: false,
isPanelReady: isConfigReady,
isRunnable,
templateContent: PIPELINE_TEMPLATE_CONTENT,
templateFileName: EVALUATION_TEMPLATE_FILE_NAMES[resourceType],
})
@ -52,7 +41,7 @@ const PipelineBatchActions = ({
onOpenChange={actions.setIsUploadPopoverOpen}
triggerDisabled={actions.uploadButtonDisabled}
triggerLabel={t('pipeline.uploadAndRun')}
inputFields={PIPELINE_INPUT_FIELDS}
inputFields={actions.templateColumns}
currentFileName={actions.currentFileName}
currentFileExtension={actions.currentFileExtension}
currentFileSize={actions.currentFileSize}

View File

@ -1,107 +1,63 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import PanelContextmenu from '../panel-contextmenu'
import { BlockEnum } from '../types'
import { createNode } from './fixtures'
import { renderWorkflowFlowComponent } from './workflow-test-env'
const mockUseClickAway = vi.hoisted(() => vi.fn())
const mockUseTranslation = vi.hoisted(() => vi.fn())
const mockUseStore = vi.hoisted(() => vi.fn())
const mockUseNodesInteractions = vi.hoisted(() => vi.fn())
const mockUsePanelInteractions = vi.hoisted(() => vi.fn())
const mockUseWorkflowStartRun = vi.hoisted(() => vi.fn())
const mockUseWorkflowMoveMode = vi.hoisted(() => vi.fn())
const mockUseOperator = vi.hoisted(() => vi.fn())
const mockUseDSL = vi.hoisted(() => vi.fn())
vi.mock('ahooks', () => ({
useClickAway: (...args: unknown[]) => mockUseClickAway(...args),
}))
const mockUseNodesReadOnly = vi.hoisted(() => vi.fn())
const mockUseAvailableBlocks = vi.hoisted(() => vi.fn())
const mockUseNodesMetaData = vi.hoisted(() => vi.fn())
const mockUseIsChatMode = vi.hoisted(() => vi.fn())
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation(),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: {
panelMenu?: { left: number, top: number }
clipboardElements: unknown[]
pendingComment: null | { pageX: number, pageY: number, elementX: number, elementY: number }
setCommentPlacing: (placing: boolean) => void
setCommentQuickAdd: (quickAdd: boolean) => void
setShowImportDSLModal: (visible: boolean) => void
}) => unknown) => mockUseStore(selector),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesInteractions: () => mockUseNodesInteractions(),
usePanelInteractions: () => mockUsePanelInteractions(),
useWorkflowStartRun: () => mockUseWorkflowStartRun(),
useWorkflowMoveMode: () => mockUseWorkflowMoveMode(),
useAvailableBlocks: () => mockUseAvailableBlocks(),
useDSL: () => mockUseDSL(),
useIsChatMode: () => mockUseIsChatMode(),
useNodesInteractions: () => mockUseNodesInteractions(),
useNodesMetaData: () => mockUseNodesMetaData(),
useNodesReadOnly: () => mockUseNodesReadOnly(),
usePanelInteractions: () => mockUsePanelInteractions(),
useWorkflowMoveMode: () => mockUseWorkflowMoveMode(),
useWorkflowStartRun: () => mockUseWorkflowStartRun(),
}))
vi.mock('@/app/components/workflow/operator/hooks', () => ({
useOperator: () => mockUseOperator(),
}))
vi.mock('@/app/components/workflow/operator/add-block', () => ({
__esModule: true,
default: ({ renderTrigger }: { renderTrigger: () => ReactNode }) => (
<div data-testid="add-block">{renderTrigger()}</div>
),
}))
vi.mock('@/app/components/base/divider', () => ({
__esModule: true,
default: ({ className }: { className?: string }) => <div data-testid="divider" className={className} />,
}))
vi.mock('@/app/components/workflow/shortcuts-name', () => ({
__esModule: true,
default: ({ keys }: { keys: string[] }) => <span data-testid={`shortcut-${keys.join('-')}`}>{keys.join('+')}</span>,
}))
describe('PanelContextmenu', () => {
const mockHandleNodesPaste = vi.fn()
const mockHandlePaneContextmenuCancel = vi.fn()
const mockHandleStartWorkflowRun = vi.fn()
const mockHandleWorkflowStartRunInChatflow = vi.fn()
const mockHandleAddNote = vi.fn()
const mockExportCheck = vi.fn()
const mockSetShowImportDSLModal = vi.fn()
const mockSetCommentPlacing = vi.fn()
const mockSetCommentQuickAdd = vi.fn()
let panelMenu: { left: number, top: number } | undefined
let clipboardElements: unknown[]
let pendingComment: null | { pageX: number, pageY: number, elementX: number, elementY: number }
let clickAwayHandler: (() => void) | undefined
const defaultNodesMetaDataMap = {
[BlockEnum.Answer]: {
defaultValue: {
title: 'Answer',
desc: '',
type: BlockEnum.Answer,
},
},
}
beforeEach(() => {
vi.clearAllMocks()
panelMenu = undefined
clipboardElements = []
pendingComment = null
clickAwayHandler = undefined
mockUseClickAway.mockImplementation((handler: () => void) => {
clickAwayHandler = handler
})
mockUseTranslation.mockReturnValue({
t: (key: string) => key,
})
mockUseStore.mockImplementation((selector: (state: {
panelMenu?: { left: number, top: number }
clipboardElements: unknown[]
pendingComment: null | { pageX: number, pageY: number, elementX: number, elementY: number }
setCommentPlacing: (placing: boolean) => void
setCommentQuickAdd: (quickAdd: boolean) => void
setShowImportDSLModal: (visible: boolean) => void
}) => unknown) => selector({
panelMenu,
clipboardElements,
pendingComment,
setCommentPlacing: mockSetCommentPlacing,
setCommentQuickAdd: mockSetCommentQuickAdd,
setShowImportDSLModal: mockSetShowImportDSLModal,
}))
mockUseNodesInteractions.mockReturnValue({
handleNodesPaste: mockHandleNodesPaste,
})
@ -110,6 +66,7 @@ describe('PanelContextmenu', () => {
})
mockUseWorkflowStartRun.mockReturnValue({
handleStartWorkflowRun: mockHandleStartWorkflowRun,
handleWorkflowStartRunInChatflow: mockHandleWorkflowStartRunInChatflow,
})
mockUseWorkflowMoveMode.mockReturnValue({
isCommentModeAvailable: false,
@ -120,50 +77,86 @@ describe('PanelContextmenu', () => {
mockUseDSL.mockReturnValue({
exportCheck: mockExportCheck,
})
mockUseNodesReadOnly.mockReturnValue({
nodesReadOnly: false,
})
mockUseAvailableBlocks.mockReturnValue({
availableNextBlocks: [BlockEnum.Answer],
})
mockUseNodesMetaData.mockReturnValue({
nodesMap: defaultNodesMetaDataMap,
})
mockUseIsChatMode.mockReturnValue(false)
})
it('should stay hidden when the panel menu is absent', () => {
render(<PanelContextmenu />)
renderWorkflowFlowComponent(<PanelContextmenu />)
expect(screen.queryByTestId('add-block')).not.toBeInTheDocument()
expect(screen.queryByText('common.addBlock')).not.toBeInTheDocument()
})
it('should keep paste disabled when the clipboard is empty', () => {
panelMenu = { left: 24, top: 48 }
render(<PanelContextmenu />)
it('should keep paste disabled when the clipboard is empty', async () => {
renderWorkflowFlowComponent(<PanelContextmenu />, {
initialStoreState: {
panelMenu: { clientX: 24, clientY: 48 },
},
hooksStoreProps: {},
})
await screen.findByText('common.pasteHere')
fireEvent.click(screen.getByText('common.pasteHere'))
expect(mockHandleNodesPaste).not.toHaveBeenCalled()
expect(mockHandlePaneContextmenuCancel).not.toHaveBeenCalled()
})
it('should render actions, position the menu, and execute each action', () => {
panelMenu = { left: 24, top: 48 }
clipboardElements = [{ id: 'copied-node' }]
const { container } = render(<PanelContextmenu />)
expect(screen.getByTestId('add-block')).toHaveTextContent('common.addBlock')
expect(screen.getByRole('button', { name: /common\.run/i })).toHaveTextContent(/Alt\s*R/)
expect(screen.getByRole('button', { name: /common\.pasteHere/i })).toHaveTextContent(/Ctrl\s*V/)
expect(container.firstChild).toHaveStyle({
left: '24px',
top: '48px',
it('should render actions and execute enabled actions', async () => {
const { store } = renderWorkflowFlowComponent(<PanelContextmenu />, {
initialStoreState: {
panelMenu: { clientX: 24, clientY: 48 },
clipboardElements: [createNode({ id: 'copied-node' })],
},
hooksStoreProps: {},
})
expect(await screen.findByText('common.addBlock')).toBeInTheDocument()
expect(screen.getByText('common.run')).toBeInTheDocument()
expect(screen.getByText('common.pasteHere')).toBeInTheDocument()
fireEvent.click(screen.getByText('nodes.note.addNote'))
fireEvent.click(screen.getByText('common.run'))
fireEvent.click(screen.getByText('common.pasteHere'))
fireEvent.click(screen.getByText('export'))
fireEvent.click(screen.getByText('importApp'))
clickAwayHandler?.()
expect(mockHandleAddNote).toHaveBeenCalledTimes(1)
expect(mockHandleStartWorkflowRun).toHaveBeenCalledTimes(1)
expect(mockHandleNodesPaste).toHaveBeenCalledTimes(1)
expect(mockExportCheck).toHaveBeenCalledTimes(1)
expect(mockSetShowImportDSLModal).toHaveBeenCalledWith(true)
expect(mockHandlePaneContextmenuCancel).toHaveBeenCalledTimes(4)
await waitFor(() => {
expect(mockHandleAddNote).toHaveBeenCalledTimes(1)
expect(mockHandleStartWorkflowRun).toHaveBeenCalledTimes(1)
expect(mockHandleNodesPaste).toHaveBeenCalledTimes(1)
expect(mockExportCheck).toHaveBeenCalledTimes(1)
expect(store.getState().showImportDSLModal).toBe(true)
})
})
it('should render preview action in chat mode', async () => {
mockUseIsChatMode.mockReturnValue(true)
renderWorkflowFlowComponent(<PanelContextmenu />, {
initialStoreState: {
panelMenu: { clientX: 24, clientY: 48 },
},
hooksStoreProps: {},
})
expect(await screen.findByText('common.debugAndPreview')).toBeInTheDocument()
expect(screen.queryByText('common.run')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('common.debugAndPreview'))
await waitFor(() => {
expect(mockHandleWorkflowStartRunInChatflow).toHaveBeenCalledTimes(1)
expect(mockHandleStartWorkflowRun).not.toHaveBeenCalled()
expect(mockHandlePaneContextmenuCancel).toHaveBeenCalled()
})
})
})

View File

@ -36,7 +36,7 @@ describe('usePanelInteractions', () => {
container.remove()
})
it('handlePaneContextMenu should set panelMenu with computed coordinates when container exists', () => {
it('handlePaneContextMenu should set panelMenu with viewport coordinates', () => {
const { result, store } = renderWorkflowHook(() => usePanelInteractions(), {
initialStoreState: {
nodeMenu: { clientX: 40, clientY: 20, nodeId: 'n1' },
@ -54,28 +54,14 @@ describe('usePanelInteractions', () => {
expect(preventDefault).toHaveBeenCalled()
expect(store.getState().panelMenu).toEqual({
top: 200,
left: 250,
clientX: 350,
clientY: 250,
})
expect(store.getState().nodeMenu).toBeUndefined()
expect(store.getState().selectionMenu).toBeUndefined()
expect(store.getState().edgeMenu).toBeUndefined()
})
it('handlePaneContextMenu should throw when container does not exist', () => {
container.remove()
const { result } = renderWorkflowHook(() => usePanelInteractions())
expect(() => {
result.current.handlePaneContextMenu({
preventDefault: vi.fn(),
clientX: 350,
clientY: 250,
} as unknown as React.MouseEvent)
}).toThrow()
})
it('handlePaneContextMenu should sync clipboard from navigator clipboard', async () => {
const clipboardNode = createNode({ id: 'clipboard-node' })
const clipboardEdge = createEdge({
@ -106,7 +92,7 @@ describe('usePanelInteractions', () => {
it('handlePaneContextmenuCancel should clear panelMenu', () => {
const { result, store } = renderWorkflowHook(() => usePanelInteractions(), {
initialStoreState: { panelMenu: { top: 10, left: 20 } },
initialStoreState: { panelMenu: { clientX: 20, clientY: 10 } },
})
result.current.handlePaneContextmenuCancel()

View File

@ -174,7 +174,7 @@ describe('useSelectionInteractions', () => {
it('handleSelectionContextMenu should set menu only when clicking on selection rect', () => {
const { result, store } = renderSelectionInteractions({
nodeMenu: { clientX: 20, clientY: 10, nodeId: 'n1' },
panelMenu: { top: 30, left: 40 },
panelMenu: { clientX: 40, clientY: 30 },
edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' },
})

View File

@ -22,15 +22,13 @@ export const usePanelInteractions = () => {
workflowStore.getState().setClipboardData({ nodes, edges })
})
const container = document.querySelector('#workflow-container')
const { x, y } = container!.getBoundingClientRect()
workflowStore.setState({
nodeMenu: undefined,
selectionMenu: undefined,
edgeMenu: undefined,
panelMenu: {
top: e.clientY - y,
left: e.clientX - x,
clientX: e.clientX,
clientY: e.clientY,
},
})
}, [workflowStore, appDslVersion])

View File

@ -0,0 +1,239 @@
import { renderHook } from '@testing-library/react'
import {
BlockEnum,
InputVarType,
VarType,
} from '@/app/components/workflow/types'
import { FlowType } from '@/types/common'
import useOneStepRun from '../use-one-step-run'
const mockWorkflowState = {
conversationVariables: [],
dataSourceList: [],
nodesWithInspectVars: [],
setNodesWithInspectVars: vi.fn(),
setShowSingleRunPanel: vi.fn(),
setIsListening: vi.fn(),
setListeningTriggerType: vi.fn(),
setListeningTriggerNodeId: vi.fn(),
setListeningTriggerNodeIds: vi.fn(),
setListeningTriggerIsAll: vi.fn(),
setShowVariableInspectPanel: vi.fn(),
}
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
error: vi.fn(),
},
}))
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: vi.fn(),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useIsChatMode: () => false,
useNodeDataUpdate: () => ({
handleNodeDataUpdate: vi.fn(),
}),
useWorkflow: () => ({
getBeforeNodesInSameBranch: () => [
{
id: 'start',
data: {
type: 'start',
title: 'Start',
variables: [],
},
},
],
getBeforeNodesInSameBranchIncludeParent: () => [
{
id: 'start',
data: {
type: 'start',
title: 'Start',
variables: [],
},
},
],
}),
}))
vi.mock('@/app/components/workflow/hooks/use-inspect-vars-crud', () => ({
default: () => ({
appendNodeInspectVars: vi.fn(),
invalidateSysVarValues: vi.fn(),
invalidateConversationVarValues: vi.fn(),
}),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: typeof mockWorkflowState) => unknown) => selector(mockWorkflowState),
useWorkflowStore: () => ({
getState: () => mockWorkflowState,
}),
}))
vi.mock('reactflow', () => ({
useStoreApi: () => ({
getState: () => ({
getNodes: () => [],
}),
}),
}))
vi.mock('@/service/use-tools', () => ({
useAllBuiltInTools: () => ({ data: [] }),
useAllCustomTools: () => ({ data: [] }),
useAllWorkflowTools: () => ({ data: [] }),
useAllMCPTools: () => ({ data: [] }),
}))
vi.mock('@/service/use-workflow', () => ({
useInvalidLastRun: () => vi.fn(),
}))
vi.mock('@/service/workflow', () => ({
fetchNodeInspectVars: vi.fn(),
getIterationSingleNodeRunUrl: vi.fn(),
getLoopSingleNodeRunUrl: vi.fn(),
singleNodeRun: vi.fn(),
}))
vi.mock('@/service/base', () => ({
post: vi.fn(),
ssePost: vi.fn(),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
useSubscription: vi.fn(),
},
}),
}))
vi.mock('../components/variable/use-match-schema-type', () => ({
default: () => ({
schemaTypeDefinitions: [],
}),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/use-match-schema-type', () => ({
default: () => ({
schemaTypeDefinitions: [],
}),
}))
vi.mock('@/app/components/workflow/nodes/assigner/default', () => ({
default: {},
}))
vi.mock('@/app/components/workflow/nodes/code/default', () => ({
default: {},
}))
vi.mock('@/app/components/workflow/nodes/document-extractor/default', () => ({
default: {},
}))
vi.mock('@/app/components/workflow/nodes/http/default', () => ({
default: {},
}))
vi.mock('@/app/components/workflow/nodes/human-input/default', () => ({
default: {},
}))
vi.mock('@/app/components/workflow/nodes/if-else/default', () => ({
default: {},
}))
vi.mock('@/app/components/workflow/nodes/iteration/default', () => ({
default: {},
}))
vi.mock('@/app/components/workflow/nodes/knowledge-retrieval/default', () => ({
default: {},
}))
vi.mock('@/app/components/workflow/nodes/llm/default', () => ({
default: {},
}))
vi.mock('@/app/components/workflow/nodes/loop/default', () => ({
default: {},
}))
vi.mock('@/app/components/workflow/nodes/parameter-extractor/default', () => ({
default: {},
}))
vi.mock('@/app/components/workflow/nodes/question-classifier/default', () => ({
default: {},
}))
vi.mock('@/app/components/workflow/nodes/template-transform/default', () => ({
default: {},
}))
vi.mock('@/app/components/workflow/nodes/tool/default', () => ({
default: {},
}))
vi.mock('@/app/components/workflow/nodes/variable-assigner/default', () => ({
default: {},
}))
const renderUseOneStepRun = () => renderHook(() => useOneStepRun({
id: 'if-else-node',
flowId: 'app-id',
flowType: FlowType.appFlow,
data: {
type: BlockEnum.IfElse,
title: 'IF/ELSE',
desc: '',
},
defaultRunInputData: {},
isRunAfterSingleRun: false,
isPaused: false,
}))
describe('useOneStepRun single-run input vars', () => {
beforeEach(() => {
vi.clearAllMocks()
Object.defineProperty(globalThis, 'location', {
value: {
pathname: '/app/test-app/workflow',
},
configurable: true,
})
})
it('uses value_type when the variable cannot be resolved from output vars', () => {
const { result } = renderUseOneStepRun()
const inputs = result.current.toVarInputs([
{
variable: '#start.amount#',
value_selector: ['start', 'amount'],
value_type: VarType.number,
},
])
expect(inputs).toMatchObject([
{
variable: '#start.amount#',
type: InputVarType.number,
},
])
})
it('resolves global system vars by full variable name', () => {
const { result } = renderUseOneStepRun()
const inputs = result.current.varSelectorsToVarInputs([
['sys', 'timestamp'],
])
expect(inputs).toMatchObject([
{
variable: '#sys.timestamp#',
type: InputVarType.number,
},
])
})
})

View File

@ -178,13 +178,15 @@ const useOneStepRun = <T>({
}
const allOutputVars = toNodeOutputVars(availableNodes, isChatMode, undefined, undefined, conversationVariables, [], allPluginInfoList, schemaTypeDefinitions)
const targetVar = allOutputVars.find(item => isSystem ? !!item.isStartNode : item.nodeId === valueSelector[0])
if (isSystem) {
const selectorKey = valueSelector.join('.')
return allOutputVars.flatMap(item => item.vars).find(item => item.variable === selectorKey)
}
const targetVar = allOutputVars.find(item => item.nodeId === valueSelector[0])
if (!targetVar)
return undefined
if (isSystem)
return targetVar.vars.find(item => item.variable.split('.')[1] === valueSelector[1])
let curr: any = targetVar.vars
for (let i = 1; i < valueSelector.length; i++) {
const key = valueSelector[i]
@ -1079,12 +1081,19 @@ const useOneStepRun = <T>({
const varInputs = variables.filter(item => !isENV(item.value_selector)).map((item) => {
const originalVar = getVar(item.value_selector)
if (!originalVar) {
const fallbackType = item.value_type
? varTypeToInputVarType(item.value_type, {
isSelect: !!item.options?.length,
isParagraph: !!item.isParagraph,
})
: InputVarType.textInput
return {
label: item.label || item.variable,
variable: item.variable,
type: InputVarType.textInput,
type: fallbackType,
required: true,
value_selector: item.value_selector,
options: item.options,
}
}
return {

View File

@ -30,11 +30,6 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
},
}))
vi.mock('@/app/components/base/tooltip', () => ({
__esModule: true,
default: () => <div>tooltip</div>,
}))
vi.mock('@/app/components/base/action-button', () => ({
__esModule: true,
default: (props: {

View File

@ -92,9 +92,9 @@ describe('human-input/delivery-method/email-configure-modal', () => {
render(
<EmailConfigureModal
isShow
open
config={createEmailConfig()}
onClose={vi.fn()}
onOpenChange={vi.fn()}
onConfirm={handleConfirm}
/>,
)
@ -127,8 +127,8 @@ describe('human-input/delivery-method/email-configure-modal', () => {
render(
<EmailConfigureModal
isShow
onClose={vi.fn()}
open
onOpenChange={vi.fn()}
onConfirm={handleConfirm}
/>,
)
@ -162,12 +162,12 @@ describe('human-input/delivery-method/email-configure-modal', () => {
})
it('should close from both the icon trigger and the cancel button', () => {
const handleClose = vi.fn()
const handleOpenChange = vi.fn()
render(
<EmailConfigureModal
isShow
open
config={createEmailConfig()}
onClose={handleClose}
onOpenChange={handleOpenChange}
onConfirm={vi.fn()}
/>,
)
@ -175,6 +175,7 @@ describe('human-input/delivery-method/email-configure-modal', () => {
fireEvent.click(screen.getByRole('dialog').querySelector('.absolute') as HTMLDivElement)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(handleClose).toHaveBeenCalledTimes(2)
expect(handleOpenChange).toHaveBeenCalledTimes(2)
expect(handleOpenChange).toHaveBeenCalledWith(false)
})
})

View File

@ -1,4 +1,4 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { DeliveryMethodType } from '../../../types'
import DeliveryMethodForm from '../index'
@ -9,11 +9,6 @@ vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation(),
}))
vi.mock('@/app/components/base/tooltip', () => ({
__esModule: true,
default: ({ popupContent }: { popupContent: string }) => <div data-testid="tooltip">{popupContent}</div>,
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesSyncDraft: () => mockUseNodesSyncDraft(),
}))
@ -62,15 +57,6 @@ vi.mock('../method-item', () => ({
),
}))
vi.mock('../upgrade-modal', () => ({
__esModule: true,
default: ({ onClose }: { onClose: () => void }) => (
<button type="button" onClick={onClose}>
upgrade-modal
</button>
),
}))
describe('DeliveryMethodForm', () => {
const onChange = vi.fn()
const mockHandleSyncWorkflowDraft = vi.fn()
@ -132,7 +118,7 @@ describe('DeliveryMethodForm', () => {
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true, true)
})
it('should open and close the upgrade modal', () => {
it('should open and close the upgrade modal', async () => {
render(
<DeliveryMethodForm
nodeId="node-1"
@ -142,9 +128,9 @@ describe('DeliveryMethodForm', () => {
)
fireEvent.click(screen.getByText('show-upgrade'))
expect(screen.getByText('upgrade-modal')).toBeInTheDocument()
expect(screen.getByRole('dialog')).toBeInTheDocument()
fireEvent.click(screen.getByText('upgrade-modal'))
expect(screen.queryByText('upgrade-modal')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'nodes.humanInput.deliveryMethod.upgradeTipHide' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
})
})

View File

@ -6,15 +6,15 @@ import { DeliveryMethodType } from '../../../types'
import DeliveryMethodItem from '../method-item'
type EmailConfigureModalProps = {
isShow: boolean
open: boolean
config?: EmailConfig
onClose: () => void
onOpenChange: (open: boolean) => void
onConfirm: (data: EmailConfig) => void
}
type TestEmailSenderProps = {
isShow: boolean
onClose: () => void
open: boolean
onOpenChange: (open: boolean) => void
jumpToEmailConfigModal: () => void
}
@ -30,7 +30,7 @@ vi.mock('@/context/app-context', () => ({
vi.mock('../email-configure-modal', () => ({
default: (props: EmailConfigureModalProps) => {
mockEmailConfigureModal(props)
return props.isShow
return props.open
? (
<div data-testid="email-configure-modal">
<button
@ -44,7 +44,7 @@ vi.mock('../email-configure-modal', () => ({
>
confirm-email-config
</button>
<button type="button" onClick={props.onClose}>close-email-config</button>
<button type="button" onClick={() => props.onOpenChange(false)}>close-email-config</button>
</div>
)
: null
@ -54,11 +54,11 @@ vi.mock('../email-configure-modal', () => ({
vi.mock('../test-email-sender', () => ({
default: (props: TestEmailSenderProps) => {
mockTestEmailSender(props)
return props.isShow
return props.open
? (
<div data-testid="test-email-sender">
<button type="button" onClick={props.jumpToEmailConfigModal}>jump-to-config</button>
<button type="button" onClick={props.onClose}>close-test-sender</button>
<button type="button" onClick={() => props.onOpenChange(false)}>close-test-sender</button>
</div>
)
: null
@ -140,14 +140,14 @@ describe('human-input/delivery-method/method-item', () => {
const row = getMethodRow('webapp')
const actionButtons = within(row).getAllByRole('button')
const deleteButtonWrapper = actionButtons[0]!.parentElement as HTMLDivElement
const deleteButton = actionButtons[0]!
fireEvent.mouseEnter(deleteButtonWrapper)
fireEvent.mouseEnter(deleteButton)
expect(row)!.toHaveClass('border-state-destructive-border')
fireEvent.mouseLeave(deleteButtonWrapper)
fireEvent.mouseLeave(deleteButton)
expect(row).not.toHaveClass('border-state-destructive-border')
fireEvent.click(actionButtons[0]!)
fireEvent.click(deleteButton)
expect(handleDelete).toHaveBeenCalledWith(DeliveryMethodType.WebApp)
})

View File

@ -67,7 +67,7 @@ describe('human-input/delivery-method/method-selector', () => {
})
expect(handleShowUpgradeTip).not.toHaveBeenCalled()
expect(screen.getByText('workflow.nodes.humanInput.deliveryMethod.contactTip1')).toBeInTheDocument()
expect(screen.getByRole('tooltip')).toHaveTextContent('nodes.humanInput.deliveryMethod.contactTip2')
expect(screen.getByText('nodes.humanInput.deliveryMethod.contactTip2')).toBeInTheDocument()
})
it('should disable webapp in trigger mode and show added states without creating duplicates', () => {

View File

@ -0,0 +1,298 @@
import type { ReactNode } from 'react'
import type { EmailConfig, FormInputItem } from '../../../types'
import type { App, AppSSO } from '@/types/app'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useStore as useAppStore } from '@/app/components/app/store'
import { HooksStoreContext } from '@/app/components/workflow/hooks-store/provider'
import { createHooksStore } from '@/app/components/workflow/hooks-store/store'
import { BlockEnum, InputVarType, VarType } from '@/app/components/workflow/types'
import { AppContext, initialLangGeniusVersionInfo, initialWorkspaceInfo, userProfilePlaceholder } from '@/context/app-context'
import EmailSenderModal from '../test-email-sender'
type RecordedRequest = {
url: string
method: string
body?: unknown
}
const createQueryClient = () => new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
mutations: {
retry: false,
},
},
})
const renderWithProviders = (ui: ReactNode) => {
const queryClient = createQueryClient()
const hooksStore = createHooksStore({})
return render(
<QueryClientProvider client={queryClient}>
<AppContext.Provider
value={{
userProfile: {
...userProfilePlaceholder,
id: 'user-1',
email: 'owner@example.com',
name: 'Owner',
},
currentWorkspace: {
...initialWorkspaceInfo,
id: 'workspace-1',
name: 'Product Team',
},
isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: true,
isCurrentWorkspaceEditor: true,
isCurrentWorkspaceDatasetOperator: true,
mutateUserProfile: vi.fn(),
mutateCurrentWorkspace: vi.fn(),
langGeniusVersionInfo: initialLangGeniusVersionInfo,
useSelector: selector => selector({
userProfile: {
...userProfilePlaceholder,
id: 'user-1',
email: 'owner@example.com',
name: 'Owner',
},
currentWorkspace: {
...initialWorkspaceInfo,
id: 'workspace-1',
name: 'Product Team',
},
isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: true,
isCurrentWorkspaceEditor: true,
isCurrentWorkspaceDatasetOperator: true,
mutateUserProfile: vi.fn(),
mutateCurrentWorkspace: vi.fn(),
langGeniusVersionInfo: initialLangGeniusVersionInfo,
useSelector: vi.fn(),
isLoadingCurrentWorkspace: false,
isValidatingCurrentWorkspace: false,
}),
isLoadingCurrentWorkspace: false,
isValidatingCurrentWorkspace: false,
}}
>
<HooksStoreContext.Provider value={hooksStore}>
{ui}
</HooksStoreContext.Provider>
</AppContext.Provider>
</QueryClientProvider>,
)
}
const setupFetch = () => {
const requests: RecordedRequest[] = []
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (resource: RequestInfo | URL, options?: RequestInit) => {
const request = resource instanceof Request ? resource : new Request(resource, options)
const body = request.method === 'GET' ? undefined : await request.clone().json()
requests.push({
url: request.url,
method: request.method,
body,
})
if (request.url.includes('/workspaces/current/members')) {
return new Response(JSON.stringify({
accounts: [
{
id: 'member-1',
email: 'member@example.com',
name: 'Member One',
avatar: '',
avatar_url: '',
status: 'active',
role: 'normal',
created_at: '',
last_active_at: '',
last_login_at: '',
},
],
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
return new Response(JSON.stringify({ result: 'success' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
})
return {
fetchSpy,
requests,
}
}
const createConfig = (overrides: Partial<EmailConfig> = {}): EmailConfig => ({
recipients: {
whole_workspace: true,
items: [],
},
subject: 'Review request',
body: 'Please review {{#start.score#}}',
debug_mode: false,
...overrides,
})
const createFormInput = (overrides: Partial<FormInputItem> = {}): FormInputItem => ({
type: InputVarType.textInput,
output_variable_name: 'user_name',
default: {
type: 'variable',
selector: ['start', 'user_name'],
value: '',
},
...overrides,
})
describe('human-input/delivery-method/test-email-sender', () => {
beforeEach(() => {
vi.clearAllMocks()
useAppStore.setState({
appDetail: {
id: 'app-1',
name: 'Workflow App',
} as App & Partial<AppSSO>,
})
})
afterEach(() => {
vi.restoreAllMocks()
})
it('should submit generated variable inputs and show the success state', async () => {
const user = userEvent.setup()
const { requests } = setupFetch()
const handleOpenChange = vi.fn()
renderWithProviders(
<EmailSenderModal
nodeId="human-node"
deliveryId="delivery-1"
open
onOpenChange={handleOpenChange}
jumpToEmailConfigModal={vi.fn()}
config={createConfig()}
formInputs={[createFormInput()]}
availableNodes={[
{
id: 'start',
type: 'custom',
position: { x: 0, y: 0 },
data: {
title: 'Start',
desc: '',
type: BlockEnum.Start,
},
},
]}
nodesOutputVars={[
{
nodeId: 'start',
title: 'Start',
vars: [
{
variable: 'user_name',
type: VarType.string,
},
{
variable: 'score',
type: VarType.number,
},
],
},
]}
/>,
)
const sendButton = screen.getByRole('button', { name: 'workflow.nodes.humanInput.deliveryMethod.emailSender.send' })
expect(sendButton).toBeDisabled()
await user.type(screen.getByPlaceholderText('user_name'), 'Ada')
await user.type(screen.getByPlaceholderText('score'), '42')
expect(sendButton).toBeEnabled()
await user.click(sendButton)
await waitFor(() => expect(screen.getByText('workflow.nodes.humanInput.deliveryMethod.emailSender.done')).toBeInTheDocument())
expect(requests).toContainEqual(expect.objectContaining({
url: 'http://localhost:5001/console/api/apps/app-1/workflows/draft/human-input/nodes/human-node/delivery-test',
method: 'POST',
body: {
delivery_method_id: 'delivery-1',
inputs: {
'#start.user_name#': 'Ada',
'#start.score#': '42',
},
},
}))
await user.click(screen.getByRole('button', { name: 'common.operation.ok' }))
expect(handleOpenChange).toHaveBeenCalledWith(false)
})
it('should render fallback variable inputs and allow cancelling', async () => {
const user = userEvent.setup()
setupFetch()
const handleOpenChange = vi.fn()
renderWithProviders(
<EmailSenderModal
nodeId="human-node"
deliveryId="delivery-1"
open
onOpenChange={handleOpenChange}
jumpToEmailConfigModal={vi.fn()}
config={createConfig({
body: 'Please review {{#unknown.message#}}',
})}
/>,
)
expect(screen.getByPlaceholderText('message')).toBeInTheDocument()
await user.click(screen.getByText('workflow.nodes.humanInput.deliveryMethod.emailSender.vars'))
expect(screen.queryByPlaceholderText('message')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(handleOpenChange).toHaveBeenCalledWith(false)
})
it('should show selected recipients with the email configuration tip', () => {
setupFetch()
renderWithProviders(
<EmailSenderModal
nodeId="human-node"
deliveryId="delivery-1"
open
onOpenChange={vi.fn()}
jumpToEmailConfigModal={vi.fn()}
config={createConfig({
recipients: {
whole_workspace: true,
items: [{ type: 'external', email: 'external@example.com' }],
},
body: 'Please review {{#url#}}',
})}
/>,
)
expect(screen.getByText('external@example.com')).toBeInTheDocument()
expect(screen.getByText('nodes.humanInput.deliveryMethod.emailSender.tip')).toBeInTheDocument()
})
})

View File

@ -1,5 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react'
import UpgradeModal from '../upgrade-modal'
import { UpgradeModal } from '../upgrade-modal'
const mockUseModalContextSelector = vi.hoisted(() => vi.fn())
@ -32,8 +32,8 @@ describe('human-input/delivery-method/upgrade-modal', () => {
render(
<UpgradeModal
isShow
onClose={handleClose}
open
onOpenChange={handleClose}
/>,
)
@ -41,7 +41,7 @@ describe('human-input/delivery-method/upgrade-modal', () => {
expect(screen.getByText('workflow.nodes.humanInput.deliveryMethod.upgradeTipContent')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'workflow.nodes.humanInput.deliveryMethod.upgradeTipHide' }))
expect(handleClose).toHaveBeenCalledTimes(1)
expect(handleClose).toHaveBeenCalledWith(false)
fireEvent.click(screen.getByRole('button', { name: /billing.upgradeBtn.encourageShort/i }))
expect(handleShowPricingModal).toHaveBeenCalledTimes(1)

View File

@ -4,15 +4,13 @@ import type {
NodeOutPutVar,
} from '@/app/components/workflow/types'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { Switch } from '@langgenius/dify-ui/switch'
import { toast } from '@langgenius/dify-ui/toast'
import { RiBugLine, RiCloseLine } from '@remixicon/react'
import { noop } from 'es-toolkit/compat'
import { RiBugLine } from '@remixicon/react'
import { memo, useCallback, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import MailBodyInput from './mail-body-input'
import Recipient from './recipient'
@ -20,8 +18,8 @@ import Recipient from './recipient'
const i18nPrefix = 'nodes.humanInput'
type EmailConfigureModalProps = {
isShow: boolean
onClose: () => void
open: boolean
onOpenChange: (open: boolean) => void
onConfirm: (data: EmailConfig) => void
config?: EmailConfig
nodesOutputVars?: NodeOutPutVar[]
@ -29,8 +27,8 @@ type EmailConfigureModalProps = {
}
const EmailConfigureModal = ({
isShow,
onClose,
open,
onOpenChange,
onConfirm,
config,
nodesOutputVars = [],
@ -78,89 +76,87 @@ const EmailConfigureModal = ({
}, [checkValidConfig, onConfirm, recipients, subject, body, debugMode])
return (
<Modal
isShow={isShow}
onClose={noop}
className="relative max-w-[720px]! p-0!"
<Dialog
open={open}
onOpenChange={onOpenChange}
>
<div className="absolute top-5 right-5 cursor-pointer p-1.5" onClick={onClose}>
<RiCloseLine className="h-5 w-5 text-text-tertiary" />
</div>
<div className="space-y-1 p-6 pb-3">
<div className="title-2xl-semi-bold text-text-primary">{t(`${i18nPrefix}.deliveryMethod.emailConfigure.title`, { ns: 'workflow' })}</div>
<div className="system-xs-regular text-text-tertiary">{t(`${i18nPrefix}.deliveryMethod.emailConfigure.description`, { ns: 'workflow' })}</div>
</div>
<div className="space-y-5 px-6 py-3">
<div>
<div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary">
{t(`${i18nPrefix}.deliveryMethod.emailConfigure.subject`, { ns: 'workflow' })}
</div>
<Input
className="w-full"
value={subject}
onChange={e => setSubject(e.target.value)}
placeholder={t(`${i18nPrefix}.deliveryMethod.emailConfigure.subjectPlaceholder`, { ns: 'workflow' })}
/>
<DialogContent className="max-h-[calc(100dvh-64px)]! w-[720px]!">
<DialogCloseButton />
<div className="space-y-1 pr-8">
<DialogTitle className="title-2xl-semi-bold text-text-primary">{t(`${i18nPrefix}.deliveryMethod.emailConfigure.title`, { ns: 'workflow' })}</DialogTitle>
<div className="system-xs-regular text-text-tertiary">{t(`${i18nPrefix}.deliveryMethod.emailConfigure.description`, { ns: 'workflow' })}</div>
</div>
<div>
<div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary">
{t(`${i18nPrefix}.deliveryMethod.emailConfigure.body`, { ns: 'workflow' })}
</div>
<MailBodyInput
value={body}
onChange={setBody}
nodesOutputVars={nodesOutputVars}
availableNodes={availableNodes}
/>
</div>
<div>
<div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary">
{t(`${i18nPrefix}.deliveryMethod.emailConfigure.recipient`, { ns: 'workflow' })}
</div>
<Recipient
data={recipients}
onChange={setRecipients}
/>
</div>
<Divider className="my-0! mt-5! h-px!" />
<div className="flex items-start justify-between gap-2 rounded-[10px] border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-3 pl-2.5 shadow-xs">
<div className="rounded-sm border border-divider-regular bg-components-icon-bg-orange-dark-solid p-0.5">
<RiBugLine className="h-3.5 w-3.5 text-text-primary-on-surface" />
</div>
<div className="grow space-y-1">
<div className="system-sm-medium text-text-secondary">{t(`${i18nPrefix}.deliveryMethod.emailConfigure.debugMode`, { ns: 'workflow' })}</div>
<div className="body-xs-regular text-text-tertiary">
<Trans
i18nKey={`${i18nPrefix}.deliveryMethod.emailConfigure.debugModeTip1`}
ns="workflow"
components={{ email: <span className="body-md-medium text-text-primary">{email}</span> }}
values={{ email }}
/>
<div>{t(`${i18nPrefix}.deliveryMethod.emailConfigure.debugModeTip2`, { ns: 'workflow' })}</div>
<div className="mt-6 space-y-5">
<div>
<div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary">
{t(`${i18nPrefix}.deliveryMethod.emailConfigure.subject`, { ns: 'workflow' })}
</div>
<Input
className="w-full"
value={subject}
onChange={e => setSubject(e.target.value)}
placeholder={t(`${i18nPrefix}.deliveryMethod.emailConfigure.subjectPlaceholder`, { ns: 'workflow' })}
/>
</div>
<div>
<div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary">
{t(`${i18nPrefix}.deliveryMethod.emailConfigure.body`, { ns: 'workflow' })}
</div>
<MailBodyInput
value={body}
onChange={setBody}
nodesOutputVars={nodesOutputVars}
availableNodes={availableNodes}
/>
</div>
<div>
<div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary">
{t(`${i18nPrefix}.deliveryMethod.emailConfigure.recipient`, { ns: 'workflow' })}
</div>
<Recipient
data={recipients}
onChange={setRecipients}
/>
</div>
<div className="flex items-start justify-between gap-2 rounded-[10px] border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-3 pl-2.5 shadow-xs">
<div className="rounded-sm border border-divider-regular bg-components-icon-bg-orange-dark-solid p-0.5">
<RiBugLine className="h-3.5 w-3.5 text-text-primary-on-surface" />
</div>
<div className="grow space-y-1">
<div className="system-sm-medium text-text-secondary">{t(`${i18nPrefix}.deliveryMethod.emailConfigure.debugMode`, { ns: 'workflow' })}</div>
<div className="body-xs-regular text-text-tertiary">
<Trans
i18nKey={`${i18nPrefix}.deliveryMethod.emailConfigure.debugModeTip1`}
ns="workflow"
components={{ email: <span className="body-md-medium text-text-primary">{email}</span> }}
values={{ email }}
/>
<div>{t(`${i18nPrefix}.deliveryMethod.emailConfigure.debugModeTip2`, { ns: 'workflow' })}</div>
</div>
</div>
<Switch
checked={debugMode}
onCheckedChange={checked => setDebugMode(checked)}
/>
</div>
<Switch
checked={debugMode}
onCheckedChange={checked => setDebugMode(checked)}
/>
</div>
</div>
<div className="flex flex-row-reverse gap-2 p-6 pt-5">
<Button
variant="primary"
className="w-[72px]"
onClick={handleConfirm}
>
{t('operation.save', { ns: 'common' })}
</Button>
<Button
className="w-[72px]"
onClick={onClose}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
</div>
</Modal>
<div className="mt-6 flex flex-row-reverse gap-2">
<Button
variant="primary"
className="w-[72px]"
onClick={handleConfirm}
>
{t('operation.save', { ns: 'common' })}
</Button>
<Button
className="w-[72px]"
onClick={() => onOpenChange(false)}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -3,14 +3,14 @@ import type {
Node,
NodeOutPutVar,
} from '@/app/components/workflow/types'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { produce } from 'immer'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { Infotip } from '@/app/components/base/infotip'
import { useNodesSyncDraft } from '@/app/components/workflow/hooks'
import MethodItem from './method-item'
import MethodSelector from './method-selector'
import UpgradeModal from './upgrade-modal'
import { UpgradeModal } from './upgrade-modal'
const i18nPrefix = 'nodes.humanInput'
@ -62,27 +62,15 @@ const DeliveryMethodForm: React.FC<Props> = ({
const handleShowUpgradeModal = () => {
setShowUpgradeModal(true)
}
const handleCloseUpgradeModal = () => {
setShowUpgradeModal(false)
}
return (
<div className="px-4 py-2">
<div className="mb-1 flex items-center justify-between">
<div className="flex items-center gap-0.5">
<div className="system-sm-semibold-uppercase text-text-secondary">{t(`${i18nPrefix}.deliveryMethod.title`, { ns: 'workflow' })}</div>
<Tooltip>
<TooltipTrigger
render={(
<span className="flex h-3.5 w-3.5 shrink-0 p-px">
<span aria-hidden className="i-ri-question-line h-full w-full text-text-quaternary hover:text-text-tertiary" />
</span>
)}
/>
<TooltipContent>
{t(`${i18nPrefix}.deliveryMethod.tooltip`, { ns: 'workflow' })}
</TooltipContent>
</Tooltip>
<Infotip aria-label={t(`${i18nPrefix}.deliveryMethod.tooltip`, { ns: 'workflow' })}>
{t(`${i18nPrefix}.deliveryMethod.tooltip`, { ns: 'workflow' })}
</Infotip>
</div>
{!readonly && (
<div className="flex items-center px-1">
@ -115,12 +103,10 @@ const DeliveryMethodForm: React.FC<Props> = ({
))}
</div>
)}
{showUpgradeModal && (
<UpgradeModal
isShow={showUpgradeModal}
onClose={handleCloseUpgradeModal}
/>
)}
<UpgradeModal
open={showUpgradeModal}
onOpenChange={setShowUpgradeModal}
/>
</div>
)
}

View File

@ -7,6 +7,7 @@ import type {
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Switch } from '@langgenius/dify-ui/switch'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import {
RiDeleteBinLine,
RiEqualizer2Line,
@ -18,7 +19,6 @@ import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
import Badge from '@/app/components/base/badge/index'
import Tooltip from '@/app/components/base/tooltip'
import Indicator from '@/app/components/header/indicator'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { DeliveryMethodType } from '../../types'
@ -79,6 +79,8 @@ const DeliveryMethodItem: FC<DeliveryMethodItemProps> = ({
}
return t(`${i18nPrefix}.deliveryMethod.emailSender.testSendTip`, { ns: 'workflow' })
}, [method.type, method.config?.debug_mode, t, email])
const configureLabel = t('common.configure', { ns: 'workflow' })
const removeLabel = t('operation.remove', { ns: 'common' })
const jumpToEmailConfigModal = useCallback(() => {
setShowTestEmailModal(false)
@ -114,47 +116,49 @@ const DeliveryMethodItem: FC<DeliveryMethodItemProps> = ({
<div className="hidden items-end gap-1 group-hover:flex">
{method.type === DeliveryMethodType.Email && method.config && (
<>
<Tooltip
popupContent={emailSenderTooltipContent}
asChild={false}
needsDelay={false}
>
<ActionButton
onClick={() => {
setShowTestEmailModal(true)
}}
>
<RiSendPlane2Line className="h-4 w-4" />
</ActionButton>
<Tooltip>
<TooltipTrigger
render={(
<ActionButton
aria-label={emailSenderTooltipContent}
onClick={() => setShowTestEmailModal(true)}
>
<RiSendPlane2Line className="h-4 w-4" />
</ActionButton>
)}
/>
<TooltipContent>{emailSenderTooltipContent}</TooltipContent>
</Tooltip>
<Tooltip
popupContent={t('common.configure', { ns: 'workflow' })}
asChild={false}
needsDelay={false}
>
<ActionButton onClick={() => setShowEmailModal(true)}>
<RiEqualizer2Line className="h-4 w-4" />
</ActionButton>
<Tooltip>
<TooltipTrigger
render={(
<ActionButton
aria-label={configureLabel}
onClick={() => setShowEmailModal(true)}
>
<RiEqualizer2Line className="h-4 w-4" />
</ActionButton>
)}
/>
<TooltipContent>{configureLabel}</TooltipContent>
</Tooltip>
</>
)}
<Tooltip
popupContent={t('operation.remove', { ns: 'common' })}
asChild={false}
needsDelay={false}
>
<div
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
<ActionButton
state={isHovering ? ActionButtonState.Destructive : ActionButtonState.Default}
onClick={() => onDelete(method.type)}
>
<RiDeleteBinLine className="h-4 w-4" />
</ActionButton>
</div>
<Tooltip>
<TooltipTrigger
render={(
<ActionButton
aria-label={removeLabel}
state={isHovering ? ActionButtonState.Destructive : ActionButtonState.Default}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
onClick={() => onDelete(method.type)}
>
<RiDeleteBinLine className="h-4 w-4" />
</ActionButton>
)}
/>
<TooltipContent>{removeLabel}</TooltipContent>
</Tooltip>
</div>
)}
@ -178,33 +182,29 @@ const DeliveryMethodItem: FC<DeliveryMethodItemProps> = ({
)}
</div>
</div>
{showEmailModal && (
<EmailConfigureModal
isShow={showEmailModal}
config={method.config as EmailConfig}
nodesOutputVars={nodesOutputVars}
availableNodes={availableNodes}
onClose={() => setShowEmailModal(false)}
onConfirm={(data) => {
handleConfigChange(data)
setShowEmailModal(false)
}}
/>
)}
{showTestEmailModal && (
<TestEmailSender
nodeId={nodeId}
deliveryId={method.id}
isShow={showTestEmailModal}
config={method.config as EmailConfig}
formContent={formContent}
formInputs={formInputs}
nodesOutputVars={nodesOutputVars}
availableNodes={availableNodes}
onClose={() => setShowTestEmailModal(false)}
jumpToEmailConfigModal={jumpToEmailConfigModal}
/>
)}
<EmailConfigureModal
open={showEmailModal}
config={method.config as EmailConfig}
nodesOutputVars={nodesOutputVars}
availableNodes={availableNodes}
onOpenChange={setShowEmailModal}
onConfirm={(data) => {
handleConfigChange(data)
setShowEmailModal(false)
}}
/>
<TestEmailSender
nodeId={nodeId}
deliveryId={method.id}
open={showTestEmailModal}
config={method.config as EmailConfig}
formContent={formContent}
formInputs={formInputs}
nodesOutputVars={nodesOutputVars}
availableNodes={availableNodes}
onOpenChange={setShowTestEmailModal}
jumpToEmailConfigModal={jumpToEmailConfigModal}
/>
</>
)
}

View File

@ -2,6 +2,11 @@
import type { FC } from 'react'
import type { DeliveryMethod } from '../../types'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import {
RiAddLine,
RiDiscordFill,
@ -9,17 +14,12 @@ import {
RiMailSendFill,
RiRobot2Fill,
} from '@remixicon/react'
import { memo, useCallback, useMemo, useRef, useState } from 'react'
import { memo, useMemo, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { v4 as uuid4 } from 'uuid'
import ActionButton from '@/app/components/base/action-button'
import Badge from '@/app/components/base/badge'
import { Slack, Teams } from '@/app/components/base/icons/src/public/other'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import useWorkflowNodes from '@/app/components/workflow/store/workflow/use-nodes'
import { isTriggerWorkflow } from '@/app/components/workflow/utils/workflow-entry'
import { IS_CE_EDITION } from '@/config'
@ -40,20 +40,10 @@ const MethodSelector: FC<MethodSelectorProps> = ({
onShowUpgradeTip,
}) => {
const { t } = useTranslation()
const [open, doSetOpen] = useState(false)
const [open, setOpen] = useState(false)
const humanInputEmailDeliveryEnabled = useProviderContextSelector(s => s.humanInputEmailDeliveryEnabled)
const openRef = useRef(open)
const nodes = useWorkflowNodes()
const setOpen = useCallback((v: boolean) => {
doSetOpen(v)
openRef.current = v
}, [doSetOpen])
const handleTrigger = useCallback(() => {
setOpen(!openRef.current)
}, [setOpen])
const webAppDeliveryInfo = useMemo(() => {
const isTriggerMode = isTriggerWorkflow(nodes)
return {
@ -71,23 +61,25 @@ const MethodSelector: FC<MethodSelectorProps> = ({
}, [data, humanInputEmailDeliveryEnabled])
return (
<PortalToFollowElem
<Popover
open={open}
onOpenChange={setOpen}
placement="bottom-end"
offset={{
mainAxis: 4,
crossAxis: 12,
}}
>
<PortalToFollowElemTrigger onClick={handleTrigger}>
<div>
<ActionButton className={cn(open && 'bg-state-base-hover')}>
<PopoverTrigger
render={(
<ActionButton
aria-label={t(`${i18nPrefix}.deliveryMethod.title`, { ns: 'workflow' })}
className={cn(open && 'bg-state-base-hover')}
>
<RiAddLine className="h-4 w-4" />
</ActionButton>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-50">
)}
/>
<PopoverContent
placement="bottom-end"
sideOffset={4}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
>
<div className="w-[360px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
<div className="p-1">
<div
@ -215,8 +207,8 @@ const MethodSelector: FC<MethodSelectorProps> = ({
</div>
</div>
)}
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}
export default memo(MethodSelector)

View File

@ -3,16 +3,17 @@ import type {
Node,
NodeOutPutVar,
ValueSelector,
Var,
} from '@/app/components/workflow/types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { RiArrowRightSFill, RiCloseLine } from '@remixicon/react'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { RiArrowRightSFill } from '@remixicon/react'
import { noop, unionBy } from 'es-toolkit/compat'
import { memo, useCallback, useMemo, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import Divider from '@/app/components/base/divider'
import Modal from '@/app/components/base/modal'
import { getInputVars as doGetInputVars } from '@/app/components/base/prompt-editor/constants'
import FormItem from '@/app/components/workflow/nodes/_base/components/before-run-form/form-item'
import {
@ -30,11 +31,11 @@ import EmailInput from './recipient/email-input'
const i18nPrefix = 'nodes.humanInput'
type EmailConfigureModalProps = {
type EmailSenderModalProps = {
nodeId: string
deliveryId: string
isShow: boolean
onClose: () => void
open: boolean
onOpenChange: (open: boolean) => void
jumpToEmailConfigModal: () => void
config?: EmailConfig
formContent?: string
@ -48,18 +49,22 @@ const getOriginVar = (valueSelector: string[], list: NodeOutPutVar[]) => {
if (!targetVar)
return undefined
let curr: any = targetVar.vars
let curr: Var[] | undefined = targetVar.vars
for (let i = 1; i < valueSelector.length; i++) {
const key = valueSelector[i]
const isLast = i === valueSelector.length - 1
const currentVar: Var | undefined = curr?.find(v => v.variable.replace('conversation.', '') === key)
if (Array.isArray(curr))
curr = curr.find((v: any) => v.variable.replace('conversation.', '') === key)
if (!currentVar)
return undefined
if (isLast)
return curr
else if (curr?.type === VarType.object || curr?.type === VarType.file)
curr = curr.children
return currentVar
if ((currentVar.type === VarType.object || currentVar.type === VarType.file) && Array.isArray(currentVar.children))
curr = currentVar.children
else
return undefined
}
return undefined
@ -68,15 +73,15 @@ const getOriginVar = (valueSelector: string[], list: NodeOutPutVar[]) => {
const EmailSenderModal = ({
nodeId,
deliveryId,
isShow,
onClose,
open,
onOpenChange,
jumpToEmailConfigModal,
config,
formContent,
formInputs,
nodesOutputVars = [],
availableNodes = [],
}: EmailConfigureModalProps) => {
}: EmailSenderModalProps) => {
const { t } = useTranslation()
const { userProfile, currentWorkspace } = useAppContext()
const appDetail = useAppStore(state => state.appDetail)
@ -104,7 +109,7 @@ const EmailSenderModal = ({
return {
label: {
nodeType: varInfo?.type,
nodeName: varInfo?.title || availableNodes[0]?.data.title!, // default start node title
nodeName: varInfo?.title || availableNodes[0]?.data.title || '',
variable: isSystemVar(item) ? item.join('.') : item[item.length - 1]!,
isChatVar: isConversationVar(item),
},
@ -178,194 +183,194 @@ const EmailSenderModal = ({
if (done) {
return (
<Modal
isShow={isShow}
onClose={noop}
className="relative max-w-[480px]! p-0!"
<Dialog
open={open}
onOpenChange={onOpenChange}
>
<div className="space-y-2 p-6 pb-3">
<div className="title-2xl-semi-bold text-text-primary">{t(`${i18nPrefix}.deliveryMethod.emailSender.done`, { ns: 'workflow' })}</div>
{debugEnabled && (
<div className="system-md-regular text-text-secondary">
<Trans
i18nKey={`${i18nPrefix}.deliveryMethod.emailSender.debugDone`}
ns="workflow"
components={{ email: <span className="system-md-semibold text-text-secondary"></span> }}
values={{ email: userProfile.email }}
<DialogContent>
<div className="space-y-2">
<DialogTitle className="title-2xl-semi-bold text-text-primary">{t(`${i18nPrefix}.deliveryMethod.emailSender.done`, { ns: 'workflow' })}</DialogTitle>
{debugEnabled && (
<div className="system-md-regular text-text-secondary">
<Trans
i18nKey={`${i18nPrefix}.deliveryMethod.emailSender.debugDone`}
ns="workflow"
components={{ email: <span className="system-md-semibold text-text-secondary"></span> }}
values={{ email: userProfile.email }}
/>
</div>
)}
{!debugEnabled && onlyWholeTeam && (
<div className="system-md-regular text-text-secondary">
<Trans
i18nKey={`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamDone2`}
ns="workflow"
components={{ team: <span className="system-md-medium text-text-secondary"></span> }}
values={{ team: currentWorkspace.name.replace(/'/g, '') }}
/>
</div>
)}
{!debugEnabled && onlySpecificUsers && (
<div className="system-md-regular text-text-secondary">{t(`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamDone3`, { ns: 'workflow' })}</div>
)}
{!debugEnabled && combinedRecipients && (
<div className="system-md-regular text-text-secondary">
<Trans
i18nKey={`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamDone1`}
ns="workflow"
components={{ team: <span className="system-md-medium text-text-secondary"></span> }}
values={{ team: currentWorkspace.name.replace(/'/g, '') }}
/>
</div>
)}
</div>
{(onlySpecificUsers || combinedRecipients) && !debugEnabled && (
<div className="mt-4">
<EmailInput
disabled
email={userProfile.email}
value={config?.recipients?.items}
list={accounts}
onDelete={noop}
onSelect={noop}
onAdd={noop}
/>
</div>
)}
<div className="mt-6 flex flex-row-reverse gap-2">
<Button
variant="primary"
className="w-[72px]"
onClick={() => onOpenChange(false)}
>
{t('operation.ok', { ns: 'common' })}
</Button>
</div>
</DialogContent>
</Dialog>
)
}
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
>
<DialogContent>
<DialogCloseButton />
<div className="space-y-1 pr-8">
<DialogTitle className="title-2xl-semi-bold text-text-primary">{t(`${i18nPrefix}.deliveryMethod.emailSender.title`, { ns: 'workflow' })}</DialogTitle>
{debugEnabled && (
<>
<div className="system-sm-regular text-text-secondary">{t(`${i18nPrefix}.deliveryMethod.emailSender.debugModeTip`, { ns: 'workflow' })}</div>
<div className="system-sm-regular text-text-secondary">
<Trans
i18nKey={`${i18nPrefix}.deliveryMethod.emailSender.debugModeTip2`}
ns="workflow"
components={{ email: <span className="system-sm-semibold text-text-primary"></span> }}
values={{ email: userProfile.email }}
/>
</div>
</>
)}
{!debugEnabled && onlyWholeTeam && (
<div className="system-md-regular text-text-secondary">
<div className="system-sm-regular text-text-secondary">
<Trans
i18nKey={`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamDone2`}
i18nKey={`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamTip2`}
ns="workflow"
components={{ team: <span className="system-md-medium text-text-secondary"></span> }}
components={{ team: <span className="system-sm-semibold text-text-primary"></span> }}
values={{ team: currentWorkspace.name.replace(/'/g, '') }}
/>
</div>
)}
{!debugEnabled && onlySpecificUsers && (
<div className="system-md-regular text-text-secondary">{t(`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamDone3`, { ns: 'workflow' })}</div>
<div className="system-sm-regular text-text-secondary">{t(`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamTip3`, { ns: 'workflow' })}</div>
)}
{!debugEnabled && combinedRecipients && (
<div className="system-md-regular text-text-secondary">
<div className="system-sm-regular text-text-secondary">
<Trans
i18nKey={`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamDone1`}
i18nKey={`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamTip1`}
ns="workflow"
components={{ team: <span className="system-md-medium text-text-secondary"></span> }}
components={{ team: <span className="system-sm-semibold text-text-primary"></span> }}
values={{ team: currentWorkspace.name.replace(/'/g, '') }}
/>
</div>
)}
</div>
{(onlySpecificUsers || combinedRecipients) && !debugEnabled && (
<div className="px-5">
<EmailInput
disabled
email={userProfile.email}
value={config?.recipients?.items}
list={accounts}
onDelete={noop}
onSelect={noop}
onAdd={noop}
/>
</div>
)}
<div className="flex flex-row-reverse gap-2 p-6 pt-5">
<Button
variant="primary"
className="w-[72px]"
onClick={onClose}
>
{t('operation.ok', { ns: 'common' })}
</Button>
</div>
</Modal>
)
}
return (
<Modal
isShow={isShow}
onClose={noop}
className="relative max-w-[480px]! p-0!"
>
<div className="absolute top-5 right-5 cursor-pointer p-1.5" onClick={onClose}>
<RiCloseLine className="h-5 w-5 text-text-tertiary" />
</div>
<div className="space-y-1 p-6 pb-3">
<div className="title-2xl-semi-bold text-text-primary">{t(`${i18nPrefix}.deliveryMethod.emailSender.title`, { ns: 'workflow' })}</div>
{debugEnabled && (
<>
<div className="system-sm-regular text-text-secondary">{t(`${i18nPrefix}.deliveryMethod.emailSender.debugModeTip`, { ns: 'workflow' })}</div>
<div className="system-sm-regular text-text-secondary">
<div className="mt-4">
<EmailInput
disabled
email={userProfile.email}
value={config?.recipients?.items}
list={accounts}
onDelete={noop}
onSelect={noop}
onAdd={noop}
/>
</div>
<div className="mt-1 system-xs-regular text-text-tertiary">
<Trans
i18nKey={`${i18nPrefix}.deliveryMethod.emailSender.debugModeTip2`}
i18nKey={`${i18nPrefix}.deliveryMethod.emailSender.tip`}
ns="workflow"
components={{ email: <span className="system-sm-semibold text-text-primary"></span> }}
values={{ email: userProfile.email }}
components={{
strong: <span onClick={jumpToEmailConfigModal} className="cursor-pointer system-xs-regular text-text-accent"></span>,
}}
/>
</div>
</>
)}
{!debugEnabled && onlyWholeTeam && (
<div className="system-sm-regular text-text-secondary">
<Trans
i18nKey={`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamTip2`}
ns="workflow"
components={{ team: <span className="system-sm-semibold text-text-primary"></span> }}
values={{ team: currentWorkspace.name.replace(/'/g, '') }}
/>
</div>
)}
{!debugEnabled && onlySpecificUsers && (
<div className="system-sm-regular text-text-secondary">{t(`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamTip3`, { ns: 'workflow' })}</div>
)}
{!debugEnabled && combinedRecipients && (
<div className="system-sm-regular text-text-secondary">
<Trans
i18nKey={`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamTip1`}
ns="workflow"
components={{ team: <span className="system-sm-semibold text-text-primary"></span> }}
values={{ team: currentWorkspace.name.replace(/'/g, '') }}
/>
</div>
)}
</div>
{(onlySpecificUsers || combinedRecipients) && !debugEnabled && (
<>
<div className="px-5">
<EmailInput
disabled
email={userProfile.email}
value={config?.recipients?.items}
list={accounts}
onDelete={noop}
onSelect={noop}
onAdd={noop}
/>
</div>
<div className="px-6 pt-1 system-xs-regular text-text-tertiary">
<Trans
i18nKey={`${i18nPrefix}.deliveryMethod.emailSender.tip`}
ns="workflow"
components={{
strong: <span onClick={jumpToEmailConfigModal} className="cursor-pointer system-xs-regular text-text-accent"></span>,
}}
/>
</div>
</>
)}
{/* vars */}
{generatedInputs.length > 0 && (
<>
<div className="px-6">
<Divider className="mt-4! mb-2! h-px! w-12! bg-divider-regular" />
</div>
<div className="px-6 py-2">
<div className="group flex h-6 cursor-pointer items-center" onClick={() => setCollapsed(!collapsed)}>
<div className="mr-1 system-sm-semibold-uppercase text-text-secondary">{t(`${i18nPrefix}.deliveryMethod.emailSender.vars`, { ns: 'workflow' })}</div>
<RiArrowRightSFill className={cn('h-4 w-4 text-text-quaternary group-hover:text-text-primary', !collapsed && 'rotate-90')} />
{/* vars */}
{generatedInputs.length > 0 && (
<>
<div>
<Divider className="mt-4! mb-2! h-px! w-12! bg-divider-regular" />
</div>
<div className="system-xs-regular text-text-tertiary">{t(`${i18nPrefix}.deliveryMethod.emailSender.varsTip`, { ns: 'workflow' })}</div>
{!collapsed && (
<div className="mt-3 space-y-4">
{generatedInputs.map((variable, index) => (
<div
key={variable.variable}
className="mb-4 last-of-type:mb-0"
>
<FormItem
autoFocus={index === 0}
payload={variable}
value={inputs[variable.variable]}
onChange={v => handleValueChange(variable.variable, v)}
/>
</div>
))}
<div className="py-2">
<div className="group flex h-6 cursor-pointer items-center" onClick={() => setCollapsed(!collapsed)}>
<div className="mr-1 system-sm-semibold-uppercase text-text-secondary">{t(`${i18nPrefix}.deliveryMethod.emailSender.vars`, { ns: 'workflow' })}</div>
<RiArrowRightSFill className={cn('h-4 w-4 text-text-quaternary group-hover:text-text-primary', !collapsed && 'rotate-90')} />
</div>
)}
</div>
</>
)}
<div className="flex flex-row-reverse gap-2 p-6 pt-5">
<Button
disabled={sendingEmail || !confirmChecked}
loading={sendingEmail}
variant="primary"
onClick={handleConfirm}
>
{t(`${i18nPrefix}.deliveryMethod.emailSender.send`, { ns: 'workflow' })}
</Button>
<Button
className="w-[72px]"
onClick={onClose}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
</div>
</Modal>
<div className="system-xs-regular text-text-tertiary">{t(`${i18nPrefix}.deliveryMethod.emailSender.varsTip`, { ns: 'workflow' })}</div>
{!collapsed && (
<div className="mt-3 space-y-4">
{generatedInputs.map((variable, index) => (
<div
key={variable.variable}
className="mb-4 last-of-type:mb-0"
>
<FormItem
autoFocus={index === 0}
payload={variable}
value={inputs[variable.variable]}
onChange={v => handleValueChange(variable.variable, v)}
/>
</div>
))}
</div>
)}
</div>
</>
)}
<div className="mt-6 flex flex-row-reverse gap-2">
<Button
disabled={sendingEmail || !confirmChecked}
loading={sendingEmail}
variant="primary"
onClick={handleConfirm}
>
{t(`${i18nPrefix}.deliveryMethod.emailSender.send`, { ns: 'workflow' })}
</Button>
<Button
className="w-[72px]"
onClick={() => onOpenChange(false)}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -1,76 +1,60 @@
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
RiMailSendFill,
} from '@remixicon/react'
import { noop } from 'es-toolkit/compat'
import { RiMailSendFill } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { SparklesSoft } from '@/app/components/base/icons/src/public/common'
import Modal from '@/app/components/base/modal'
import PremiumBadge from '@/app/components/base/premium-badge'
import { UpgradeModal as BaseUpgradeModal } from '@/app/components/base/upgrade-modal'
import { useModalContextSelector } from '@/context/modal-context'
type UpgradeModalProps = {
isShow: boolean
onClose: () => void
open: boolean
onOpenChange: (open: boolean) => void
}
const UpgradeModal: React.FC<UpgradeModalProps> = ({
isShow,
onClose,
}) => {
export function UpgradeModal({
open,
onOpenChange,
}: UpgradeModalProps) {
const { t } = useTranslation()
const setShowPricingModal = useModalContextSelector(s => s.setShowPricingModal)
const handleUpgrade = () => {
setShowPricingModal()
}
return (
<Modal
isShow={isShow}
onClose={noop}
className="relative w-[580px]! max-w-[580px]! p-8!"
>
<div className="pb-6">
<div
className={cn(
'mb-6 inline-flex rounded-xl border border-divider-regular bg-util-colors-blue-brand-blue-brand-500 p-2',
)}
>
<RiMailSendFill className="h-6 w-6 text-text-primary-on-surface" />
</div>
<p
className="bg-[linear-gradient(271deg,var(--components-input-border-active-prompt-1,#155AEF)_-12.85%,var(--components-input-border-active-prompt-2,#0BA5EC)_95.4%)] bg-clip-text title-3xl-semi-bold text-transparent"
>
{t('nodes.humanInput.deliveryMethod.upgradeTip', { ns: 'workflow' })}
</p>
<p className="mt-2 system-md-regular text-text-tertiary">
{t('nodes.humanInput.deliveryMethod.upgradeTipContent', { ns: 'workflow' })}
</p>
</div>
<div className="flex justify-end pt-5">
<Button
className="w-[72px]"
onClick={onClose}
>
{t('nodes.humanInput.deliveryMethod.upgradeTipHide', { ns: 'workflow' })}
</Button>
<PremiumBadge
size="custom"
color="blue"
allowHover={true}
className="ml-3 h-8 w-[93px]"
onClick={() => {
setShowPricingModal()
}}
>
<SparklesSoft className="flex h-3.5 w-3.5 items-center py-px pl-[3px] text-components-premium-badge-indigo-text-stop-0" />
<div className="system-sm-medium">
<span className="p-1">
{t('upgradeBtn.encourageShort', { ns: 'billing' })}
</span>
</div>
</PremiumBadge>
</div>
</Modal>
<BaseUpgradeModal
open={open}
onOpenChange={onOpenChange}
Icon={RiMailSendFill}
title={t('nodes.humanInput.deliveryMethod.upgradeTip', { ns: 'workflow' })}
description={t('nodes.humanInput.deliveryMethod.upgradeTipContent', { ns: 'workflow' })}
classNames={{
content: 'max-w-[580px]',
}}
footer={(
<>
<Button
className="w-[72px]"
onClick={() => onOpenChange(false)}
>
{t('nodes.humanInput.deliveryMethod.upgradeTipHide', { ns: 'workflow' })}
</Button>
<PremiumBadge
size="custom"
color="blue"
allowHover={true}
className="h-8 w-[93px]"
onClick={handleUpgrade}
>
<SparklesSoft className="flex h-3.5 w-3.5 items-center py-px pl-[3px] text-components-premium-badge-indigo-text-stop-0" />
<div className="system-sm-medium">
<span className="p-1">
{t('upgradeBtn.encourageShort', { ns: 'billing' })}
</span>
</div>
</PremiumBadge>
</>
)}
/>
)
}
export default UpgradeModal

View File

@ -18,7 +18,7 @@ import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import Divider from '@/app/components/base/divider'
import Tooltip from '@/app/components/base/tooltip'
import { Infotip } from '@/app/components/base/infotip'
import OutputVars, { VarItem } from '@/app/components/workflow/nodes/_base/components/output-vars'
import Split from '@/app/components/workflow/nodes/_base/components/split'
import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list'
@ -108,9 +108,9 @@ const Panel: FC<NodePanelProps<HumanInputNodeType>> = ({
<div className="mb-1 flex shrink-0 items-center justify-between">
<div className="flex h-6 items-center gap-0.5">
<div className="system-sm-semibold-uppercase text-text-secondary">{t(`${i18nPrefix}.formContent.title`, { ns: 'workflow' })}</div>
<Tooltip
popupContent={t(`${i18nPrefix}.formContent.tooltip`, { ns: 'workflow' })}
/>
<Infotip aria-label={t(`${i18nPrefix}.formContent.tooltip`, { ns: 'workflow' })}>
{t(`${i18nPrefix}.formContent.tooltip`, { ns: 'workflow' })}
</Infotip>
</div>
{!readOnly && (
<div className="flex items-center">
@ -164,9 +164,9 @@ const Panel: FC<NodePanelProps<HumanInputNodeType>> = ({
<div className="mb-1 flex items-center justify-between">
<div className="flex items-center gap-0.5">
<div className="system-sm-semibold-uppercase text-text-secondary">{t(`${i18nPrefix}.userActions.title`, { ns: 'workflow' })}</div>
<Tooltip
popupContent={t(`${i18nPrefix}.userActions.tooltip`, { ns: 'workflow' })}
/>
<Infotip aria-label={t(`${i18nPrefix}.userActions.tooltip`, { ns: 'workflow' })}>
{t(`${i18nPrefix}.userActions.tooltip`, { ns: 'workflow' })}
</Infotip>
</div>
{!readOnly && (
<div className="flex items-center px-1">

View File

@ -1,13 +1,20 @@
import { cn } from '@langgenius/dify-ui/cn'
import { useClickAway } from 'ahooks'
import {
ContextMenu,
ContextMenuContent,
ContextMenuGroup,
ContextMenuItem,
ContextMenuSeparator,
} from '@langgenius/dify-ui/context-menu'
import {
memo,
useRef,
useCallback,
useMemo,
} from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '../base/divider'
import {
useDSL,
useIsChatMode,
useNodesInteractions,
usePanelInteractions,
useWorkflowMoveMode,
@ -20,7 +27,6 @@ import { useStore } from './store'
const PanelContextmenu = () => {
const { t } = useTranslation()
const ref = useRef(null)
const panelMenu = useStore(s => s.panelMenu)
const clipboardElements = useStore(s => s.clipboardElements)
const setShowImportDSLModal = useStore(s => s.setShowImportDSLModal)
@ -29,127 +35,147 @@ const PanelContextmenu = () => {
const setCommentQuickAdd = useStore(s => s.setCommentQuickAdd)
const { handleNodesPaste } = useNodesInteractions()
const { handlePaneContextmenuCancel } = usePanelInteractions()
const { handleStartWorkflowRun } = useWorkflowStartRun()
const {
handleStartWorkflowRun,
handleWorkflowStartRunInChatflow,
} = useWorkflowStartRun()
const { handleAddNote } = useOperator()
const { isCommentModeAvailable } = useWorkflowMoveMode()
const { exportCheck } = useDSL()
const isChatMode = useIsChatMode()
const panelMenuClientX = panelMenu?.clientX
const panelMenuClientY = panelMenu?.clientY
useClickAway(() => {
handlePaneContextmenuCancel()
}, ref)
const anchor = useMemo(() => {
if (panelMenuClientX === undefined || panelMenuClientY === undefined)
return null
const renderTrigger = () => {
return {
getBoundingClientRect: () => DOMRect.fromRect({
width: 0,
height: 0,
x: panelMenuClientX,
y: panelMenuClientY,
}),
}
}, [panelMenuClientX, panelMenuClientY])
const renderAddBlockTrigger = useCallback(() => {
return (
<button
type="button"
className="flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
className={cn(
'mx-1 flex h-8 w-[calc(100%-8px)] items-center rounded-lg outline-hidden hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover',
'justify-between gap-4 px-3 text-text-secondary',
)}
>
{t('common.addBlock', { ns: 'workflow' })}
</button>
)
}
}, [t])
if (!panelMenu)
const handleRunAction = useCallback(() => {
if (isChatMode)
handleWorkflowStartRunInChatflow()
else
handleStartWorkflowRun()
handlePaneContextmenuCancel()
}, [isChatMode, handleWorkflowStartRunInChatflow, handleStartWorkflowRun, handlePaneContextmenuCancel])
if (!panelMenu || !anchor)
return null
return (
<div
className="absolute z-9 w-[200px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg"
style={{
left: panelMenu.left,
top: panelMenu.top,
}}
ref={ref}
<ContextMenu
open
onOpenChange={open => !open && handlePaneContextmenuCancel()}
>
<div className="p-1">
<AddBlock
renderTrigger={renderTrigger}
offset={{
mainAxis: -36,
crossAxis: -4,
}}
/>
<button
type="button"
className="flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={(e) => {
e.stopPropagation()
handleAddNote()
handlePaneContextmenuCancel()
}}
>
{t('nodes.note.addNote', { ns: 'workflow' })}
</button>
{isCommentModeAvailable && (
<button
type="button"
disabled={!!pendingComment}
className={cn(
'flex h-8 w-full items-center justify-between rounded-lg px-3 text-sm text-text-secondary',
pendingComment ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:bg-state-base-hover',
)}
<ContextMenuContent
positionerProps={{ anchor }}
popupClassName="w-[200px] rounded-lg"
>
<ContextMenuGroup>
<AddBlock
renderTrigger={renderAddBlockTrigger}
offset={{
mainAxis: -36,
crossAxis: -4,
}}
/>
<ContextMenuItem
className="justify-between gap-4 px-3 text-text-secondary"
onClick={(e) => {
e.stopPropagation()
if (pendingComment)
return
setCommentQuickAdd(true)
setCommentPlacing(true)
handleAddNote()
handlePaneContextmenuCancel()
}}
>
{t('comments.actions.addComment', { ns: 'workflow' })}
</button>
)}
<button
type="button"
className="flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => {
handleStartWorkflowRun()
handlePaneContextmenuCancel()
}}
>
{t('common.run', { ns: 'workflow' })}
<ShortcutKbd shortcut="workflow.open-test-run-menu" />
</button>
</div>
<Divider className="m-0" />
<div className="p-1">
<button
type="button"
disabled={!clipboardElements.length}
className={cn(
'flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary',
!clipboardElements.length ? 'cursor-not-allowed opacity-50' : 'hover:bg-state-base-hover',
{t('nodes.note.addNote', { ns: 'workflow' })}
</ContextMenuItem>
{isCommentModeAvailable && (
<ContextMenuItem
disabled={!!pendingComment}
className={cn(
'justify-between gap-4 px-3 text-text-secondary',
pendingComment && 'cursor-not-allowed opacity-50',
)}
onClick={(e) => {
e.stopPropagation()
if (pendingComment)
return
setCommentQuickAdd(true)
setCommentPlacing(true)
handlePaneContextmenuCancel()
}}
>
{t('comments.actions.addComment', { ns: 'workflow' })}
</ContextMenuItem>
)}
onClick={() => {
if (clipboardElements.length) {
handleNodesPaste()
handlePaneContextmenuCancel()
}
}}
>
{t('common.pasteHere', { ns: 'workflow' })}
<ShortcutKbd shortcut="workflow.paste" />
</button>
</div>
<Divider className="m-0" />
<div className="p-1">
<button
type="button"
className="flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => exportCheck?.()}
>
{t('export', { ns: 'app' })}
</button>
<button
type="button"
className="flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => setShowImportDSLModal(true)}
>
{t('importApp', { ns: 'app' })}
</button>
</div>
</div>
<ContextMenuItem
className="justify-between gap-4 px-3 text-text-secondary"
onClick={handleRunAction}
>
{isChatMode ? t('common.debugAndPreview', { ns: 'workflow' }) : t('common.run', { ns: 'workflow' })}
{!isChatMode && <ShortcutKbd shortcut="workflow.open-test-run-menu" />}
</ContextMenuItem>
</ContextMenuGroup>
<ContextMenuSeparator />
<ContextMenuGroup>
<ContextMenuItem
disabled={!clipboardElements.length}
className={cn(
'justify-between gap-4 px-3 text-text-secondary',
!clipboardElements.length && 'cursor-not-allowed opacity-50',
)}
onClick={() => {
if (clipboardElements.length) {
handleNodesPaste()
handlePaneContextmenuCancel()
}
}}
>
{t('common.pasteHere', { ns: 'workflow' })}
<ShortcutKbd shortcut="workflow.paste" />
</ContextMenuItem>
</ContextMenuGroup>
<ContextMenuSeparator />
<ContextMenuGroup>
<ContextMenuItem
className="justify-between gap-4 px-3 text-text-secondary"
onClick={() => exportCheck?.()}
>
{t('export', { ns: 'app' })}
</ContextMenuItem>
<ContextMenuItem
className="justify-between gap-4 px-3 text-text-secondary"
onClick={() => setShowImportDSLModal(true)}
>
{t('importApp', { ns: 'app' })}
</ContextMenuItem>
</ContextMenuGroup>
</ContextMenuContent>
</ContextMenu>
)
}

View File

@ -49,7 +49,7 @@ type MockRestoreConfirmModalProps = {
type MockVersionHistoryItemProps = {
item: VersionHistory
onClick: (item: VersionHistory) => void
handleClickMenuItem: (operation: VersionHistoryContextMenuOptions) => void
handleClickActionMenuItem: (operation: VersionHistoryContextMenuOptions) => void
}
vi.mock('@/context/app-context', () => ({
@ -148,7 +148,7 @@ vi.mock('@/app/components/app/app-publisher/version-info-modal', () => ({
vi.mock('../version-history-item', () => ({
default: (props: MockVersionHistoryItemProps) => {
const MockVersionHistoryItem = () => {
const { item, onClick, handleClickMenuItem } = props
const { item, onClick, handleClickActionMenuItem } = props
useEffect(() => {
if (item.version === WorkflowVersion.Draft)
@ -159,7 +159,7 @@ vi.mock('../version-history-item', () => ({
<div>
<button onClick={() => onClick(item)}>{item.marked_name || item.version}</button>
{item.version !== WorkflowVersion.Draft && (
<button onClick={() => handleClickMenuItem(VersionHistoryContextMenuOptions.restore)}>
<button onClick={() => handleClickActionMenuItem(VersionHistoryContextMenuOptions.restore)}>
{`restore-${item.id}`}
</button>
)}

View File

@ -60,7 +60,7 @@ describe('VersionHistoryItem', () => {
currentVersion={null}
latestVersionId="latest-version"
onClick={onClick}
handleClickMenuItem={vi.fn()}
handleClickActionMenuItem={vi.fn()}
isLast={false}
/>,
)
@ -81,7 +81,7 @@ describe('VersionHistoryItem', () => {
describe('Published Items', () => {
it('should open the context menu for a latest named version and forward restore', async () => {
const user = userEvent.setup()
const handleClickMenuItem = vi.fn()
const handleClickActionMenuItem = vi.fn()
const onClick = vi.fn()
render(
@ -90,7 +90,7 @@ describe('VersionHistoryItem', () => {
currentVersion={null}
latestVersionId="version-1"
onClick={onClick}
handleClickMenuItem={handleClickMenuItem}
handleClickActionMenuItem={handleClickActionMenuItem}
isLast={false}
/>,
)
@ -120,8 +120,8 @@ describe('VersionHistoryItem', () => {
fireEvent.click(restoreItem)
expect(handleClickMenuItem).toHaveBeenCalledTimes(1)
expect(handleClickMenuItem).toHaveBeenCalledWith(
expect(handleClickActionMenuItem).toHaveBeenCalledTimes(1)
expect(handleClickActionMenuItem).toHaveBeenCalledWith(
VersionHistoryContextMenuOptions.restore,
VersionHistoryContextMenuOptions.restore,
)
@ -138,7 +138,7 @@ describe('VersionHistoryItem', () => {
currentVersion={item}
latestVersionId="other-version"
onClick={onClick}
handleClickMenuItem={vi.fn()}
handleClickActionMenuItem={vi.fn()}
isLast
/>,
)

View File

@ -2,9 +2,9 @@ import { DropdownMenu, DropdownMenuContent } from '@langgenius/dify-ui/dropdown-
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { VersionHistoryContextMenuOptions } from '../../../../types'
import MenuItem from '../menu-item'
import ActionMenuItem from '../action-menu-item'
describe('MenuItem', () => {
describe('ActionMenuItem', () => {
it('forwards the selected operation and supports destructive styling', async () => {
const user = userEvent.setup()
const onClick = vi.fn()
@ -12,7 +12,7 @@ describe('MenuItem', () => {
render(
<DropdownMenu open onOpenChange={vi.fn()}>
<DropdownMenuContent>
<MenuItem
<ActionMenuItem
item={{
key: VersionHistoryContextMenuOptions.delete,
name: 'Delete',

View File

@ -2,21 +2,21 @@ import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWorkflowComponent } from '../../../../__tests__/workflow-test-env'
import { VersionHistoryContextMenuOptions } from '../../../../types'
import ContextMenu from '../index'
import ActionMenu from '../index'
describe('ContextMenu', () => {
describe('ActionMenu', () => {
it('toggles the trigger and forwards menu clicks', async () => {
const user = userEvent.setup()
const setOpen = vi.fn()
const handleClickMenuItem = vi.fn()
const handleClickActionMenuItem = vi.fn()
renderWorkflowComponent(
<ContextMenu
<ActionMenu
isNamedVersion
isShowDelete
open
setOpen={setOpen}
handleClickMenuItem={handleClickMenuItem}
handleClickActionMenuItem={handleClickActionMenuItem}
/>,
)
@ -25,11 +25,11 @@ describe('ContextMenu', () => {
await user.click(screen.getByText('common.operation.delete'))
expect(setOpen).toHaveBeenCalled()
expect(handleClickMenuItem).toHaveBeenCalledWith(
expect(handleClickActionMenuItem).toHaveBeenCalledWith(
VersionHistoryContextMenuOptions.restore,
VersionHistoryContextMenuOptions.restore,
)
expect(handleClickMenuItem).toHaveBeenCalledWith(
expect(handleClickActionMenuItem).toHaveBeenCalledWith(
VersionHistoryContextMenuOptions.delete,
VersionHistoryContextMenuOptions.delete,
)

View File

@ -1,15 +1,15 @@
import { renderWorkflowHook } from '../../../../__tests__/workflow-test-env'
import { VersionHistoryContextMenuOptions } from '../../../../types'
import useContextMenu from '../use-context-menu'
import useActionMenu from '../use-action-menu'
describe('useContextMenu', () => {
describe('useActionMenu', () => {
it('returns restore, edit, export, copy and delete operations for app workflows', () => {
const { result } = renderWorkflowHook(() => useContextMenu({
const { result } = renderWorkflowHook(() => useActionMenu({
isNamedVersion: true,
isShowDelete: false,
open: false,
setOpen: vi.fn(),
handleClickMenuItem: vi.fn(),
handleClickActionMenuItem: vi.fn(),
}))
expect(result.current.deleteOperation).toEqual({
@ -25,12 +25,12 @@ describe('useContextMenu', () => {
})
it('omits export for pipelines and renames the edit action for unnamed versions', () => {
const { result } = renderWorkflowHook(() => useContextMenu({
const { result } = renderWorkflowHook(() => useActionMenu({
isNamedVersion: false,
isShowDelete: true,
open: false,
setOpen: vi.fn(),
handleClickMenuItem: vi.fn(),
handleClickActionMenuItem: vi.fn(),
}), {
initialStoreState: {
pipelineId: 'pipeline-1',

View File

@ -4,7 +4,7 @@ import { cn } from '@langgenius/dify-ui/cn'
import { DropdownMenuItem } from '@langgenius/dify-ui/dropdown-menu'
import * as React from 'react'
type MenuItemProps = {
type ActionMenuItemProps = {
item: {
key: VersionHistoryContextMenuOptions
name: string
@ -13,7 +13,7 @@ type MenuItemProps = {
isDestructive?: boolean
}
const MenuItem: FC<MenuItemProps> = ({
const ActionMenuItem: FC<ActionMenuItemProps> = ({
item,
onClick,
isDestructive = false,
@ -41,4 +41,4 @@ const MenuItem: FC<MenuItemProps> = ({
)
}
export default React.memo(MenuItem)
export default React.memo(ActionMenuItem)

View File

@ -9,23 +9,23 @@ import {
import { RiMoreFill } from '@remixicon/react'
import * as React from 'react'
import { VersionHistoryContextMenuOptions } from '../../../types'
import MenuItem from './menu-item'
import useContextMenu from './use-context-menu'
import ActionMenuItem from './action-menu-item'
import useActionMenu from './use-action-menu'
export type ContextMenuProps = {
export type ActionMenuProps = {
isShowDelete: boolean
isNamedVersion: boolean
open: boolean
setOpen: React.Dispatch<React.SetStateAction<boolean>>
handleClickMenuItem: (operation: VersionHistoryContextMenuOptions) => void
handleClickActionMenuItem: (operation: VersionHistoryContextMenuOptions) => void
}
const ContextMenu: FC<ContextMenuProps> = (props: ContextMenuProps) => {
const { isShowDelete, handleClickMenuItem, open, setOpen } = props
const ActionMenu: FC<ActionMenuProps> = (props: ActionMenuProps) => {
const { isShowDelete, handleClickActionMenuItem, open, setOpen } = props
const {
deleteOperation,
options,
} = useContextMenu(props)
} = useActionMenu(props)
return (
<DropdownMenu
@ -44,10 +44,10 @@ const ContextMenu: FC<ContextMenuProps> = (props: ContextMenuProps) => {
>
{
options.map(option => (
<MenuItem
<ActionMenuItem
key={option.key}
item={option}
onClick={handleClickMenuItem.bind(null, option.key)}
onClick={handleClickActionMenuItem.bind(null, option.key)}
/>
))
}
@ -55,10 +55,10 @@ const ContextMenu: FC<ContextMenuProps> = (props: ContextMenuProps) => {
isShowDelete && (
<>
<DropdownMenuSeparator className="my-0" />
<MenuItem
<ActionMenuItem
item={deleteOperation}
isDestructive
onClick={handleClickMenuItem.bind(null, VersionHistoryContextMenuOptions.delete)}
onClick={handleClickActionMenuItem.bind(null, VersionHistoryContextMenuOptions.delete)}
/>
</>
)
@ -68,4 +68,4 @@ const ContextMenu: FC<ContextMenuProps> = (props: ContextMenuProps) => {
)
}
export default React.memo(ContextMenu)
export default React.memo(ActionMenu)

View File

@ -1,10 +1,10 @@
import type { ContextMenuProps } from './index'
import type { ActionMenuProps } from './index'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore } from '@/app/components/workflow/store'
import { VersionHistoryContextMenuOptions } from '../../../types'
const useContextMenu = (props: ContextMenuProps) => {
const useActionMenu = (props: ActionMenuProps) => {
const {
isNamedVersion,
} = props
@ -43,7 +43,7 @@ const useContextMenu = (props: ContextMenuProps) => {
name: t('versionHistory.copyId', { ns: 'workflow' }),
},
]
}, [isNamedVersion, t])
}, [isNamedVersion, pipelineId, t])
return {
deleteOperation,
@ -51,4 +51,4 @@ const useContextMenu = (props: ContextMenuProps) => {
}
}
export default useContextMenu
export default useActionMenu

View File

@ -107,7 +107,7 @@ export const VersionHistoryPanel = ({
setIsOnlyShowNamedVersions(false)
}, [])
const handleClickMenuItem = useCallback((item: VersionHistory, operation: VersionHistoryContextMenuOptions) => {
const handleClickActionMenuItem = useCallback((item: VersionHistory, operation: VersionHistoryContextMenuOptions) => {
setOperatedItem(item)
switch (operation) {
case VersionHistoryContextMenuOptions.restore:
@ -292,7 +292,7 @@ export const VersionHistoryPanel = ({
currentVersion={currentVersion}
latestVersionId={latestVersionId || ''}
onClick={handleVersionClick}
handleClickMenuItem={handleClickMenuItem.bind(null, item)}
handleClickActionMenuItem={handleClickActionMenuItem.bind(null, item)}
isLast={isLast}
/>
)

View File

@ -6,14 +6,14 @@ import * as React from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { WorkflowVersion } from '../../types'
import ContextMenu from './context-menu'
import ActionMenu from './action-menu'
type VersionHistoryItemProps = {
item: VersionHistory
currentVersion: VersionHistory | null
latestVersionId: string
onClick: (item: VersionHistory) => void
handleClickMenuItem: (operation: VersionHistoryContextMenuOptions) => void
handleClickActionMenuItem: (operation: VersionHistoryContextMenuOptions) => void
isLast: boolean
}
@ -41,7 +41,7 @@ const VersionHistoryItem: React.FC<VersionHistoryItemProps> = ({
currentVersion,
latestVersionId,
onClick,
handleClickMenuItem,
handleClickActionMenuItem,
isLast,
}) => {
const { t } = useTranslation()
@ -122,15 +122,15 @@ const VersionHistoryItem: React.FC<VersionHistoryItemProps> = ({
)
}
</div>
{/* Context Menu */}
{/* Action Menu */}
{!isDraft && isHovering && (
<div className="absolute top-1 right-1">
<ContextMenu
<ActionMenu
isShowDelete={!isLatest}
isNamedVersion={!!item.marked_name}
open={open}
setOpen={setOpen}
handleClickMenuItem={handleClickMenuItem}
handleClickActionMenuItem={handleClickActionMenuItem}
/>
</div>
)}

View File

@ -1,4 +1,5 @@
import type { CreateSnippetDialogPayload } from './create-snippet-dialog'
import type { WorkflowShortcutId } from './shortcuts/definitions'
import type { Edge, Node } from './types'
import type { SnippetCanvasData } from '@/models/snippet'
import { cn } from '@langgenius/dify-ui/cn'
@ -28,7 +29,6 @@ import CreateSnippetDialog from './create-snippet-dialog'
import { useNodesInteractions, useNodesReadOnly, useNodesSyncDraft } from './hooks'
import { useSelectionInteractions } from './hooks/use-selection-interactions'
import { useWorkflowHistory, WorkflowHistoryEvent } from './hooks/use-workflow-history'
import type { WorkflowShortcutId } from './shortcuts/definitions'
import { ShortcutKbd } from './shortcuts/shortcut-kbd'
import { useStore, useWorkflowStore } from './store'
import { BlockEnum, TRIGGER_NODE_TYPES } from './types'
@ -65,6 +65,7 @@ type ActionMenuItem = {
disabled?: boolean
shortcut?: WorkflowShortcutId
translationKey: string
workflowShortcutId?: WorkflowShortcutId
}
const DEFAULT_SNIPPET_VIEWPORT: SnippetCanvasData['viewport'] = { x: 0, y: 0, zoom: 1 }

View File

@ -108,7 +108,7 @@ describe('createWorkflowStore', () => {
['showWorkflowVersionHistoryPanel', 'setShowWorkflowVersionHistoryPanel', true],
['showInputsPanel', 'setShowInputsPanel', true],
['showDebugAndPreviewPanel', 'setShowDebugAndPreviewPanel', true],
['panelMenu', 'setPanelMenu', { top: 10, left: 20 }],
['panelMenu', 'setPanelMenu', { clientX: 20, clientY: 10 }],
['selectionMenu', 'setSelectionMenu', { clientX: 50, clientY: 60 }],
['edgeMenu', 'setEdgeMenu', { clientX: 320, clientY: 180, edgeId: 'e1' }],
['showVariableInspectPanel', 'setShowVariableInspectPanel', true],

View File

@ -19,12 +19,12 @@ describe('createPanelSlice', () => {
store.getState().setShowFeaturesPanel(true)
store.getState().setShowDebugAndPreviewPanel(true)
store.getState().setPanelMenu({ top: 24, left: 48 })
store.getState().setPanelMenu({ clientX: 48, clientY: 24 })
store.getState().setEdgeMenu({ clientX: 80, clientY: 120, edgeId: 'edge-1' })
expect(store.getState().showFeaturesPanel).toBe(true)
expect(store.getState().showDebugAndPreviewPanel).toBe(true)
expect(store.getState().panelMenu).toEqual({ top: 24, left: 48 })
expect(store.getState().panelMenu).toEqual({ clientX: 48, clientY: 24 })
expect(store.getState().edgeMenu).toEqual({ clientX: 80, clientY: 120, edgeId: 'edge-1' })
})
})

View File

@ -17,8 +17,8 @@ export type PanelSliceShape = {
showUserCursors: boolean
setShowUserCursors: (showUserCursors: boolean) => void
panelMenu?: {
top: number
left: number
clientX: number
clientY: number
}
setPanelMenu: (panelMenu: PanelSliceShape['panelMenu']) => void
selectionMenu?: {

View File

@ -169,13 +169,13 @@ export const ModalContextProvider = ({
showModelModal.onCancelCallback()
}, [showModelModal])
const handleSaveModelModal = useCallback((formValues?: Record<string, any>) => {
const handleSaveModelModal = useCallback((formValues?: Record<string, unknown>) => {
if (showModelModal?.onSaveCallback)
showModelModal.onSaveCallback(showModelModal.payload, formValues)
setShowModelModal(null)
}, [showModelModal])
const handleRemoveModelModal = useCallback((formValues?: Record<string, any>) => {
const handleRemoveModelModal = useCallback((formValues?: Record<string, unknown>) => {
if (showModelModal?.onRemoveCallback)
showModelModal.onRemoveCallback(showModelModal.payload, formValues)
setShowModelModal(null)
@ -369,7 +369,7 @@ export const ModalContextProvider = ({
}}
onSave={() => {
setShowUpdatePluginModal(null)
showUpdatePluginModal.onSaveCallback?.({} as any)
showUpdatePluginModal.onSaveCallback?.()
}}
/>
)

View File

@ -1,4 +1,5 @@
import { act, screen, waitFor } from '@testing-library/react'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { defaultPlan } from '@/app/components/billing/config'
import { Plan } from '@/app/components/billing/type'
@ -27,21 +28,6 @@ vi.mock('@/context/app-context', () => ({
useAppContext: () => mockUseAppContext(),
}))
let latestTriggerEventsModalProps: any = null
const triggerEventsLimitModalMock = vi.fn((props: any) => {
latestTriggerEventsModalProps = props
return (
<div data-testid="trigger-limit-modal">
<button type="button" onClick={props.onClose}>dismiss</button>
<button type="button" onClick={props.onUpgrade}>upgrade</button>
</div>
)
})
vi.mock('@/app/components/billing/trigger-events-limit-modal', () => ({
default: (props: any) => triggerEventsLimitModalMock(props),
}))
type DefaultPlanShape = typeof defaultPlan
type ResetShape = {
apiRateLimit: number | null
@ -79,8 +65,6 @@ const renderProvider = () => renderWithNuqs(
describe('ModalContextProvider trigger events limit modal', () => {
beforeEach(() => {
latestTriggerEventsModalProps = null
triggerEventsLimitModalMock.mockClear()
mockUseAppContext.mockReset()
mockUseProviderContext.mockReset()
window.localStorage.clear()
@ -109,25 +93,20 @@ describe('ModalContextProvider trigger events limit modal', () => {
// Note: vitest.setup.ts replaces localStorage with a mock object that has vi.fn() methods
// We need to spy on the mock's setItem, not Storage.prototype.setItem
const setItemSpy = vi.spyOn(localStorage, 'setItem')
const user = userEvent.setup()
renderProvider()
await waitFor(() => expect(screen.getByTestId('trigger-limit-modal'))!.toBeInTheDocument())
expect(latestTriggerEventsModalProps).toMatchObject({
usage: 3000,
total: 3000,
resetInDays: 5,
})
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
expect(screen.getAllByText('3000')).toHaveLength(2)
act(() => {
latestTriggerEventsModalProps.onClose()
})
await user.click(screen.getByRole('button', { name: 'billing.triggerLimitModal.dismiss' }))
await waitFor(() => expect(screen.queryByTestId('trigger-limit-modal')).not.toBeInTheDocument())
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
await waitFor(() => {
expect(setItemSpy.mock.calls.length).toBeGreaterThan(0)
})
const [key, value] = (setItemSpy.mock.calls[0] ?? []) as [any, any]
const [key, value] = (setItemSpy.mock.calls[0] ?? []) as [string, string]
expect(key).toContain('trigger-events-limit-dismissed-workspace-1-professional-3000-')
expect(value).toBe('1')
})
@ -147,18 +126,16 @@ describe('ModalContextProvider trigger events limit modal', () => {
throw new Error('Storage disabled')
})
const setItemSpy = vi.spyOn(localStorage, 'setItem')
const user = userEvent.setup()
renderProvider()
await waitFor(() => expect(screen.getByTestId('trigger-limit-modal'))!.toBeInTheDocument())
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
act(() => {
latestTriggerEventsModalProps.onClose()
})
await user.click(screen.getByRole('button', { name: 'billing.triggerLimitModal.dismiss' }))
await waitFor(() => expect(screen.queryByTestId('trigger-limit-modal')).not.toBeInTheDocument())
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(setItemSpy).not.toHaveBeenCalled()
await waitFor(() => expect(triggerEventsLimitModalMock).toHaveBeenCalledTimes(1))
})
it('falls back to the in-memory guard when localStorage.setItem fails', async () => {
@ -175,16 +152,37 @@ describe('ModalContextProvider trigger events limit modal', () => {
vi.spyOn(localStorage, 'setItem').mockImplementation(() => {
throw new Error('Quota exceeded')
})
const user = userEvent.setup()
renderProvider()
await waitFor(() => expect(screen.getByTestId('trigger-limit-modal'))!.toBeInTheDocument())
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
act(() => {
latestTriggerEventsModalProps.onClose()
await user.click(screen.getByRole('button', { name: 'billing.triggerLimitModal.dismiss' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
})
it('closes the trigger events limit modal and opens pricing when upgrading', async () => {
const plan = createPlan({
type: Plan.professional,
usage: { triggerEvents: 400 },
total: { triggerEvents: 400 },
reset: { triggerEvents: 6 },
})
mockUseProviderContext.mockReturnValue({
plan,
isFetchedPlan: true,
})
const user = userEvent.setup()
await waitFor(() => expect(screen.queryByTestId('trigger-limit-modal')).not.toBeInTheDocument())
await waitFor(() => expect(triggerEventsLimitModalMock).toHaveBeenCalledTimes(1))
renderProvider()
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
await user.click(screen.getByText('billing.triggerLimitModal.upgrade'))
await waitFor(() => expect(screen.getByText('billing.plansCommon.mostPopular')).toBeInTheDocument())
expect(screen.queryByText('400')).not.toBeInTheDocument()
})
})

View File

@ -28,8 +28,8 @@ import { createContext, useContext, useContextSelector } from 'use-context-selec
export type ModalState<T> = {
payload: T
onCancelCallback?: () => void
onSaveCallback?: (newPayload?: T, formValues?: Record<string, any>) => void
onRemoveCallback?: (newPayload?: T, formValues?: Record<string, any>) => void
onSaveCallback?: (newPayload?: T, formValues?: Record<string, unknown>) => void
onRemoveCallback?: (newPayload?: T, formValues?: Record<string, unknown>) => void
onEditCallback?: (newPayload: T) => void
onValidateBeforeSaveCallback?: (newPayload: T) => boolean
isEditMode?: boolean

View File

@ -32,11 +32,11 @@ export const appWorkflowTypeConvertContract = base
export const workflowOnlineUsersContract = base
.route({
path: '/apps/workflows/online-users',
method: 'GET',
method: 'POST',
})
.input(type<{
query: {
app_ids: string
body: {
app_ids: string[]
}
}>())
.output(type<WorkflowOnlineUsersResponse>())

View File

@ -14,6 +14,7 @@ import type {
EvaluationRunDetailResponse,
EvaluationRunRequest,
EvaluationTargetType,
EvaluationTemplateColumnsResponse,
EvaluationVersionDetailResponse,
EvaluationWorkflowAssociatedTargetsResponse,
} from '@/types/evaluation'
@ -154,6 +155,20 @@ export const evaluationTemplateDownloadContract = base
}>())
.output(type<unknown>())
export const evaluationTemplateColumnsContract = base
.route({
path: '/{targetType}/{targetId}/evaluation/template-columns',
method: 'POST',
})
.input(type<{
params: {
targetType: EvaluationTargetType
targetId: string
}
body: EvaluationConfigData
}>())
.output(type<EvaluationTemplateColumnsResponse>())
export const evaluationConfigContract = base
.route({
path: '/{targetType}/{targetId}/evaluation',

View File

@ -20,6 +20,7 @@ import {
evaluationMetricsContract,
evaluationNodeInfoContract,
evaluationRunDetailContract,
evaluationTemplateColumnsContract,
evaluationTemplateDownloadContract,
evaluationVersionDetailContract,
evaluationWorkflowAssociatedTargetsContract,
@ -140,6 +141,7 @@ export const consoleRouterContract = {
},
evaluation: {
templateDownload: evaluationTemplateDownloadContract,
templateColumns: evaluationTemplateColumnsContract,
config: evaluationConfigContract,
saveConfig: saveEvaluationConfigContract,
logs: evaluationLogsContract,

View File

@ -4,9 +4,10 @@
"batch.emptyHistory": "No test history yet.",
"batch.example": "Example:",
"batch.fileRequired": "Upload an evaluation dataset file before running the test.",
"batch.loadingInputFields": "Loading input fields...",
"batch.loadingInputFields": "Loading template columns...",
"batch.noInputFields": "No published start node input fields found.",
"batch.noSnippetInputFields": "No published snippet input fields found.",
"batch.noTemplateColumns": "No template columns found.",
"batch.noticeDescription": "Configuration incomplete. Select the Judge Model and Metrics on the left to generate your batch test template.",
"batch.noticeTitle": "Quick start",
"batch.removeUploadedFile": "Remove uploaded file",
@ -20,6 +21,7 @@
"batch.status.success": "Success",
"batch.tabs.history": "Test History",
"batch.tabs.input-fields": "Input Fields",
"batch.templateColumnsError": "Failed to generate the CSV template.",
"batch.title": "Batch Test",
"batch.uploadAndRun": "Upload & Run Test",
"batch.uploadDropzoneEmphasis": "filled",

View File

@ -4,9 +4,10 @@
"batch.emptyHistory": "还没有测试历史。",
"batch.example": "示例:",
"batch.fileRequired": "请先上传评估数据集文件,再运行测试。",
"batch.loadingInputFields": "正在加载输入字段...",
"batch.loadingInputFields": "正在加载模板列...",
"batch.noInputFields": "未找到已发布 Start 节点的输入字段。",
"batch.noSnippetInputFields": "未找到已发布的片段输入字段。",
"batch.noTemplateColumns": "未找到模板列。",
"batch.noticeDescription": "配置尚未完成。请先在左侧选择判定模型和指标,以生成批量测试模板。",
"batch.noticeTitle": "快速开始",
"batch.removeUploadedFile": "移除已上传文件",
@ -20,6 +21,7 @@
"batch.status.success": "成功",
"batch.tabs.history": "测试历史",
"batch.tabs.input-fields": "输入字段",
"batch.templateColumnsError": "生成 CSV 模板失败。",
"batch.title": "批量测试",
"batch.uploadAndRun": "上传并运行测试",
"batch.uploadDropzoneEmphasis": "已填写的",

View File

@ -14,7 +14,7 @@ export const fetchWorkflowOnlineUsers = async ({ appIds }: { appIds: string[] })
return {}
const response = await consoleClient.apps.workflowOnlineUsers({
query: { app_ids: appIds.join(',') },
body: { app_ids: appIds },
})
if (!response?.data)

View File

@ -1,5 +1,5 @@
import type { EvaluationResourceType, NonPipelineEvaluationResourceType } from '@/app/components/evaluation/types'
import type { AvailableEvaluationWorkflowsResponse, EvaluationConfig } from '@/types/evaluation'
import type { AvailableEvaluationWorkflowsResponse, EvaluationConfig, EvaluationConfigData } from '@/types/evaluation'
import {
keepPreviousData,
skipToken,
@ -131,6 +131,27 @@ export const useStartEvaluationRunMutation = () => {
}))
}
export const useEvaluationTemplateColumns = (
resourceType: EvaluationResourceType,
resourceId: string,
configPayload: EvaluationConfigData | null,
enabled = true,
) => {
return useQuery(consoleQuery.evaluation.templateColumns.queryOptions({
input: resourceId && configPayload
? {
params: {
targetType: resourceType,
targetId: resourceId,
},
body: configPayload,
}
: skipToken,
enabled: !!resourceId && !!configPayload && enabled,
refetchOnWindowFocus: false,
}))
}
export const useAvailableEvaluationWorkflows = (
params: AvailableEvaluationWorkflowsParams = {},
options?: { enabled?: boolean },

View File

@ -59,6 +59,15 @@ export type EvaluationRunRequest = EvaluationConfigData & {
file_id: string
}
export type EvaluationTemplateColumn = {
name: string
type: string
}
export type EvaluationTemplateColumnsResponse = {
columns: EvaluationTemplateColumn[]
}
export type EvaluationRunStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
export type EvaluationJudgmentMetricsSummary = {