mirror of
https://github.com/langgenius/dify.git
synced 2026-05-06 18:27:19 +08:00
merge evaluation fe
This commit is contained in:
commit
e1e17d8a51
@ -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():
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
82
api/uv.lock
generated
@ -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
5672
eslint-suppressions.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -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'
|
||||
|
||||
@ -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()
|
||||
|
||||
75
web/app/components/base/upgrade-modal/index.tsx
Normal file
75
web/app/components/base/upgrade-modal/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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>,
|
||||
}))
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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'
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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 => (
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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>) => {
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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' },
|
||||
})
|
||||
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@ -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 {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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())
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -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',
|
||||
@ -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,
|
||||
)
|
||||
@ -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',
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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' })
|
||||
})
|
||||
})
|
||||
|
||||
@ -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?: {
|
||||
|
||||
@ -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?.()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>())
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "已填写的",
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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 = {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user