Compare commits

...

102 Commits

Author SHA1 Message Date
JzoNg
81baeae5c4 fix(web): evaluation workflow switch 2026-04-03 18:22:44 +08:00
JzoNg
a3010bdc0b Merge branch 'main' into jzh 2026-04-03 18:05:54 +08:00
Coding On Star
83d4176785 test: add unit tests for app store and annotation components, enhancing coverage for state management and UI interactions (#34510)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-03 09:09:59 +00:00
JzoNg
8133e550ed chore: fix pre-hook of web 2026-04-03 16:21:32 +08:00
JzoNg
2bb0eab636 chore(web): mapping row refactor 2026-04-03 16:10:41 +08:00
JzoNg
5311b5d00d feat(web): available evaluation workflow selector 2026-04-03 16:06:33 +08:00
JzoNg
9b02ccdd12 Merge branch 'main' into jzh 2026-04-03 15:15:11 +08:00
JzoNg
231783eebe chore(web): fix lint 2026-04-03 15:13:52 +08:00
yyh
c94951b2f8 refactor(web): migrate notion page selectors to tanstack virtual (#34508)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-03 07:03:12 +00:00
Matt Van Horn
a9cf8f6c5d refactor(web): replace react-syntax-highlighter with shiki (#33473)
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Stephen Zhou <38493346+hyoban@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-03 06:40:26 +00:00
JzoNg
756606f478 feat(web): hide card view in evaluation 2026-04-03 14:39:41 +08:00
YBoy
64ddec0d67 refactor(api): type annotation service dicts with TypedDict (#34482)
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
2026-04-03 06:25:52 +00:00
JzoNg
6651c1c5da feat(web): workflow switch 2026-04-03 14:22:50 +08:00
Renzo
da3b0caf5e refactor: select in account_service (RegisterService class) (#34500)
Some checks failed
autofix.ci / autofix (push) Has been cancelled
Build and Push API & Web / build (api, {{defaultContext}}:api, Dockerfile, DIFY_API_IMAGE_NAME, linux/amd64, ubuntu-latest, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, {{defaultContext}}:api, Dockerfile, DIFY_API_IMAGE_NAME, linux/arm64, ubuntu-24.04-arm, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, {{defaultContext}}, web/Dockerfile, DIFY_WEB_IMAGE_NAME, linux/amd64, ubuntu-latest, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, {{defaultContext}}, web/Dockerfile, DIFY_WEB_IMAGE_NAME, linux/arm64, ubuntu-24.04-arm, build-web-arm64) (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled
Main CI Pipeline / Skip Duplicate Checks (push) Has been cancelled
Main CI Pipeline / Check Changed Files (push) Has been cancelled
Main CI Pipeline / Run API Tests (push) Has been cancelled
Main CI Pipeline / Skip API Tests (push) Has been cancelled
Main CI Pipeline / API Tests (push) Has been cancelled
Main CI Pipeline / Run Web Tests (push) Has been cancelled
Main CI Pipeline / Skip Web Tests (push) Has been cancelled
Main CI Pipeline / Web Tests (push) Has been cancelled
Main CI Pipeline / Run Web Full-Stack E2E (push) Has been cancelled
Main CI Pipeline / Skip Web Full-Stack E2E (push) Has been cancelled
Main CI Pipeline / Web Full-Stack E2E (push) Has been cancelled
Main CI Pipeline / Style Check (push) Has been cancelled
Main CI Pipeline / Run VDB Tests (push) Has been cancelled
Main CI Pipeline / Skip VDB Tests (push) Has been cancelled
Main CI Pipeline / VDB Tests (push) Has been cancelled
Main CI Pipeline / Run DB Migration Test (push) Has been cancelled
Main CI Pipeline / Skip DB Migration Test (push) Has been cancelled
Main CI Pipeline / DB Migration Test (push) Has been cancelled
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-03 06:21:26 +00:00
JzoNg
61e257b2a8 feat(web): app switch api 2026-04-03 13:56:00 +08:00
Stephen Zhou
4fedd43af5 chore: update code-inspector-plugin to 1.5.1 (#34506) 2026-04-03 05:34:03 +00:00
yyh
a263f28e19 fix(web): restore ui select public exports (#34501) 2026-04-03 04:42:02 +00:00
Stephen Zhou
d53862f135 chore: override lodash (#34502) 2026-04-03 04:40:46 +00:00
JzoNg
3ac4caf735 Merge branch 'main' into jzh 2026-04-03 11:28:22 +08:00
JzoNg
268ae1751d Merge branch 'main' into jzh 2026-04-01 09:26:13 +08:00
JzoNg
015cbf850b Merge branch 'main' into jzh 2026-03-31 18:08:24 +08:00
JzoNg
873e13c2fb feat(web): support select node in metric card 2026-03-31 18:07:52 +08:00
JzoNg
688bf7e7a1 feat(web): metric card style 2026-03-31 17:43:56 +08:00
JzoNg
a6ffff3b39 fix(web): fix style of metric selector 2026-03-31 17:22:07 +08:00
JzoNg
023fc55bd5 fix(web): empty state of metric 2026-03-31 17:11:44 +08:00
JzoNg
351b909a53 feat(web): metric card 2026-03-31 17:00:37 +08:00
JzoNg
6bec4f65c9 refactor(web): metric section refactor 2026-03-31 16:28:48 +08:00
JzoNg
74f87ce152 Merge branch 'main' into jzh 2026-03-31 16:13:04 +08:00
JzoNg
92c472ccc7 Merge branch 'main' into jzh 2026-03-30 15:40:23 +08:00
JzoNg
b92b8becd1 feat(web): metric selector 2026-03-30 15:39:52 +08:00
JzoNg
23d0d6a65d chore(web): i18n of metrics 2026-03-30 14:20:43 +08:00
JzoNg
1660067d6e feat(web): judgement model selector 2026-03-30 14:03:37 +08:00
JzoNg
0642475b85 Merge branch 'main' into jzh 2026-03-30 13:30:10 +08:00
JzoNg
8cb634c9bc feat(web): evaluation layout 2026-03-30 11:27:06 +08:00
JzoNg
768b41c3cf Merge branch 'main' into jzh 2026-03-30 11:07:42 +08:00
JzoNg
ca88516d54 refactor(web): refactor evaluation page 2026-03-30 11:06:41 +08:00
JzoNg
871a2a149f refactor(web): split snippet index 2026-03-30 10:32:59 +08:00
JzoNg
60e381eff0 Merge branch 'main' into jzh 2026-03-30 09:48:58 +08:00
JzoNg
768b3eb6f9 feat(web): test run of snippet 2026-03-29 20:55:11 +08:00
JzoNg
2f88da4a6d feat(web): add variable inspect for snippet 2026-03-29 20:23:24 +08:00
JzoNg
a8cdf6964c feat(web): test run button 2026-03-29 20:02:59 +08:00
JzoNg
985c3db4fd feat(web): snippet input field panel layout 2026-03-29 18:02:27 +08:00
JzoNg
9636472db7 refactor(web): snippet main 2026-03-29 17:50:30 +08:00
JzoNg
0ad268aa7d feat(web): snippet publish 2026-03-29 17:29:37 +08:00
JzoNg
a4ea33167d feat(web): block selector in snippet 2026-03-29 17:01:32 +08:00
JzoNg
0f13aabea8 feat(web): input fields in snippet 2026-03-29 16:31:38 +08:00
JzoNg
1e76ef5ccb chore(web): ignore system vars & conversation vars in rag-pipeline and snippet 2026-03-29 15:56:24 +08:00
JzoNg
e6e3229d17 feat(web): input field button style 2026-03-29 15:45:05 +08:00
JzoNg
dccf8e723a feat(web): snippet version panel 2026-03-29 15:26:59 +08:00
JzoNg
c41ba7d627 feat(web): snippet header in graph 2026-03-29 15:02:34 +08:00
JzoNg
a6e9316de3 Merge branch 'main' into jzh 2026-03-29 14:07:49 +08:00
JzoNg
559d326cbd chore(web): mock data of snippet 2026-03-27 17:24:01 +08:00
JzoNg
abedf2506f Merge branch 'main' into jzh 2026-03-27 17:01:27 +08:00
JzoNg
d01428b5bc feat(web): snippet graph draft sync 2026-03-27 16:02:47 +08:00
JzoNg
0de1f17e5c Merge branch 'main' into jzh 2026-03-27 15:23:49 +08:00
JzoNg
17d07a5a43 feat(web): init snippet graph 2026-03-27 15:23:03 +08:00
JzoNg
3bdbea99a3 Merge branch 'main' into jzh 2026-03-27 14:04:10 +08:00
JzoNg
b7683aedb1 Merge branch 'main' into jzh 2026-03-26 21:38:48 +08:00
JzoNg
515036e758 test(web): add tests for snippets 2026-03-26 21:38:22 +08:00
JzoNg
22b382527f feat(web): add snippet to workflow 2026-03-26 21:26:29 +08:00
JzoNg
2cfe4b5b86 feat(web): snippet graph data fetching 2026-03-26 21:11:09 +08:00
JzoNg
6876c8041c feat(web): snippet list data fetching in block selector 2026-03-26 20:58:42 +08:00
JzoNg
7de45584ce refactor: snippets list 2026-03-26 20:41:51 +08:00
JzoNg
5572d7c7e8 Merge branch 'main' into jzh 2026-03-26 20:10:47 +08:00
JzoNg
db0a2fe52e Merge branch 'main' into jzh 2026-03-26 16:29:44 +08:00
JzoNg
f0ae8d6167 fix(web): unused imports caused by merge 2026-03-26 16:28:56 +08:00
JzoNg
2514e181ba Merge branch 'main' into jzh 2026-03-26 16:16:10 +08:00
JzoNg
be2e6e9a14 Merge branch 'main' into jzh 2026-03-26 14:23:29 +08:00
JzoNg
875e2eac1b Merge branch 'main' into jzh 2026-03-26 08:38:57 +08:00
JzoNg
c3c73ceb1f Merge branch 'main' into jzh 2026-03-25 23:02:18 +08:00
JzoNg
6318bf0a2a feat(web): create snippet from workflow 2026-03-25 22:57:48 +08:00
JzoNg
5e1f252046 feat(web): selection context menu style update 2026-03-25 22:36:27 +08:00
JzoNg
df3b960505 fix(web): position of selection context menu in workflow graph 2026-03-25 22:02:50 +08:00
JzoNg
26bc108bf1 chore(web): tests for snippet info 2026-03-25 21:35:36 +08:00
JzoNg
a5cff32743 feat(web): snippet info operations 2026-03-25 21:29:06 +08:00
JzoNg
d418dd8eec Merge branch 'main' into jzh 2026-03-25 20:17:32 +08:00
JzoNg
61702fe346 Merge branch 'main' into jzh 2026-03-25 18:17:03 +08:00
JzoNg
43f0c780c3 Merge branch 'main' into jzh 2026-03-25 15:30:21 +08:00
JzoNg
30ebf2bfa9 Merge branch 'main' into jzh 2026-03-24 07:25:22 +08:00
JzoNg
7e3027b5f7 feat(web): snippet card usage info 2026-03-23 17:02:00 +08:00
JzoNg
b3acf83090 Merge branch 'main' into jzh 2026-03-23 16:46:26 +08:00
JzoNg
36c3d6e48a feat(web): snippet list fetching & display 2026-03-23 16:37:05 +08:00
JzoNg
f782ac6b3c feat(web): create snippets by DSL import 2026-03-23 14:55:36 +08:00
JzoNg
feef2dd1fa feat(web): add snippet creation dialog flow 2026-03-23 11:29:41 +08:00
JzoNg
a716d8789d refactor: extract snippet list components 2026-03-23 10:48:15 +08:00
JzoNg
6816f89189 Merge branch 'main' into jzh 2026-03-23 10:13:45 +08:00
JzoNg
bfcac64a9d Merge branch 'main' into jzh 2026-03-20 15:33:49 +08:00
JzoNg
664eb601a2 feat(web): add api of snippet worfklows 2026-03-20 15:29:53 +08:00
JzoNg
8e5cc4e0aa feat(web): add evaluation api 2026-03-20 15:23:03 +08:00
JzoNg
9f28575903 feat(web): add snippets api 2026-03-20 15:11:33 +08:00
JzoNg
4b9a26a5e6 Merge branch 'main' into jzh 2026-03-20 14:01:34 +08:00
JzoNg
7b85adf1cc Merge branch 'main' into jzh 2026-03-20 10:46:45 +08:00
JzoNg
c964708ebe Merge branch 'main' into jzh 2026-03-18 18:07:20 +08:00
JzoNg
883eb498c0 Merge branch 'main' into jzh 2026-03-18 17:40:51 +08:00
JzoNg
4d3738d225 Merge branch 'main' into feat/evaluation-fe 2026-03-17 10:42:44 +08:00
JzoNg
dd0dee739d Merge branch 'main' into jzh 2026-03-16 15:43:20 +08:00
zxhlyh
4d19914fcb Merge branch 'main' into feat/evaluation-fe 2026-03-16 10:47:37 +08:00
zxhlyh
887c7710e9 feat: evaluation 2026-03-16 10:46:33 +08:00
zxhlyh
7a722773c7 feat: snippet canvas 2026-03-13 17:45:04 +08:00
zxhlyh
a763aff58b feat: snippets list 2026-03-13 16:12:42 +08:00
zxhlyh
c1011f4e5c feat: add to snippet 2026-03-13 14:29:59 +08:00
zxhlyh
f7afa103a5 feat: select snippets 2026-03-13 13:43:29 +08:00
359 changed files with 31726 additions and 6172 deletions

View File

@@ -77,7 +77,7 @@ if $web_modified; then
fi
cd ./web || exit 1
vp staged
pnpm exec vp staged
if $web_ts_modified; then
echo "Running TypeScript type-check:tsgo"

View File

@@ -8,7 +8,7 @@ from hashlib import sha256
from typing import Any, TypedDict, cast
from pydantic import BaseModel, TypeAdapter
from sqlalchemy import func, select
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
@@ -1392,10 +1392,10 @@ class RegisterService:
db.session.add(dify_setup)
db.session.commit()
except Exception as e:
db.session.query(DifySetup).delete()
db.session.query(TenantAccountJoin).delete()
db.session.query(Account).delete()
db.session.query(Tenant).delete()
db.session.execute(delete(DifySetup))
db.session.execute(delete(TenantAccountJoin))
db.session.execute(delete(Account))
db.session.execute(delete(Tenant))
db.session.commit()
logger.exception("Setup account failed, email: %s, name: %s", email, name)
@@ -1496,7 +1496,11 @@ class RegisterService:
TenantService.switch_tenant(account, tenant.id)
else:
TenantService.check_member_permission(tenant, inviter, account, "add")
ta = db.session.query(TenantAccountJoin).filter_by(tenant_id=tenant.id, account_id=account.id).first()
ta = db.session.scalar(
select(TenantAccountJoin)
.where(TenantAccountJoin.tenant_id == tenant.id, TenantAccountJoin.account_id == account.id)
.limit(1)
)
if not ta:
TenantService.create_tenant_member(tenant, account, role)
@@ -1553,21 +1557,18 @@ class RegisterService:
if not invitation_data:
return None
tenant = (
db.session.query(Tenant)
.where(Tenant.id == invitation_data["workspace_id"], Tenant.status == "normal")
.first()
tenant = db.session.scalar(
select(Tenant).where(Tenant.id == invitation_data["workspace_id"], Tenant.status == "normal").limit(1)
)
if not tenant:
return None
tenant_account = (
db.session.query(Account, TenantAccountJoin.role)
tenant_account = db.session.execute(
select(Account, TenantAccountJoin.role)
.join(TenantAccountJoin, Account.id == TenantAccountJoin.account_id)
.where(Account.email == invitation_data["email"], TenantAccountJoin.tenant_id == tenant.id)
.first()
)
).first()
if not tenant_account:
return None

View File

@@ -4,6 +4,8 @@ import uuid
import pandas as pd
logger = logging.getLogger(__name__)
from typing import TypedDict
from sqlalchemy import or_, select
from werkzeug.datastructures import FileStorage
from werkzeug.exceptions import NotFound
@@ -23,6 +25,27 @@ from tasks.annotation.enable_annotation_reply_task import enable_annotation_repl
from tasks.annotation.update_annotation_to_index_task import update_annotation_to_index_task
class AnnotationJobStatusDict(TypedDict):
job_id: str
job_status: str
class EmbeddingModelDict(TypedDict):
embedding_provider_name: str
embedding_model_name: str
class AnnotationSettingDict(TypedDict):
id: str
enabled: bool
score_threshold: float
embedding_model: EmbeddingModelDict | dict
class AnnotationSettingDisabledDict(TypedDict):
enabled: bool
class AppAnnotationService:
@classmethod
def up_insert_app_annotation_from_message(cls, args: dict, app_id: str) -> MessageAnnotation:
@@ -85,7 +108,7 @@ class AppAnnotationService:
return annotation
@classmethod
def enable_app_annotation(cls, args: dict, app_id: str):
def enable_app_annotation(cls, args: dict, app_id: str) -> AnnotationJobStatusDict:
enable_app_annotation_key = f"enable_app_annotation_{str(app_id)}"
cache_result = redis_client.get(enable_app_annotation_key)
if cache_result is not None:
@@ -109,7 +132,7 @@ class AppAnnotationService:
return {"job_id": job_id, "job_status": "waiting"}
@classmethod
def disable_app_annotation(cls, app_id: str):
def disable_app_annotation(cls, app_id: str) -> AnnotationJobStatusDict:
_, current_tenant_id = current_account_with_tenant()
disable_app_annotation_key = f"disable_app_annotation_{str(app_id)}"
cache_result = redis_client.get(disable_app_annotation_key)
@@ -567,7 +590,7 @@ class AppAnnotationService:
db.session.commit()
@classmethod
def get_app_annotation_setting_by_app_id(cls, app_id: str):
def get_app_annotation_setting_by_app_id(cls, app_id: str) -> AnnotationSettingDict | AnnotationSettingDisabledDict:
_, current_tenant_id = current_account_with_tenant()
# get app info
app = (
@@ -602,7 +625,9 @@ class AppAnnotationService:
return {"enabled": False}
@classmethod
def update_app_annotation_setting(cls, app_id: str, annotation_setting_id: str, args: dict):
def update_app_annotation_setting(
cls, app_id: str, annotation_setting_id: str, args: dict
) -> AnnotationSettingDict:
current_user, current_tenant_id = current_account_with_tenant()
# get app info
app = (

View File

@@ -1034,7 +1034,7 @@ class TestRegisterService:
)
# Verify rollback operations were called
mock_db_dependencies["db"].session.query.assert_called()
mock_db_dependencies["db"].session.execute.assert_called()
# ==================== Registration Tests ====================
@@ -1599,10 +1599,8 @@ class TestRegisterService:
mock_session_class.return_value.__exit__.return_value = None
mock_lookup.return_value = mock_existing_account
# Mock the db.session.query for TenantAccountJoin
mock_db_query = MagicMock()
mock_db_query.filter_by.return_value.first.return_value = None # No existing member
mock_db_dependencies["db"].session.query.return_value = mock_db_query
# Mock scalar for TenantAccountJoin lookup - no existing member
mock_db_dependencies["db"].session.scalar.return_value = None
# Mock TenantService methods
with (
@@ -1777,14 +1775,9 @@ class TestRegisterService:
}
mock_get_invitation_by_token.return_value = invitation_data
# Mock database queries - complex query mocking
mock_query1 = MagicMock()
mock_query1.where.return_value.first.return_value = mock_tenant
mock_query2 = MagicMock()
mock_query2.join.return_value.where.return_value.first.return_value = (mock_account, "normal")
mock_db_dependencies["db"].session.query.side_effect = [mock_query1, mock_query2]
# Mock scalar for tenant lookup, execute for account+role lookup
mock_db_dependencies["db"].session.scalar.return_value = mock_tenant
mock_db_dependencies["db"].session.execute.return_value.first.return_value = (mock_account, "normal")
# Execute test
result = RegisterService.get_invitation_if_token_valid("tenant-456", "test@example.com", "token-123")
@@ -1816,10 +1809,8 @@ class TestRegisterService:
}
mock_redis_dependencies.get.return_value = json.dumps(invitation_data).encode()
# Mock database queries - no tenant found
mock_query = MagicMock()
mock_query.filter.return_value.first.return_value = None
mock_db_dependencies["db"].session.query.return_value = mock_query
# Mock scalar for tenant lookup - not found
mock_db_dependencies["db"].session.scalar.return_value = None
# Execute test
result = RegisterService.get_invitation_if_token_valid("tenant-456", "test@example.com", "token-123")
@@ -1842,14 +1833,9 @@ class TestRegisterService:
}
mock_redis_dependencies.get.return_value = json.dumps(invitation_data).encode()
# Mock database queries
mock_query1 = MagicMock()
mock_query1.filter.return_value.first.return_value = mock_tenant
mock_query2 = MagicMock()
mock_query2.join.return_value.where.return_value.first.return_value = None # No account found
mock_db_dependencies["db"].session.query.side_effect = [mock_query1, mock_query2]
# Mock scalar for tenant, execute for account+role
mock_db_dependencies["db"].session.scalar.return_value = mock_tenant
mock_db_dependencies["db"].session.execute.return_value.first.return_value = None # No account found
# Execute test
result = RegisterService.get_invitation_if_token_valid("tenant-456", "test@example.com", "token-123")
@@ -1875,14 +1861,9 @@ class TestRegisterService:
}
mock_redis_dependencies.get.return_value = json.dumps(invitation_data).encode()
# Mock database queries
mock_query1 = MagicMock()
mock_query1.filter.return_value.first.return_value = mock_tenant
mock_query2 = MagicMock()
mock_query2.join.return_value.where.return_value.first.return_value = (mock_account, "normal")
mock_db_dependencies["db"].session.query.side_effect = [mock_query1, mock_query2]
# Mock scalar for tenant, execute for account+role
mock_db_dependencies["db"].session.scalar.return_value = mock_tenant
mock_db_dependencies["db"].session.execute.return_value.first.return_value = (mock_account, "normal")
# Execute test
result = RegisterService.get_invitation_if_token_valid("tenant-456", "test@example.com", "token-123")

View File

@@ -5,7 +5,6 @@
"prepare": "vp config"
},
"devDependencies": {
"taze": "catalog:",
"vite-plus": "catalog:"
},
"engines": {

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.33317 3.33333H7.33317V12.6667H5.33317V14H10.6665V12.6667H8.6665V3.33333H10.6665V2H5.33317V3.33333ZM1.33317 4.66667C0.964984 4.66667 0.666504 4.96515 0.666504 5.33333V10.6667C0.666504 11.0349 0.964984 11.3333 1.33317 11.3333H5.33317V10H1.99984V6H5.33317V4.66667H1.33317ZM10.6665 6H13.9998V10H10.6665V11.3333H14.6665C15.0347 11.3333 15.3332 11.0349 15.3332 10.6667V5.33333C15.3332 4.96515 15.0347 4.66667 14.6665 4.66667H10.6665V6Z" fill="#354052"/>
</svg>

After

Width:  |  Height:  |  Size: 563 B

560
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -41,6 +41,7 @@ overrides:
is-generator-function: npm:@nolyfill/is-generator-function@^1.0.44
is-typed-array: npm:@nolyfill/is-typed-array@^1.0.44
isarray: npm:@nolyfill/isarray@^1.0.44
lodash@>=4.0.0 <= 4.17.23: 4.18.0
lodash-es@>=4.0.0 <= 4.17.23: 4.18.0
object.assign: npm:@nolyfill/object.assign@^1.0.44
object.entries: npm:@nolyfill/object.entries@^1.0.44
@@ -131,6 +132,7 @@ catalog:
"@tanstack/react-form-devtools": 0.2.20
"@tanstack/react-query": 5.96.1
"@tanstack/react-query-devtools": 5.96.1
"@tanstack/react-virtual": 3.13.23
"@testing-library/dom": 10.4.1
"@testing-library/jest-dom": 6.9.1
"@testing-library/react": 16.3.2
@@ -146,8 +148,6 @@ catalog:
"@types/qs": 6.15.0
"@types/react": 19.2.14
"@types/react-dom": 19.2.3
"@types/react-syntax-highlighter": 15.5.13
"@types/react-window": 1.8.8
"@types/sortablejs": 1.15.9
"@typescript-eslint/eslint-plugin": 8.58.0
"@typescript-eslint/parser": 8.58.0
@@ -162,7 +162,7 @@ catalog:
class-variance-authority: 0.7.1
clsx: 2.1.1
cmdk: 1.1.1
code-inspector-plugin: 1.5.0
code-inspector-plugin: 1.5.1
copy-to-clipboard: 3.3.3
cron-parser: 5.5.0
dayjs: 1.11.20
@@ -187,6 +187,7 @@ catalog:
fast-deep-equal: 3.1.3
foxact: 0.3.0
happy-dom: 20.8.9
hast-util-to-jsx-runtime: 2.3.6
hono: 4.12.10
html-entities: 2.6.0
html-to-image: 1.11.13
@@ -227,14 +228,13 @@ catalog:
react-pdf-highlighter: 8.0.0-rc.0
react-server-dom-webpack: 19.2.4
react-sortablejs: 6.1.4
react-syntax-highlighter: 15.6.6
react-textarea-autosize: 8.5.9
react-window: 1.8.11
reactflow: 11.11.4
remark-breaks: 4.0.0
remark-directive: 4.0.0
scheduler: 0.27.0
sharp: 0.34.5
shiki: 4.0.2
sortablejs: 1.15.7
std-semver: 1.0.8
storybook: 10.3.4
@@ -242,7 +242,6 @@ catalog:
string-ts: 2.3.1
tailwind-merge: 3.5.0
tailwindcss: 4.2.2
taze: 19.11.0
tldts: 7.0.27
tsdown: 0.21.7
tsx: 4.21.0

View File

@@ -1,10 +0,0 @@
import { defineConfig } from 'taze'
export default defineConfig({
exclude: [
// We are going to replace these
'react-syntax-highlighter',
'react-window',
'@types/react-window',
],
})

View File

@@ -0,0 +1,36 @@
import { vi } from 'vitest'
const mockVirtualizer = ({
count,
estimateSize,
}: {
count: number
estimateSize?: (index: number) => number
}) => {
const getSize = (index: number) => estimateSize?.(index) ?? 0
return {
getTotalSize: () => Array.from({ length: count }).reduce<number>((total, _, index) => total + getSize(index), 0),
getVirtualItems: () => {
let start = 0
return Array.from({ length: count }).map((_, index) => {
const size = getSize(index)
const virtualItem = {
end: start + size,
index,
key: index,
size,
start,
}
start += size
return virtualItem
})
},
measureElement: vi.fn(),
scrollToIndex: vi.fn(),
}
}
export { mockVirtualizer as useVirtualizer }

View File

@@ -0,0 +1,11 @@
import Evaluation from '@/app/components/evaluation'
const Page = async (props: {
params: Promise<{ appId: string }>
}) => {
const { appId } = await props.params
return <Evaluation resourceType="workflow" resourceId={appId} />
}
export default Page

View File

@@ -7,6 +7,8 @@ import {
RiDashboard2Line,
RiFileList3Fill,
RiFileList3Line,
RiFlaskFill,
RiFlaskLine,
RiTerminalBoxFill,
RiTerminalBoxLine,
RiTerminalWindowFill,
@@ -67,40 +69,47 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
}>>([])
const getNavigationConfig = useCallback((appId: string, isCurrentWorkspaceEditor: boolean, mode: AppModeEnum) => {
const navConfig = [
...(isCurrentWorkspaceEditor
? [{
name: t('appMenus.promptEng', { ns: 'common' }),
href: `/app/${appId}/${(mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT) ? 'workflow' : 'configuration'}`,
icon: RiTerminalWindowLine,
selectedIcon: RiTerminalWindowFill,
}]
: []
),
{
name: t('appMenus.apiAccess', { ns: 'common' }),
href: `/app/${appId}/develop`,
icon: RiTerminalBoxLine,
selectedIcon: RiTerminalBoxFill,
},
...(isCurrentWorkspaceEditor
? [{
name: mode !== AppModeEnum.WORKFLOW
? t('appMenus.logAndAnn', { ns: 'common' })
: t('appMenus.logs', { ns: 'common' }),
href: `/app/${appId}/logs`,
icon: RiFileList3Line,
selectedIcon: RiFileList3Fill,
}]
: []
),
{
name: t('appMenus.overview', { ns: 'common' }),
href: `/app/${appId}/overview`,
icon: RiDashboard2Line,
selectedIcon: RiDashboard2Fill,
},
]
const navConfig = []
if (isCurrentWorkspaceEditor) {
navConfig.push({
name: t('appMenus.promptEng', { ns: 'common' }),
href: `/app/${appId}/${(mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT) ? 'workflow' : 'configuration'}`,
icon: RiTerminalWindowLine,
selectedIcon: RiTerminalWindowFill,
})
navConfig.push({
name: t('appMenus.evaluation', { ns: 'common' }),
href: `/app/${appId}/evaluation`,
icon: RiFlaskLine,
selectedIcon: RiFlaskFill,
})
}
navConfig.push({
name: t('appMenus.apiAccess', { ns: 'common' }),
href: `/app/${appId}/develop`,
icon: RiTerminalBoxLine,
selectedIcon: RiTerminalBoxFill,
})
if (isCurrentWorkspaceEditor) {
navConfig.push({
name: mode !== AppModeEnum.WORKFLOW
? t('appMenus.logAndAnn', { ns: 'common' })
: t('appMenus.logs', { ns: 'common' }),
href: `/app/${appId}/logs`,
icon: RiFileList3Line,
selectedIcon: RiFileList3Fill,
})
}
navConfig.push({
name: t('appMenus.overview', { ns: 'common' }),
href: `/app/${appId}/overview`,
icon: RiDashboard2Line,
selectedIcon: RiDashboard2Fill,
})
return navConfig
}, [t])

View File

@@ -0,0 +1,11 @@
import Evaluation from '@/app/components/evaluation'
const Page = async (props: {
params: Promise<{ datasetId: string }>
}) => {
const { datasetId } = await props.params
return <Evaluation resourceType="pipeline" resourceId={datasetId} />
}
export default Page

View File

@@ -6,6 +6,8 @@ import {
RiEqualizer2Line,
RiFileTextFill,
RiFileTextLine,
RiFlaskFill,
RiFlaskLine,
RiFocus2Fill,
RiFocus2Line,
} from '@remixicon/react'
@@ -86,20 +88,30 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
]
if (datasetRes?.provider !== 'external') {
baseNavigation.unshift({
name: t('datasetMenus.pipeline', { ns: 'common' }),
href: `/datasets/${datasetId}/pipeline`,
icon: PipelineLine as RemixiconComponentType,
selectedIcon: PipelineFill as RemixiconComponentType,
disabled: false,
})
baseNavigation.unshift({
name: t('datasetMenus.documents', { ns: 'common' }),
href: `/datasets/${datasetId}/documents`,
icon: RiFileTextLine,
selectedIcon: RiFileTextFill,
disabled: isButtonDisabledWithPipeline,
})
return [
{
name: t('datasetMenus.documents', { ns: 'common' }),
href: `/datasets/${datasetId}/documents`,
icon: RiFileTextLine,
selectedIcon: RiFileTextFill,
disabled: isButtonDisabledWithPipeline,
},
{
name: t('datasetMenus.pipeline', { ns: 'common' }),
href: `/datasets/${datasetId}/pipeline`,
icon: PipelineLine as RemixiconComponentType,
selectedIcon: PipelineFill as RemixiconComponentType,
disabled: false,
},
{
name: t('datasetMenus.evaluation', { ns: 'common' }),
href: `/datasets/${datasetId}/evaluation`,
icon: RiFlaskLine,
selectedIcon: RiFlaskFill,
disabled: false,
},
...baseNavigation,
]
}
return baseNavigation

View File

@@ -6,7 +6,7 @@ import Loading from '@/app/components/base/loading'
import { useAppContext } from '@/context/app-context'
import { usePathname, useRouter } from '@/next/navigation'
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/explore', '/tools'] as const
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/snippets', '/explore', '/tools'] as const
const isPathUnderRoute = (pathname: string, route: string) => pathname === route || pathname.startsWith(`${route}/`)

View File

@@ -0,0 +1,11 @@
import SnippetEvaluationPage from '@/app/components/snippets/snippet-evaluation-page'
const Page = async (props: {
params: Promise<{ snippetId: string }>
}) => {
const { snippetId } = await props.params
return <SnippetEvaluationPage snippetId={snippetId} />
}
export default Page

View File

@@ -0,0 +1,11 @@
import SnippetPage from '@/app/components/snippets'
const Page = async (props: {
params: Promise<{ snippetId: string }>
}) => {
const { snippetId } = await props.params
return <SnippetPage snippetId={snippetId} />
}
export default Page

View File

@@ -0,0 +1,21 @@
import Page from './page'
const mockRedirect = vi.fn()
vi.mock('next/navigation', () => ({
redirect: (path: string) => mockRedirect(path),
}))
describe('snippet detail redirect page', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should redirect legacy snippet detail routes to orchestrate', async () => {
await Page({
params: Promise.resolve({ snippetId: 'snippet-1' }),
})
expect(mockRedirect).toHaveBeenCalledWith('/snippets/snippet-1/orchestrate')
})
})

View File

@@ -0,0 +1,11 @@
import { redirect } from 'next/navigation'
const Page = async (props: {
params: Promise<{ snippetId: string }>
}) => {
const { snippetId } = await props.params
redirect(`/snippets/${snippetId}/orchestrate`)
}
export default Page

View File

@@ -0,0 +1,7 @@
import Apps from '@/app/components/apps'
const SnippetsPage = () => {
return <Apps pageType="snippets" />
}
export default SnippetsPage

View File

@@ -165,6 +165,21 @@ describe('AppDetailNav', () => {
)
expect(screen.queryByTestId('extra-info')).not.toBeInTheDocument()
})
it('should render custom header and navigation when provided', () => {
render(
<AppDetailNav
navigation={navigation}
renderHeader={mode => <div data-testid="custom-header" data-mode={mode} />}
renderNavigation={mode => <div data-testid="custom-navigation" data-mode={mode} />}
/>,
)
expect(screen.getByTestId('custom-header')).toHaveAttribute('data-mode', 'expand')
expect(screen.getByTestId('custom-navigation')).toHaveAttribute('data-mode', 'expand')
expect(screen.queryByTestId('app-info')).not.toBeInTheDocument()
expect(screen.queryByTestId('nav-link-Overview')).not.toBeInTheDocument()
})
})
describe('Workflow canvas mode', () => {

View File

@@ -2,7 +2,7 @@ import type { App, AppSSO } from '@/types/app'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { AppModeEnum } from '@/types/app'
import { AppModeEnum, AppTypeEnum } from '@/types/app'
import AppInfoDetailPanel from '../app-info-detail-panel'
vi.mock('../../../base/app-icon', () => ({
@@ -135,6 +135,17 @@ describe('AppInfoDetailPanel', () => {
expect(cardView).toHaveAttribute('data-app-id', 'app-1')
})
it('should not render CardView when app type is evaluation', () => {
render(
<AppInfoDetailPanel
{...defaultProps}
appDetail={createAppDetail({ type: AppTypeEnum.EVALUATION })}
/>,
)
expect(screen.queryByTestId('card-view')).not.toBeInTheDocument()
})
it('should render app icon with large size', () => {
render(<AppInfoDetailPanel {...defaultProps} />)
const icon = screen.getByTestId('app-icon')

View File

@@ -15,7 +15,7 @@ import { useTranslation } from 'react-i18next'
import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view'
import Button from '@/app/components/base/button'
import ContentDialog from '@/app/components/base/content-dialog'
import { AppModeEnum } from '@/types/app'
import { AppModeEnum, AppTypeEnum } from '@/types/app'
import AppIcon from '../../base/app-icon'
import { getAppModeLabel } from './app-mode-labels'
import AppOperations from './app-operations'
@@ -97,7 +97,7 @@ const AppInfoDetailPanel = ({
<ContentDialog
show={show}
onClose={onClose}
className="absolute bottom-2 left-2 top-2 flex w-[420px] flex-col rounded-2xl p-0!"
className="absolute top-2 bottom-2 left-2 flex w-[420px] flex-col rounded-2xl p-0!"
>
<div className="flex shrink-0 flex-col items-start justify-center gap-3 self-stretch p-4">
<div className="flex items-center gap-3 self-stretch">
@@ -109,14 +109,14 @@ const AppInfoDetailPanel = ({
imageUrl={appDetail.icon_url}
/>
<div className="flex flex-1 flex-col items-start justify-center overflow-hidden">
<div className="w-full truncate text-text-secondary system-md-semibold">{appDetail.name}</div>
<div className="text-text-tertiary system-2xs-medium-uppercase">
<div className="w-full truncate system-md-semibold text-text-secondary">{appDetail.name}</div>
<div className="system-2xs-medium-uppercase text-text-tertiary">
{getAppModeLabel(appDetail.mode, t)}
</div>
</div>
</div>
{appDetail.description && (
<div className="overflow-wrap-anywhere max-h-[105px] w-full max-w-full overflow-y-auto whitespace-normal wrap-break-word text-text-tertiary system-xs-regular">
<div className="overflow-wrap-anywhere max-h-[105px] w-full max-w-full overflow-y-auto system-xs-regular wrap-break-word whitespace-normal text-text-tertiary">
{appDetail.description}
</div>
)}
@@ -126,11 +126,13 @@ const AppInfoDetailPanel = ({
secondaryOperations={secondaryOperations}
/>
</div>
<CardView
appId={appDetail.id}
isInPanel={true}
className="flex flex-1 flex-col gap-2 overflow-auto px-2 py-1"
/>
{appDetail.type !== AppTypeEnum.EVALUATION && (
<CardView
appId={appDetail.id}
isInPanel={true}
className="flex flex-1 flex-col gap-2 overflow-auto px-2 py-1"
/>
)}
{switchOperation && (
<div className="flex min-h-fit shrink-0 flex-col items-start justify-center gap-3 self-stretch pb-2">
<Button
@@ -140,7 +142,7 @@ const AppInfoDetailPanel = ({
onClick={switchOperation.onClick}
>
{switchOperation.icon}
<span className="text-text-tertiary system-sm-medium">{switchOperation.title}</span>
<span className="system-sm-medium text-text-tertiary">{switchOperation.title}</span>
</Button>
</div>
)}

View File

@@ -27,12 +27,16 @@ type IAppDetailNavProps = {
disabled?: boolean
}>
extraInfo?: (modeState: string) => React.ReactNode
renderHeader?: (modeState: string) => React.ReactNode
renderNavigation?: (modeState: string) => React.ReactNode
}
const AppDetailNav = ({
navigation,
extraInfo,
iconType = 'app',
renderHeader,
renderNavigation,
}: IAppDetailNavProps) => {
const { appSidebarExpand, setAppSidebarExpand } = useAppStore(useShallow(state => ({
appSidebarExpand: state.appSidebarExpand,
@@ -104,10 +108,11 @@ const AppDetailNav = ({
expand ? 'p-2' : 'p-1',
)}
>
{iconType === 'app' && (
{renderHeader?.(appSidebarExpand)}
{!renderHeader && iconType === 'app' && (
<AppInfo expand={expand} />
)}
{iconType !== 'app' && (
{!renderHeader && iconType !== 'app' && (
<DatasetInfo expand={expand} />
)}
</div>
@@ -136,7 +141,8 @@ const AppDetailNav = ({
expand ? 'px-3 py-2' : 'p-3',
)}
>
{navigation.map((item, index) => {
{renderNavigation?.(appSidebarExpand)}
{!renderNavigation && navigation.map((item, index) => {
return (
<NavLink
key={index}

View File

@@ -262,4 +262,20 @@ describe('NavLink Animation and Layout Issues', () => {
expect(iconWrapper).toHaveClass('-ml-1')
})
})
describe('Button Mode', () => {
it('should render as an interactive button when href is omitted', () => {
const onClick = vi.fn()
render(<NavLink {...mockProps} href={undefined} active={true} onClick={onClick} />)
const buttonElement = screen.getByText('Orchestrate').closest('button')
expect(buttonElement).not.toBeNull()
expect(buttonElement).toHaveClass('bg-components-menu-item-bg-active')
expect(buttonElement).toHaveClass('text-text-accent-light-mode-only')
buttonElement?.click()
expect(onClick).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -14,13 +14,15 @@ export type NavIcon = React.ComponentType<
export type NavLinkProps = {
name: string
href: string
href?: string
iconMap: {
selected: NavIcon
normal: NavIcon
}
mode?: string
disabled?: boolean
active?: boolean
onClick?: () => void
}
const NavLink = ({
@@ -29,6 +31,8 @@ const NavLink = ({
iconMap,
mode = 'expand',
disabled = false,
active,
onClick,
}: NavLinkProps) => {
const segment = useSelectedLayoutSegment()
const formattedSegment = (() => {
@@ -39,8 +43,11 @@ const NavLink = ({
return res
})()
const isActive = href.toLowerCase().split('/')?.pop() === formattedSegment
const isActive = active ?? (href ? href.toLowerCase().split('/')?.pop() === formattedSegment : false)
const NavIcon = isActive ? iconMap.selected : iconMap.normal
const linkClassName = cn(isActive
? 'border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only system-sm-semibold'
: 'text-components-menu-item-text system-sm-medium hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pl-3 pr-1')
const renderIcon = () => (
<div className={cn(mode !== 'expand' && '-ml-1')}>
@@ -70,13 +77,32 @@ const NavLink = ({
)
}
if (!href) {
return (
<button
key={name}
type="button"
className={linkClassName}
title={mode === 'collapse' ? name : ''}
onClick={onClick}
>
{renderIcon()}
<span
className={cn('overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out', mode === 'expand'
? 'ml-2 max-w-none opacity-100'
: 'ml-0 max-w-0 opacity-0')}
>
{name}
</span>
</button>
)
}
return (
<Link
key={name}
href={href}
className={cn(isActive
? 'border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only system-sm-semibold'
: 'text-components-menu-item-text system-sm-medium hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pl-3 pr-1')}
className={linkClassName}
title={mode === 'collapse' ? name : ''}
>
{renderIcon()}

View File

@@ -0,0 +1,285 @@
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import type { CreateSnippetDialogPayload } from '@/app/components/workflow/create-snippet-dialog'
import type { SnippetDetail } from '@/models/snippet'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import SnippetInfoDropdown from '../dropdown'
const mockReplace = vi.fn()
const mockDownloadBlob = vi.fn()
const mockToastSuccess = vi.fn()
const mockToastError = vi.fn()
const mockUpdateMutate = vi.fn()
const mockExportMutateAsync = vi.fn()
const mockDeleteMutate = vi.fn()
let mockDropdownOpen = false
let mockDropdownOnOpenChange: ((open: boolean) => void) | undefined
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
replace: mockReplace,
}),
}))
vi.mock('@/utils/download', () => ({
downloadBlob: (args: { data: Blob, fileName: string }) => mockDownloadBlob(args),
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
success: (...args: unknown[]) => mockToastSuccess(...args),
error: (...args: unknown[]) => mockToastError(...args),
},
}))
vi.mock('@/app/components/base/ui/dropdown-menu', () => ({
DropdownMenu: ({
open,
onOpenChange,
children,
}: {
open?: boolean
onOpenChange?: (open: boolean) => void
children: React.ReactNode
}) => {
mockDropdownOpen = !!open
mockDropdownOnOpenChange = onOpenChange
return <div>{children}</div>
},
DropdownMenuTrigger: ({
children,
className,
}: {
children: React.ReactNode
className?: string
}) => (
<button
type="button"
className={className}
onClick={() => mockDropdownOnOpenChange?.(!mockDropdownOpen)}
>
{children}
</button>
),
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
mockDropdownOpen ? <div>{children}</div> : null
),
DropdownMenuItem: ({
children,
onClick,
}: {
children: React.ReactNode
onClick?: () => void
}) => (
<button type="button" onClick={onClick}>
{children}
</button>
),
DropdownMenuSeparator: () => <hr />,
}))
vi.mock('@/service/use-snippets', () => ({
useUpdateSnippetMutation: () => ({
mutate: mockUpdateMutate,
isPending: false,
}),
useExportSnippetMutation: () => ({
mutateAsync: mockExportMutateAsync,
isPending: false,
}),
useDeleteSnippetMutation: () => ({
mutate: mockDeleteMutate,
isPending: false,
}),
}))
type MockCreateSnippetDialogProps = {
isOpen: boolean
title?: string
confirmText?: string
initialValue?: {
name?: string
description?: string
icon?: AppIconSelection
}
onClose: () => void
onConfirm: (payload: CreateSnippetDialogPayload) => void
}
vi.mock('@/app/components/workflow/create-snippet-dialog', () => ({
default: ({
isOpen,
title,
confirmText,
initialValue,
onClose,
onConfirm,
}: MockCreateSnippetDialogProps) => {
if (!isOpen)
return null
return (
<div data-testid="create-snippet-dialog">
<div>{title}</div>
<div>{confirmText}</div>
<div>{initialValue?.name}</div>
<div>{initialValue?.description}</div>
<button
type="button"
onClick={() => onConfirm({
name: 'Updated snippet',
description: 'Updated description',
icon: {
type: 'emoji',
icon: '✨',
background: '#FFFFFF',
},
graph: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
})}
>
submit-edit
</button>
<button type="button" onClick={onClose}>close-edit</button>
</div>
)
},
}))
const mockSnippet: SnippetDetail = {
id: 'snippet-1',
name: 'Social Media Repurposer',
description: 'Turn one blog post into multiple social media variations.',
author: 'Dify',
updatedAt: '2026-03-25 10:00',
usage: '12',
icon: '🤖',
iconBackground: '#F0FDF9',
status: undefined,
}
describe('SnippetInfoDropdown', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDropdownOpen = false
mockDropdownOnOpenChange = undefined
})
// Rendering coverage for the menu trigger itself.
describe('Rendering', () => {
it('should render the dropdown trigger button', () => {
render(<SnippetInfoDropdown snippet={mockSnippet} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
// Edit flow should seed the dialog with current snippet info and submit updates.
describe('Edit Snippet', () => {
it('should open the edit dialog and submit snippet updates', async () => {
const user = userEvent.setup()
mockUpdateMutate.mockImplementation((_variables: unknown, options?: { onSuccess?: () => void }) => {
options?.onSuccess?.()
})
render(<SnippetInfoDropdown snippet={mockSnippet} />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('snippet.menu.editInfo'))
expect(screen.getByTestId('create-snippet-dialog')).toBeInTheDocument()
expect(screen.getByText('snippet.editDialogTitle')).toBeInTheDocument()
expect(screen.getByText('common.operation.save')).toBeInTheDocument()
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
expect(screen.getByText(mockSnippet.description)).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'submit-edit' }))
expect(mockUpdateMutate).toHaveBeenCalledWith({
params: { snippetId: mockSnippet.id },
body: {
name: 'Updated snippet',
description: 'Updated description',
icon_info: {
icon: '✨',
icon_type: 'emoji',
icon_background: '#FFFFFF',
icon_url: undefined,
},
},
}, expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}))
expect(mockToastSuccess).toHaveBeenCalledWith('snippet.editDone')
})
})
// Export should call the export hook and download the returned YAML blob.
describe('Export Snippet', () => {
it('should export and download the snippet yaml', async () => {
const user = userEvent.setup()
mockExportMutateAsync.mockResolvedValue('yaml: content')
render(<SnippetInfoDropdown snippet={mockSnippet} />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('snippet.menu.exportSnippet'))
await waitFor(() => {
expect(mockExportMutateAsync).toHaveBeenCalledWith({ snippetId: mockSnippet.id })
})
expect(mockDownloadBlob).toHaveBeenCalledWith({
data: expect.any(Blob),
fileName: `${mockSnippet.name}.yml`,
})
})
it('should show an error toast when export fails', async () => {
const user = userEvent.setup()
mockExportMutateAsync.mockRejectedValue(new Error('export failed'))
render(<SnippetInfoDropdown snippet={mockSnippet} />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('snippet.menu.exportSnippet'))
await waitFor(() => {
expect(mockToastError).toHaveBeenCalledWith('snippet.exportFailed')
})
})
})
// Delete should require confirmation and redirect after a successful mutation.
describe('Delete Snippet', () => {
it('should confirm deletion and redirect to the snippets list', async () => {
const user = userEvent.setup()
mockDeleteMutate.mockImplementation((_variables: unknown, options?: { onSuccess?: () => void }) => {
options?.onSuccess?.()
})
render(<SnippetInfoDropdown snippet={mockSnippet} />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('snippet.menu.deleteSnippet'))
expect(screen.getByText('snippet.deleteConfirmTitle')).toBeInTheDocument()
expect(screen.getByText('snippet.deleteConfirmContent')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'snippet.menu.deleteSnippet' }))
expect(mockDeleteMutate).toHaveBeenCalledWith({
params: { snippetId: mockSnippet.id },
}, expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}))
expect(mockToastSuccess).toHaveBeenCalledWith('snippet.deleted')
expect(mockReplace).toHaveBeenCalledWith('/snippets')
})
})
})

View File

@@ -0,0 +1,62 @@
import type { SnippetDetail } from '@/models/snippet'
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import SnippetInfo from '..'
vi.mock('../dropdown', () => ({
default: () => <div data-testid="snippet-info-dropdown" />,
}))
const mockSnippet: SnippetDetail = {
id: 'snippet-1',
name: 'Social Media Repurposer',
description: 'Turn one blog post into multiple social media variations.',
author: 'Dify',
updatedAt: '2026-03-25 10:00',
usage: '12',
icon: '🤖',
iconBackground: '#F0FDF9',
status: undefined,
}
describe('SnippetInfo', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering tests for the collapsed and expanded sidebar header states.
describe('Rendering', () => {
it('should render the expanded snippet details and dropdown when expand is true', () => {
render(<SnippetInfo expand={true} snippet={mockSnippet} />)
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
expect(screen.getByText('snippet.typeLabel')).toBeInTheDocument()
expect(screen.getByText(mockSnippet.description)).toBeInTheDocument()
expect(screen.getByTestId('snippet-info-dropdown')).toBeInTheDocument()
})
it('should hide the expanded-only content when expand is false', () => {
render(<SnippetInfo expand={false} snippet={mockSnippet} />)
expect(screen.queryByText(mockSnippet.name)).not.toBeInTheDocument()
expect(screen.queryByText('snippet.typeLabel')).not.toBeInTheDocument()
expect(screen.queryByText(mockSnippet.description)).not.toBeInTheDocument()
expect(screen.queryByTestId('snippet-info-dropdown')).not.toBeInTheDocument()
})
})
// Edge cases around optional snippet fields should not break the header layout.
describe('Edge Cases', () => {
it('should omit the description block when the snippet has no description', () => {
render(
<SnippetInfo
expand={true}
snippet={{ ...mockSnippet, description: '' }}
/>,
)
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
expect(screen.queryByText(mockSnippet.description)).not.toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,197 @@
'use client'
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import type { SnippetDetail } from '@/models/snippet'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
import { toast } from '@/app/components/base/ui/toast'
import CreateSnippetDialog from '@/app/components/workflow/create-snippet-dialog'
import { useRouter } from '@/next/navigation'
import { useDeleteSnippetMutation, useExportSnippetMutation, useUpdateSnippetMutation } from '@/service/use-snippets'
import { cn } from '@/utils/classnames'
import { downloadBlob } from '@/utils/download'
type SnippetInfoDropdownProps = {
snippet: SnippetDetail
}
const FALLBACK_ICON: AppIconSelection = {
type: 'emoji',
icon: '🤖',
background: '#FFEAD5',
}
const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => {
const { t } = useTranslation('snippet')
const { replace } = useRouter()
const [open, setOpen] = React.useState(false)
const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false)
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false)
const updateSnippetMutation = useUpdateSnippetMutation()
const exportSnippetMutation = useExportSnippetMutation()
const deleteSnippetMutation = useDeleteSnippetMutation()
const initialValue = React.useMemo(() => ({
name: snippet.name,
description: snippet.description,
icon: snippet.icon
? {
type: 'emoji' as const,
icon: snippet.icon,
background: snippet.iconBackground || FALLBACK_ICON.background,
}
: FALLBACK_ICON,
}), [snippet.description, snippet.icon, snippet.iconBackground, snippet.name])
const handleOpenEditDialog = React.useCallback(() => {
setOpen(false)
setIsEditDialogOpen(true)
}, [])
const handleExportSnippet = React.useCallback(async () => {
setOpen(false)
try {
const data = await exportSnippetMutation.mutateAsync({ snippetId: snippet.id })
const file = new Blob([data], { type: 'application/yaml' })
downloadBlob({ data: file, fileName: `${snippet.name}.yml` })
}
catch {
toast.error(t('exportFailed'))
}
}, [exportSnippetMutation, snippet.id, snippet.name, t])
const handleEditSnippet = React.useCallback(async ({ name, description, icon }: {
name: string
description: string
icon: AppIconSelection
}) => {
updateSnippetMutation.mutate({
params: { snippetId: snippet.id },
body: {
name,
description: description || undefined,
icon_info: {
icon: icon.type === 'emoji' ? icon.icon : icon.fileId,
icon_type: icon.type,
icon_background: icon.type === 'emoji' ? icon.background : undefined,
icon_url: icon.type === 'image' ? icon.url : undefined,
},
},
}, {
onSuccess: () => {
toast.success(t('editDone'))
setIsEditDialogOpen(false)
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : t('editFailed'))
},
})
}, [snippet.id, t, updateSnippetMutation])
const handleDeleteSnippet = React.useCallback(() => {
deleteSnippetMutation.mutate({
params: { snippetId: snippet.id },
}, {
onSuccess: () => {
toast.success(t('deleted'))
setIsDeleteDialogOpen(false)
replace('/snippets')
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : t('deleteFailed'))
},
})
}, [deleteSnippetMutation, replace, snippet.id, t])
return (
<>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
className={cn('action-btn action-btn-m size-6 rounded-md text-text-tertiary', open && 'bg-state-base-hover text-text-secondary')}
>
<span aria-hidden className="i-ri-more-fill size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="w-[180px] p-1"
>
<DropdownMenuItem className="mx-0 gap-2" onClick={handleOpenEditDialog}>
<span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" />
<span className="grow">{t('menu.editInfo')}</span>
</DropdownMenuItem>
<DropdownMenuItem className="mx-0 gap-2" onClick={handleExportSnippet}>
<span aria-hidden className="i-ri-download-2-line size-4 shrink-0 text-text-tertiary" />
<span className="grow">{t('menu.exportSnippet')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator className="!my-1 bg-divider-subtle" />
<DropdownMenuItem
className="mx-0 gap-2"
destructive
onClick={() => {
setOpen(false)
setIsDeleteDialogOpen(true)
}}
>
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
<span className="grow">{t('menu.deleteSnippet')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{isEditDialogOpen && (
<CreateSnippetDialog
isOpen={isEditDialogOpen}
initialValue={initialValue}
title={t('editDialogTitle')}
confirmText={t('operation.save', { ns: 'common' })}
isSubmitting={updateSnippetMutation.isPending}
onClose={() => setIsEditDialogOpen(false)}
onConfirm={handleEditSnippet}
/>
)}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent className="w-[400px]">
<div className="space-y-2 p-6">
<AlertDialogTitle className="text-text-primary title-lg-semi-bold">
{t('deleteConfirmTitle')}
</AlertDialogTitle>
<AlertDialogDescription className="text-text-tertiary system-sm-regular">
{t('deleteConfirmContent')}
</AlertDialogDescription>
</div>
<AlertDialogActions className="pt-0">
<AlertDialogCancelButton>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
loading={deleteSnippetMutation.isPending}
onClick={handleDeleteSnippet}
>
{t('menu.deleteSnippet')}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}
export default React.memo(SnippetInfoDropdown)

View File

@@ -0,0 +1,55 @@
'use client'
import type { SnippetDetail } from '@/models/snippet'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import { cn } from '@/utils/classnames'
import SnippetInfoDropdown from './dropdown'
type SnippetInfoProps = {
expand: boolean
snippet: SnippetDetail
}
const SnippetInfo = ({
expand,
snippet,
}: SnippetInfoProps) => {
const { t } = useTranslation('snippet')
return (
<div className={cn('flex flex-col', expand ? 'px-2 pb-1 pt-2' : 'p-1')}>
<div className={cn('flex flex-col', expand ? 'gap-2 rounded-xl p-2' : '')}>
<div className={cn('flex', expand ? 'items-center justify-between' : 'items-start gap-3')}>
<div className={cn('shrink-0', !expand && 'ml-1')}>
<AppIcon
size={expand ? 'large' : 'small'}
iconType="emoji"
icon={snippet.icon}
background={snippet.iconBackground}
/>
</div>
{expand && <SnippetInfoDropdown snippet={snippet} />}
</div>
{expand && (
<div className="min-w-0">
<div className="truncate text-text-secondary system-md-semibold">
{snippet.name}
</div>
<div className="pt-1 text-text-tertiary system-2xs-medium-uppercase">
{t('typeLabel')}
</div>
</div>
)}
{expand && snippet.description && (
<p className="line-clamp-3 break-words text-text-tertiary system-xs-regular">
{snippet.description}
</p>
)}
</div>
</div>
)
}
export default React.memo(SnippetInfo)

View File

@@ -0,0 +1,69 @@
import { useStore } from '../store'
const resetStore = () => {
useStore.setState({
appDetail: undefined,
appSidebarExpand: '',
currentLogItem: undefined,
currentLogModalActiveTab: 'DETAIL',
showPromptLogModal: false,
showAgentLogModal: false,
showMessageLogModal: false,
showAppConfigureFeaturesModal: false,
})
}
describe('app store', () => {
beforeEach(() => {
resetStore()
})
it('should expose the default state', () => {
expect(useStore.getState()).toEqual(expect.objectContaining({
appDetail: undefined,
appSidebarExpand: '',
currentLogItem: undefined,
currentLogModalActiveTab: 'DETAIL',
showPromptLogModal: false,
showAgentLogModal: false,
showMessageLogModal: false,
showAppConfigureFeaturesModal: false,
}))
})
it('should update every mutable field through its actions', () => {
const appDetail = { id: 'app-1' } as ReturnType<typeof useStore.getState>['appDetail']
const currentLogItem = { id: 'message-1' } as ReturnType<typeof useStore.getState>['currentLogItem']
useStore.getState().setAppDetail(appDetail)
useStore.getState().setAppSidebarExpand('logs')
useStore.getState().setCurrentLogItem(currentLogItem)
useStore.getState().setCurrentLogModalActiveTab('MESSAGE')
useStore.getState().setShowPromptLogModal(true)
useStore.getState().setShowAgentLogModal(true)
useStore.getState().setShowAppConfigureFeaturesModal(true)
expect(useStore.getState()).toEqual(expect.objectContaining({
appDetail,
appSidebarExpand: 'logs',
currentLogItem,
currentLogModalActiveTab: 'MESSAGE',
showPromptLogModal: true,
showAgentLogModal: true,
showAppConfigureFeaturesModal: true,
}))
})
it('should reset the active tab when the message log modal closes', () => {
useStore.getState().setCurrentLogModalActiveTab('TRACE')
useStore.getState().setShowMessageLogModal(true)
expect(useStore.getState().showMessageLogModal).toBe(true)
expect(useStore.getState().currentLogModalActiveTab).toBe('TRACE')
useStore.getState().setShowMessageLogModal(false)
expect(useStore.getState().showMessageLogModal).toBe(false)
expect(useStore.getState().currentLogModalActiveTab).toBe('DETAIL')
})
})

View File

@@ -1,6 +1,6 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import BatchAction from './batch-action'
import BatchAction from '../batch-action'
describe('BatchAction', () => {
const baseProps = {

View File

@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import EmptyElement from './empty-element'
import EmptyElement from '../empty-element'
describe('EmptyElement', () => {
it('should render the empty state copy and supporting icon', () => {

View File

@@ -1,12 +1,12 @@
import type { UseQueryResult } from '@tanstack/react-query'
import type { Mock } from 'vitest'
import type { QueryParam } from './filter'
import type { QueryParam } from '../filter'
import type { AnnotationsCountResponse } from '@/models/log'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import * as useLogModule from '@/service/use-log'
import Filter from './filter'
import Filter from '../filter'
vi.mock('@/service/use-log')

View File

@@ -1,5 +1,6 @@
/* eslint-disable ts/no-explicit-any */
import type { Mock } from 'vitest'
import type { AnnotationItem } from './type'
import type { AnnotationItem } from '../type'
import type { App } from '@/types/app'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
@@ -9,13 +10,16 @@ import {
addAnnotation,
delAnnotation,
delAnnotations,
editAnnotation,
fetchAnnotationConfig,
fetchAnnotationList,
queryAnnotationJobStatus,
updateAnnotationScore,
updateAnnotationStatus,
} from '@/service/annotation'
import { AppModeEnum } from '@/types/app'
import Annotation from './index'
import { JobStatus } from './type'
import Annotation from '../index'
import { AnnotationEnableStatus, JobStatus } from '../type'
vi.mock('ahooks', () => ({
useDebounce: (value: any) => value,
@@ -37,29 +41,32 @@ vi.mock('@/context/provider-context', () => ({
useProviderContext: vi.fn(),
}))
vi.mock('./filter', () => ({
vi.mock('../filter', () => ({
default: ({ children }: { children: React.ReactNode }) => (
<div data-testid="filter">{children}</div>
),
}))
vi.mock('./empty-element', () => ({
vi.mock('../empty-element', () => ({
default: () => <div data-testid="empty-element" />,
}))
vi.mock('./header-opts', () => ({
vi.mock('../header-opts', () => ({
default: (props: any) => (
<div data-testid="header-opts">
<button data-testid="trigger-add" onClick={() => props.onAdd({ question: 'new question', answer: 'new answer' })}>
add
</button>
<button data-testid="trigger-added" onClick={() => props.onAdded()}>
added
</button>
</div>
),
}))
let latestListProps: any
vi.mock('./list', () => ({
vi.mock('../list', () => ({
default: (props: any) => {
latestListProps = props
if (!props.list.length)
@@ -74,7 +81,7 @@ vi.mock('./list', () => ({
},
}))
vi.mock('./view-annotation-modal', () => ({
vi.mock('../view-annotation-modal', () => ({
default: (props: any) => {
if (!props.isShow)
return null
@@ -82,14 +89,40 @@ vi.mock('./view-annotation-modal', () => ({
<div data-testid="view-modal">
<div>{props.item.question}</div>
<button data-testid="view-modal-remove" onClick={props.onRemove}>remove</button>
<button data-testid="view-modal-save" onClick={() => props.onSave('Edited question', 'Edited answer')}>save</button>
<button data-testid="view-modal-close" onClick={props.onHide}>close</button>
</div>
)
},
}))
vi.mock('@/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal', () => ({ default: (props: any) => props.isShow ? <div data-testid="config-modal" /> : null }))
vi.mock('@/app/components/billing/annotation-full/modal', () => ({ default: (props: any) => props.show ? <div data-testid="annotation-full-modal" /> : null }))
vi.mock('@/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal', () => ({
default: (props: any) => props.isShow
? (
<div data-testid="config-modal">
<button
data-testid="config-save"
onClick={() => props.onSave({
embedding_model_name: 'next-model',
embedding_provider_name: 'next-provider',
}, 0.7)}
>
save-config
</button>
<button data-testid="config-hide" onClick={props.onHide}>hide-config</button>
</div>
)
: null,
}))
vi.mock('@/app/components/billing/annotation-full/modal', () => ({
default: (props: any) => props.show
? (
<div data-testid="annotation-full-modal">
<button data-testid="hide-annotation-full-modal" onClick={props.onHide}>hide-full</button>
</div>
)
: null,
}))
const mockNotify = vi.fn()
vi.spyOn(toast, 'success').mockImplementation((message, options) => {
@@ -111,9 +144,12 @@ vi.spyOn(toast, 'info').mockImplementation((message, options) => {
const addAnnotationMock = addAnnotation as Mock
const delAnnotationMock = delAnnotation as Mock
const delAnnotationsMock = delAnnotations as Mock
const editAnnotationMock = editAnnotation as Mock
const fetchAnnotationConfigMock = fetchAnnotationConfig as Mock
const fetchAnnotationListMock = fetchAnnotationList as Mock
const queryAnnotationJobStatusMock = queryAnnotationJobStatus as Mock
const updateAnnotationScoreMock = updateAnnotationScore as Mock
const updateAnnotationStatusMock = updateAnnotationStatus as Mock
const useProviderContextMock = useProviderContext as Mock
const appDetail = {
@@ -146,6 +182,9 @@ describe('Annotation', () => {
})
fetchAnnotationListMock.mockResolvedValue({ data: [], total: 0 })
queryAnnotationJobStatusMock.mockResolvedValue({ job_status: JobStatus.completed })
updateAnnotationStatusMock.mockResolvedValue({ job_id: 'job-1' })
updateAnnotationScoreMock.mockResolvedValue(undefined)
editAnnotationMock.mockResolvedValue(undefined)
useProviderContextMock.mockReturnValue({
plan: {
usage: { annotatedResponse: 0 },
@@ -251,4 +290,166 @@ describe('Annotation', () => {
expect(latestListProps.selectedIds).toEqual([annotation.id])
})
})
it('should show the annotation-full modal when enabling annotations exceeds the plan quota', async () => {
useProviderContextMock.mockReturnValue({
plan: {
usage: { annotatedResponse: 10 },
total: { annotatedResponse: 10 },
},
enableBilling: true,
})
renderComponent()
const toggle = await screen.findByRole('switch')
fireEvent.click(toggle)
expect(screen.getByTestId('annotation-full-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('hide-annotation-full-modal'))
expect(screen.queryByTestId('annotation-full-modal')).not.toBeInTheDocument()
})
it('should disable annotations and refetch config after the async job completes', async () => {
fetchAnnotationConfigMock.mockResolvedValueOnce({
id: 'config-id',
enabled: true,
embedding_model: {
embedding_model_name: 'model',
embedding_provider_name: 'provider',
},
score_threshold: 0.5,
}).mockResolvedValueOnce({
id: 'config-id',
enabled: false,
embedding_model: {
embedding_model_name: 'model',
embedding_provider_name: 'provider',
},
score_threshold: 0.5,
})
renderComponent()
const toggle = await screen.findByRole('switch')
await waitFor(() => {
expect(toggle).toHaveAttribute('aria-checked', 'true')
})
fireEvent.click(toggle)
await waitFor(() => {
expect(updateAnnotationStatusMock).toHaveBeenCalledWith(
appDetail.id,
AnnotationEnableStatus.disable,
expect.objectContaining({
embedding_model_name: 'model',
embedding_provider_name: 'provider',
}),
0.5,
)
expect(queryAnnotationJobStatusMock).toHaveBeenCalledWith(appDetail.id, AnnotationEnableStatus.disable, 'job-1')
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
message: 'common.api.actionSuccess',
type: 'success',
}))
})
})
it('should save annotation config changes and update the score when the modal confirms', async () => {
fetchAnnotationConfigMock.mockResolvedValue({
id: 'config-id',
enabled: false,
embedding_model: {
embedding_model_name: 'model',
embedding_provider_name: 'provider',
},
score_threshold: 0.5,
})
renderComponent()
const toggle = await screen.findByRole('switch')
fireEvent.click(toggle)
expect(screen.getByTestId('config-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('config-save'))
await waitFor(() => {
expect(updateAnnotationStatusMock).toHaveBeenCalledWith(
appDetail.id,
AnnotationEnableStatus.enable,
{
embedding_model_name: 'next-model',
embedding_provider_name: 'next-provider',
},
0.7,
)
expect(updateAnnotationScoreMock).toHaveBeenCalledWith(appDetail.id, 'config-id', 0.7)
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
message: 'common.api.actionSuccess',
type: 'success',
}))
})
})
it('should refresh the list from the header shortcut and allow saving or closing the view modal', async () => {
const annotation = createAnnotation()
fetchAnnotationListMock.mockResolvedValue({ data: [annotation], total: 1 })
renderComponent()
await screen.findByTestId('list')
fireEvent.click(screen.getByTestId('list-view'))
fireEvent.click(screen.getByTestId('view-modal-save'))
await waitFor(() => {
expect(editAnnotationMock).toHaveBeenCalledWith(appDetail.id, annotation.id, {
question: 'Edited question',
answer: 'Edited answer',
})
})
fireEvent.click(screen.getByTestId('view-modal-close'))
expect(screen.queryByTestId('view-modal')).not.toBeInTheDocument()
fireEvent.click(screen.getByTestId('trigger-added'))
expect(fetchAnnotationListMock).toHaveBeenCalled()
})
it('should clear selections on cancel and hide the config modal when requested', async () => {
const annotation = createAnnotation()
fetchAnnotationConfigMock.mockResolvedValue({
id: 'config-id',
enabled: true,
embedding_model: {
embedding_model_name: 'model',
embedding_provider_name: 'provider',
},
score_threshold: 0.5,
})
fetchAnnotationListMock.mockResolvedValue({ data: [annotation], total: 1 })
renderComponent()
await screen.findByTestId('list')
await act(async () => {
latestListProps.onSelectedIdsChange([annotation.id])
})
await act(async () => {
latestListProps.onCancel()
})
expect(latestListProps.selectedIds).toEqual([])
const configButton = document.querySelector('.action-btn') as HTMLButtonElement
fireEvent.click(configButton)
expect(await screen.findByTestId('config-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('config-hide'))
expect(screen.queryByTestId('config-modal')).not.toBeInTheDocument()
})
})

View File

@@ -1,7 +1,7 @@
import type { AnnotationItem } from './type'
import type { AnnotationItem } from '../type'
import { fireEvent, render, screen, within } from '@testing-library/react'
import * as React from 'react'
import List from './list'
import List from '../list'
const mockFormatTime = vi.fn(() => 'formatted-time')

View File

@@ -2,7 +2,7 @@ import type { Mock } from 'vitest'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { useProviderContext } from '@/context/provider-context'
import AddAnnotationModal from './index'
import AddAnnotationModal from '../index'
vi.mock('@/context/provider-context', () => ({
useProviderContext: vi.fn(),

View File

@@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import EditItem, { EditItemType } from './index'
import EditItem, { EditItemType } from '../index'
describe('AddAnnotationModal/EditItem', () => {
it('should render query inputs with user avatar and placeholder strings', () => {

View File

@@ -1,10 +1,11 @@
/* eslint-disable ts/no-explicit-any */
import type { Mock } from 'vitest'
import type { Locale } from '@/i18n-config'
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import { useLocale } from '@/context/i18n'
import { LanguagesSupported } from '@/i18n-config/language'
import CSVDownload from './csv-downloader'
import CSVDownload from '../csv-downloader'
const downloaderProps: any[] = []

View File

@@ -1,7 +1,7 @@
import type { Props } from './csv-uploader'
import type { Props } from '../csv-uploader'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import CSVUploader from './csv-uploader'
import CSVUploader from '../csv-uploader'
const toastMocks = vi.hoisted(() => ({
notify: vi.fn(),
@@ -75,6 +75,20 @@ describe('CSVUploader', () => {
expect(dropZone.className).not.toContain('border-components-dropzone-border-accent')
})
it('should handle drag over and clear dragging state when leaving through the overlay', () => {
renderComponent()
const { dropZone, dropContainer } = getDropElements()
fireEvent.dragEnter(dropContainer)
const dragLayer = dropContainer.querySelector('.absolute') as HTMLDivElement
fireEvent.dragOver(dropContainer)
fireEvent.dragLeave(dragLayer)
expect(dropZone.className).not.toContain('border-components-dropzone-border-accent')
expect(dropZone.className).not.toContain('bg-components-dropzone-bg-accent')
})
it('should ignore drop events without dataTransfer', () => {
renderComponent()
const { dropContainer } = getDropElements()

View File

@@ -1,10 +1,10 @@
import type { Mock } from 'vitest'
import type { IBatchModalProps } from './index'
import type { IBatchModalProps } from '../index'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { useProviderContext } from '@/context/provider-context'
import { annotationBatchImport, checkAnnotationBatchImportProgress } from '@/service/annotation'
import BatchModal, { ProcessStatus } from './index'
import BatchModal, { ProcessStatus } from '../index'
vi.mock('@/service/annotation', () => ({
annotationBatchImport: vi.fn(),
@@ -15,13 +15,13 @@ vi.mock('@/context/provider-context', () => ({
useProviderContext: vi.fn(),
}))
vi.mock('./csv-downloader', () => ({
vi.mock('../csv-downloader', () => ({
default: () => <div data-testid="csv-downloader-stub" />,
}))
let lastUploadedFile: File | undefined
vi.mock('./csv-uploader', () => ({
vi.mock('../csv-uploader', () => ({
default: ({ file, updateFile }: { file?: File, updateFile: (file?: File) => void }) => (
<div>
<button

View File

@@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import ClearAllAnnotationsConfirmModal from './index'
import ClearAllAnnotationsConfirmModal from '../index'
vi.mock('react-i18next', () => ({
useTranslation: () => ({

View File

@@ -1,7 +1,7 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { toast } from '@/app/components/base/ui/toast'
import EditAnnotationModal from './index'
import EditAnnotationModal from '../index'
const { mockAddAnnotation, mockEditAnnotation } = vi.hoisted(() => ({
mockAddAnnotation: vi.fn(),
@@ -51,11 +51,6 @@ describe('EditAnnotationModal', () => {
onRemove: vi.fn(),
}
afterAll(() => {
toastSuccessSpy.mockRestore()
toastErrorSpy.mockRestore()
})
beforeEach(() => {
vi.clearAllMocks()
mockAddAnnotation.mockResolvedValue({
@@ -65,6 +60,11 @@ describe('EditAnnotationModal', () => {
mockEditAnnotation.mockResolvedValue({})
})
afterAll(() => {
toastSuccessSpy.mockRestore()
toastErrorSpy.mockRestore()
})
// Rendering tests (REQUIRED)
describe('Rendering', () => {
it('should render modal when isShow is true', () => {

View File

@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import EditItem, { EditItemType, EditTitle } from './index'
import EditItem, { EditItemType, EditTitle } from '../index'
describe('EditTitle', () => {
it('should render title content correctly', () => {

View File

@@ -1,6 +1,7 @@
/* eslint-disable ts/no-explicit-any */
import type { ComponentProps } from 'react'
import type { Mock } from 'vitest'
import type { AnnotationItemBasic } from '../type'
import type { AnnotationItemBasic } from '../../type'
import type { Locale } from '@/i18n-config'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
@@ -8,7 +9,7 @@ import * as React from 'react'
import { useLocale } from '@/context/i18n'
import { LanguagesSupported } from '@/i18n-config/language'
import { clearAllAnnotations, fetchExportAnnotationList } from '@/service/annotation'
import HeaderOptions from './index'
import HeaderOptions from '../index'
vi.mock('@headlessui/react', () => {
type PopoverContextValue = { open: boolean, setOpen: (open: boolean) => void }

View File

@@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import RemoveAnnotationConfirmModal from './index'
import RemoveAnnotationConfirmModal from '../index'
vi.mock('react-i18next', () => ({
useTranslation: () => ({

View File

@@ -1,9 +1,9 @@
import type { Mock } from 'vitest'
import type { AnnotationItem, HitHistoryItem } from '../type'
import type { AnnotationItem, HitHistoryItem } from '../../type'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { fetchHitHistoryList } from '@/service/annotation'
import ViewAnnotationModal from './index'
import ViewAnnotationModal from '../index'
const mockFormatTime = vi.fn(() => 'formatted-time')
@@ -17,7 +17,7 @@ vi.mock('@/service/annotation', () => ({
fetchHitHistoryList: vi.fn(),
}))
vi.mock('../edit-annotation-modal/edit-item', () => {
vi.mock('../../edit-annotation-modal/edit-item', () => {
const EditItemType = {
Query: 'query',
Answer: 'answer',

View File

@@ -1,3 +1,4 @@
/* eslint-disable ts/no-explicit-any */
import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control'
import type { App } from '@/types/app'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
@@ -5,11 +6,11 @@ import userEvent from '@testing-library/user-event'
import { toast } from '@/app/components/base/ui/toast'
import useAccessControlStore from '@/context/access-control-store'
import { AccessMode, SubjectType } from '@/models/access-control'
import AccessControlDialog from './access-control-dialog'
import AccessControlItem from './access-control-item'
import AddMemberOrGroupDialog from './add-member-or-group-pop'
import AccessControl from './index'
import SpecificGroupsOrMembers from './specific-groups-or-members'
import AccessControlDialog from '../access-control-dialog'
import AccessControlItem from '../access-control-item'
import AddMemberOrGroupDialog from '../add-member-or-group-pop'
import AccessControl from '../index'
import SpecificGroupsOrMembers from '../specific-groups-or-members'
const mockUseAppWhiteListSubjects = vi.fn()
const mockUseSearchForWhiteListCandidates = vi.fn()
@@ -18,6 +19,9 @@ const mockUseUpdateAccessMode = vi.fn(() => ({
isPending: false,
mutateAsync: mockMutateAsync,
}))
const intersectionObserverMocks = vi.hoisted(() => ({
callback: null as null | ((entries: Array<{ isIntersecting: boolean }>) => void),
}))
vi.mock('@/context/app-context', () => ({
useSelector: <T,>(selector: (value: { userProfile: { email: string, id?: string, name?: string, avatar?: string, avatar_url?: string, is_password_set?: boolean } }) => T) => selector({
@@ -105,6 +109,10 @@ const memberSubject: Subject = {
beforeAll(() => {
class MockIntersectionObserver {
constructor(callback: (entries: Array<{ isIntersecting: boolean }>) => void) {
intersectionObserverMocks.callback = callback
}
observe = vi.fn(() => undefined)
disconnect = vi.fn(() => undefined)
unobserve = vi.fn(() => undefined)
@@ -281,6 +289,39 @@ describe('AddMemberOrGroupDialog', () => {
expect(useAccessControlStore.getState().specificMembers).toEqual([baseMember])
})
it('should update the keyword, fetch the next page, and support deselection and breadcrumb reset', async () => {
const fetchNextPage = vi.fn()
mockUseSearchForWhiteListCandidates.mockReturnValue({
isLoading: false,
isFetchingNextPage: true,
fetchNextPage,
data: { pages: [{ currPage: 1, subjects: [groupSubject, memberSubject], hasMore: true }] },
})
const user = userEvent.setup()
render(<AddMemberOrGroupDialog />)
await user.click(screen.getByText('common.operation.add'))
await user.type(screen.getByPlaceholderText('app.accessControlDialog.operateGroupAndMember.searchPlaceholder'), 'Group')
expect(document.querySelector('.spin-animation')).toBeInTheDocument()
const groupCheckbox = screen.getByText(baseGroup.name).closest('div')?.previousElementSibling as HTMLElement
fireEvent.click(groupCheckbox)
fireEvent.click(groupCheckbox)
const memberCheckbox = screen.getByText(baseMember.name).parentElement?.previousElementSibling as HTMLElement
fireEvent.click(memberCheckbox)
fireEvent.click(memberCheckbox)
fireEvent.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.expand'))
fireEvent.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.allMembers'))
expect(useAccessControlStore.getState().specificGroups).toEqual([])
expect(useAccessControlStore.getState().specificMembers).toEqual([])
expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([])
expect(fetchNextPage).not.toHaveBeenCalled()
})
it('should show empty state when no candidates are returned', async () => {
mockUseSearchForWhiteListCandidates.mockReturnValue({
isLoading: false,

View File

@@ -0,0 +1,140 @@
/* eslint-disable ts/no-explicit-any */
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import FeaturesWrappedAppPublisher from '../features-wrapper'
const mockSetFeatures = vi.fn()
const mockOnPublish = vi.fn()
const mockAppPublisherProps = vi.hoisted(() => ({
current: null as null | Record<string, any>,
}))
const mockFeatures = {
moreLikeThis: { enabled: false },
opening: { enabled: false, opening_statement: '', suggested_questions: [] as string[] },
moderation: { enabled: false },
speech2text: { enabled: false },
text2speech: { enabled: false },
suggested: { enabled: false },
citation: { enabled: false },
annotationReply: { enabled: false },
file: {
image: {
detail: 'high',
enabled: false,
number_limits: 3,
transfer_methods: ['local_file', 'remote_url'],
},
enabled: false,
allowed_file_types: ['image'],
allowed_file_extensions: ['.png'],
allowed_file_upload_methods: ['local_file'],
number_limits: 3,
},
}
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/app/components/app/app-publisher', () => ({
default: (props: Record<string, any>) => {
mockAppPublisherProps.current = props
return (
<div>
<button onClick={() => props.onPublish?.({ id: 'model-1' })}>publish-through-wrapper</button>
<button onClick={() => props.onRestore?.()}>restore-through-wrapper</button>
</div>
)
},
}))
vi.mock('@/app/components/base/features/hooks', () => ({
useFeatures: (selector: (state: { features: typeof mockFeatures }) => unknown) => selector({ features: mockFeatures }),
useFeaturesStore: () => ({
getState: () => ({
features: mockFeatures,
setFeatures: mockSetFeatures,
}),
}),
}))
describe('FeaturesWrappedAppPublisher', () => {
const publishedConfig = {
modelConfig: {
more_like_this: { enabled: true },
opening_statement: 'Hello there',
suggested_questions: ['Q1'],
sensitive_word_avoidance: { enabled: true },
speech_to_text: { enabled: true },
text_to_speech: { enabled: true },
suggested_questions_after_answer: { enabled: true },
retriever_resource: { enabled: true },
annotation_reply: { enabled: true },
file_upload: {
enabled: true,
image: {
enabled: true,
detail: 'low',
number_limits: 5,
transfer_methods: ['remote_url'],
},
allowed_file_types: ['image'],
allowed_file_extensions: ['.jpg'],
allowed_file_upload_methods: ['remote_url'],
number_limits: 5,
},
resetAppConfig: vi.fn(),
},
}
beforeEach(() => {
vi.clearAllMocks()
mockAppPublisherProps.current = null
})
it('should pass current features through to onPublish', async () => {
render(
<FeaturesWrappedAppPublisher
publishedConfig={publishedConfig as any}
onPublish={mockOnPublish}
/>,
)
fireEvent.click(screen.getByText('publish-through-wrapper'))
await waitFor(() => {
expect(mockOnPublish).toHaveBeenCalledWith({ id: 'model-1' }, mockFeatures)
})
})
it('should restore published features after confirmation', async () => {
render(
<FeaturesWrappedAppPublisher
publishedConfig={publishedConfig as any}
/>,
)
fireEvent.click(screen.getByText('restore-through-wrapper'))
fireEvent.click(screen.getByRole('button', { name: 'operation.confirm' }))
await waitFor(() => {
expect(publishedConfig.modelConfig.resetAppConfig).toHaveBeenCalledTimes(1)
expect(mockSetFeatures).toHaveBeenCalledWith(expect.objectContaining({
moreLikeThis: { enabled: true },
opening: {
enabled: true,
opening_statement: 'Hello there',
suggested_questions: ['Q1'],
},
moderation: { enabled: true },
speech2text: { enabled: true },
text2speech: { enabled: true },
suggested: { enabled: true },
citation: { enabled: true },
annotationReply: { enabled: true },
}))
})
})
})

View File

@@ -0,0 +1,520 @@
/* eslint-disable ts/no-explicit-any */
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { AccessMode } from '@/models/access-control'
import { AppModeEnum, AppTypeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
import AppPublisher from '../index'
const mockOnPublish = vi.fn()
const mockOnToggle = vi.fn()
const mockSetAppDetail = vi.fn()
const mockTrackEvent = vi.fn()
const mockRefetch = vi.fn()
const mockOpenAsyncWindow = vi.fn()
const mockFetchInstalledAppList = vi.fn()
const mockFetchAppDetailDirect = vi.fn()
const mockToastError = vi.fn()
const mockConvertWorkflowType = vi.fn()
const sectionProps = vi.hoisted(() => ({
summary: null as null | Record<string, any>,
access: null as null | Record<string, any>,
actions: null as null | Record<string, any>,
}))
const ahooksMocks = vi.hoisted(() => ({
keyPressHandlers: [] as Array<(event: { preventDefault: () => void }) => void>,
}))
let mockAppDetail: Record<string, any> | null = null
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('ahooks', async () => {
return {
useKeyPress: (_keys: unknown, handler: (event: { preventDefault: () => void }) => void) => {
ahooksMocks.keyPressHandlers.push(handler)
},
}
})
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: { appDetail: Record<string, any> | null, setAppDetail: typeof mockSetAppDetail }) => unknown) => selector({
appDetail: mockAppDetail,
setAppDetail: mockSetAppDetail,
}),
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: (selector: (state: { systemFeatures: { webapp_auth: { enabled: boolean } } }) => unknown) => selector({
systemFeatures: {
webapp_auth: {
enabled: true,
},
},
}),
}))
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: () => 'moments ago',
}),
}))
vi.mock('@/hooks/use-async-window-open', () => ({
useAsyncWindowOpen: () => mockOpenAsyncWindow,
}))
vi.mock('@/service/access-control', () => ({
useGetUserCanAccessApp: () => ({
data: { result: true },
isLoading: false,
refetch: mockRefetch,
}),
useAppWhiteListSubjects: () => ({
data: { groups: [], members: [] },
isLoading: false,
}),
}))
vi.mock('@/service/explore', () => ({
fetchInstalledAppList: (...args: unknown[]) => mockFetchInstalledAppList(...args),
}))
vi.mock('@/service/apps', () => ({
fetchAppDetailDirect: (...args: unknown[]) => mockFetchAppDetailDirect(...args),
}))
vi.mock('@/service/use-apps', () => ({
useConvertWorkflowTypeMutation: () => ({
mutateAsync: (...args: unknown[]) => mockConvertWorkflowType(...args),
isPending: false,
}),
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
error: (...args: unknown[]) => mockToastError(...args),
},
}))
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
}))
vi.mock('@/app/components/app/overview/embedded', () => ({
default: ({ isShow, onClose }: { isShow: boolean, onClose: () => void }) => (isShow
? (
<div data-testid="embedded-modal">
embedded modal
<button onClick={onClose}>close-embedded-modal</button>
</div>
)
: null),
}))
vi.mock('../../app-access-control', () => ({
default: ({ onConfirm, onClose }: { onConfirm: () => Promise<void>, onClose: () => void }) => (
<div data-testid="access-control">
<button onClick={() => void onConfirm()}>confirm-access-control</button>
<button onClick={onClose}>close-access-control</button>
</div>
),
}))
vi.mock('@/app/components/base/portal-to-follow-elem', async () => {
const ReactModule = await vi.importActual<typeof import('react')>('react')
const OpenContext = ReactModule.createContext(false)
return {
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
<OpenContext value={open}>
<div>{children}</div>
</OpenContext>
),
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => (
<div onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => {
const open = ReactModule.use(OpenContext)
return open ? <div>{children}</div> : null
},
}
})
vi.mock('../sections', () => ({
PublisherSummarySection: (props: Record<string, any>) => {
sectionProps.summary = props
return (
<div>
<button onClick={() => void props.handlePublish()}>publisher-summary-publish</button>
<button onClick={() => void props.handleRestore()}>publisher-summary-restore</button>
<button onClick={() => void props.onWorkflowTypeSwitch()}>publisher-switch-workflow-type</button>
</div>
)
},
PublisherAccessSection: (props: Record<string, any>) => {
sectionProps.access = props
return <button onClick={props.onClick}>publisher-access-control</button>
},
PublisherActionsSection: (props: Record<string, any>) => {
sectionProps.actions = props
return (
<div>
<button onClick={props.handleEmbed}>publisher-embed</button>
<button onClick={() => void props.handleOpenInExplore()}>publisher-open-in-explore</button>
</div>
)
},
}))
describe('AppPublisher', () => {
beforeEach(() => {
vi.clearAllMocks()
ahooksMocks.keyPressHandlers.length = 0
sectionProps.summary = null
sectionProps.access = null
sectionProps.actions = null
mockAppDetail = {
id: 'app-1',
name: 'Demo App',
mode: AppModeEnum.CHAT,
access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
type: AppTypeEnum.WORKFLOW,
site: {
app_base_url: 'https://example.com',
access_token: 'token-1',
},
}
mockFetchInstalledAppList.mockResolvedValue({
installed_apps: [{ id: 'installed-1' }],
})
mockFetchAppDetailDirect.mockResolvedValue({
id: 'app-1',
access_mode: AccessMode.PUBLIC,
})
mockConvertWorkflowType.mockResolvedValue({})
mockOpenAsyncWindow.mockImplementation(async (resolver: () => Promise<string>) => {
await resolver()
})
})
it('should open the publish popover and refetch access permission data', async () => {
render(
<AppPublisher
publishedAt={Date.now()}
onToggle={mockOnToggle}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
expect(screen.getByText('publisher-summary-publish')).toBeInTheDocument()
expect(mockOnToggle).toHaveBeenCalledWith(true)
await waitFor(() => {
expect(mockRefetch).toHaveBeenCalledTimes(1)
})
})
it('should publish and track the publish event', async () => {
mockOnPublish.mockResolvedValue(undefined)
render(
<AppPublisher
publishedAt={Date.now()}
onPublish={mockOnPublish}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-summary-publish'))
await waitFor(() => {
expect(mockOnPublish).toHaveBeenCalledTimes(1)
expect(mockTrackEvent).toHaveBeenCalledWith('app_published_time', expect.objectContaining({
action_mode: 'app',
app_id: 'app-1',
app_name: 'Demo App',
}))
})
})
it('should open the embedded modal from the actions section', () => {
render(
<AppPublisher
publishedAt={Date.now()}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-embed'))
expect(screen.getByTestId('embedded-modal')).toBeInTheDocument()
})
it('should close embedded and access control panels through child callbacks', async () => {
render(
<AppPublisher
publishedAt={Date.now()}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-embed'))
fireEvent.click(screen.getByText('close-embedded-modal'))
expect(screen.queryByTestId('embedded-modal')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-access-control'))
expect(screen.getByTestId('access-control')).toBeInTheDocument()
fireEvent.click(screen.getByText('close-access-control'))
expect(screen.queryByTestId('access-control')).not.toBeInTheDocument()
})
it('should refresh app detail after access control confirmation', async () => {
render(
<AppPublisher
publishedAt={Date.now()}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-access-control'))
expect(screen.getByTestId('access-control')).toBeInTheDocument()
fireEvent.click(screen.getByText('confirm-access-control'))
await waitFor(() => {
expect(mockFetchAppDetailDirect).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' })
expect(mockSetAppDetail).toHaveBeenCalledWith({
id: 'app-1',
access_mode: AccessMode.PUBLIC,
})
})
})
it('should open the installed explore page through the async window helper', async () => {
render(
<AppPublisher
publishedAt={Date.now()}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-open-in-explore'))
await waitFor(() => {
expect(mockOpenAsyncWindow).toHaveBeenCalledTimes(1)
expect(mockFetchInstalledAppList).toHaveBeenCalledWith('app-1')
expect(sectionProps.actions?.appURL).toBe(`https://example.com${basePath}/chat/token-1`)
})
})
it('should ignore the trigger when the publish button is disabled', () => {
render(
<AppPublisher
disabled
publishedAt={Date.now()}
onToggle={mockOnToggle}
/>,
)
fireEvent.click(screen.getByText('common.publish').parentElement?.parentElement as HTMLElement)
expect(screen.queryByText('publisher-summary-publish')).not.toBeInTheDocument()
expect(mockOnToggle).not.toHaveBeenCalled()
})
it('should publish from the keyboard shortcut and restore the popover state', async () => {
const preventDefault = vi.fn()
const onRestore = vi.fn().mockResolvedValue(undefined)
mockOnPublish.mockResolvedValue(undefined)
render(
<AppPublisher
publishedAt={Date.now()}
onPublish={mockOnPublish}
onRestore={onRestore}
/>,
)
ahooksMocks.keyPressHandlers[0]({ preventDefault })
await waitFor(() => {
expect(preventDefault).toHaveBeenCalled()
expect(mockOnPublish).toHaveBeenCalledTimes(1)
})
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-summary-restore'))
await waitFor(() => {
expect(onRestore).toHaveBeenCalledTimes(1)
})
expect(screen.queryByText('publisher-summary-publish')).not.toBeInTheDocument()
})
it('should keep the popover open when restore fails and reset published state after publish failures', async () => {
const preventDefault = vi.fn()
const onRestore = vi.fn().mockRejectedValue(new Error('restore failed'))
mockOnPublish.mockRejectedValueOnce(new Error('publish failed'))
render(
<AppPublisher
publishedAt={Date.now()}
onPublish={mockOnPublish}
onRestore={onRestore}
/>,
)
ahooksMocks.keyPressHandlers[0]({ preventDefault })
await waitFor(() => {
expect(preventDefault).toHaveBeenCalled()
expect(mockOnPublish).toHaveBeenCalledTimes(1)
})
expect(mockTrackEvent).not.toHaveBeenCalled()
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-summary-restore'))
await waitFor(() => {
expect(onRestore).toHaveBeenCalledTimes(1)
})
expect(screen.getByText('publisher-summary-publish')).toBeInTheDocument()
})
it('should report missing explore installations', async () => {
mockFetchInstalledAppList.mockResolvedValueOnce({
installed_apps: [],
})
mockOpenAsyncWindow.mockImplementation(async (resolver: () => Promise<string>, options: { onError: (error: Error) => void }) => {
try {
await resolver()
}
catch (error) {
options.onError(error as Error)
}
})
render(
<AppPublisher
publishedAt={Date.now()}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-open-in-explore'))
await waitFor(() => {
expect(mockToastError).toHaveBeenCalledWith('No app found in Explore')
})
})
it('should report explore errors when the app cannot be opened', async () => {
mockAppDetail = {
...mockAppDetail,
id: undefined,
}
mockOpenAsyncWindow.mockImplementation(async (resolver: () => Promise<string>, options: { onError: (error: Error) => void }) => {
try {
await resolver()
}
catch (error) {
options.onError(error as Error)
}
})
render(
<AppPublisher
publishedAt={Date.now()}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-open-in-explore'))
await waitFor(() => {
expect(mockToastError).toHaveBeenCalledWith('App not found')
})
})
it('should keep access control open when app detail is unavailable during confirmation', async () => {
mockAppDetail = null
render(
<AppPublisher
publishedAt={Date.now()}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-access-control'))
fireEvent.click(screen.getByText('confirm-access-control'))
await waitFor(() => {
expect(mockFetchAppDetailDirect).not.toHaveBeenCalled()
})
expect(screen.getByTestId('access-control')).toBeInTheDocument()
})
it('should switch workflow type, refresh app detail, and close the popover for published apps', async () => {
mockFetchAppDetailDirect.mockResolvedValueOnce({
id: 'app-1',
type: AppTypeEnum.EVALUATION,
})
render(
<AppPublisher
publishedAt={Date.now()}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-switch-workflow-type'))
await waitFor(() => {
expect(mockConvertWorkflowType).toHaveBeenCalledWith({
params: { appId: 'app-1' },
query: { target_type: AppTypeEnum.EVALUATION },
})
expect(mockFetchAppDetailDirect).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' })
expect(mockSetAppDetail).toHaveBeenCalledWith({
id: 'app-1',
type: AppTypeEnum.EVALUATION,
})
})
expect(screen.queryByText('publisher-summary-publish')).not.toBeInTheDocument()
})
it('should hide access and actions sections for evaluation workflow apps', () => {
mockAppDetail = {
...mockAppDetail,
type: AppTypeEnum.EVALUATION,
}
render(
<AppPublisher
publishedAt={Date.now()}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
expect(screen.getByText('publisher-summary-publish')).toBeInTheDocument()
expect(screen.queryByText('publisher-access-control')).not.toBeInTheDocument()
expect(screen.queryByText('publisher-embed')).not.toBeInTheDocument()
expect(sectionProps.summary?.workflowTypeSwitchConfig).toEqual({
targetType: AppTypeEnum.WORKFLOW,
publishLabelKey: 'common.publishAsStandardWorkflow',
switchLabelKey: 'common.switchToStandardWorkflow',
tipKey: 'common.switchToStandardWorkflowTip',
})
})
})

View File

@@ -0,0 +1,110 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import PublishWithMultipleModel from '../publish-with-multiple-model'
const mockUseProviderContext = vi.fn()
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => mockUseProviderContext(),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useLanguage: () => 'en_US',
}))
vi.mock('../../header/account-setting/model-provider-page/model-icon', () => ({
default: ({ modelName }: { modelName: string }) => <span data-testid="model-icon">{modelName}</span>,
}))
vi.mock('@/app/components/base/portal-to-follow-elem', async () => {
const ReactModule = await vi.importActual<typeof import('react')>('react')
const OpenContext = ReactModule.createContext(false)
return {
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
<OpenContext.Provider value={open}>
<div data-testid="portal-root">{children}</div>
</OpenContext.Provider>
),
PortalToFollowElemTrigger: ({ children, onClick, className }: { children: React.ReactNode, onClick?: () => void, className?: string }) => (
<div className={className} onClick={onClick}>
{children}
</div>
),
PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className?: string }) => {
const open = ReactModule.useContext(OpenContext)
return open ? <div className={className}>{children}</div> : null
},
}
})
describe('PublishWithMultipleModel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseProviderContext.mockReturnValue({
textGenerationModelList: [
{
provider: 'openai',
models: [
{
model: 'gpt-4o',
label: {
en_US: 'GPT-4o',
},
},
],
},
],
})
})
it('should disable the trigger when no valid model configuration is available', () => {
render(
<PublishWithMultipleModel
multipleModelConfigs={[
{
id: 'config-1',
provider: 'anthropic',
model: 'claude-3',
parameters: {},
},
]}
onSelect={vi.fn()}
/>,
)
expect(screen.getByRole('button', { name: 'operation.applyConfig' })).toBeDisabled()
expect(screen.queryByText('publishAs')).not.toBeInTheDocument()
})
it('should open matching model options and call onSelect', () => {
const handleSelect = vi.fn()
const modelConfig = {
id: 'config-1',
provider: 'openai',
model: 'gpt-4o',
parameters: { temperature: 0.7 },
}
render(
<PublishWithMultipleModel
multipleModelConfigs={[modelConfig]}
onSelect={handleSelect}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.applyConfig' }))
expect(screen.getByText('publishAs')).toBeInTheDocument()
fireEvent.click(screen.getByText('GPT-4o'))
expect(handleSelect).toHaveBeenCalledWith(expect.objectContaining(modelConfig))
})
})

View File

@@ -0,0 +1,308 @@
/* eslint-disable ts/no-explicit-any */
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { AccessMode } from '@/models/access-control'
import { AppModeEnum } from '@/types/app'
import { AccessModeDisplay, PublisherAccessSection, PublisherActionsSection, PublisherSummarySection } from '../sections'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('../publish-with-multiple-model', () => ({
default: ({ onSelect }: { onSelect: (item: Record<string, unknown>) => void }) => (
<button type="button" onClick={() => onSelect({ model: 'gpt-4o' })}>publish-multiple-model</button>
),
}))
vi.mock('../suggested-action', () => ({
default: ({ children, onClick, link, disabled }: { children: ReactNode, onClick?: () => void, link?: string, disabled?: boolean }) => (
<button type="button" data-link={link} disabled={disabled} onClick={onClick}>{children}</button>
),
}))
vi.mock('@/app/components/tools/workflow-tool/configure-button', () => ({
default: (props: Record<string, unknown>) => (
<div>
workflow-tool-configure
<span>{String(props.disabledReason || '')}</span>
</div>
),
}))
describe('app-publisher sections', () => {
it('should render restore controls for published chat apps', () => {
const handleRestore = vi.fn()
render(
<PublisherSummarySection
debugWithMultipleModel={false}
draftUpdatedAt={Date.now()}
formatTimeFromNow={() => '3 minutes ago'}
handlePublish={vi.fn()}
handleRestore={handleRestore}
isChatApp
multipleModelConfigs={[]}
onWorkflowTypeSwitch={vi.fn()}
publishDisabled={false}
published={false}
publishedAt={Date.now()}
publishShortcut={['ctrl', '⇧', 'P']}
startNodeLimitExceeded={false}
upgradeHighlightStyle={{}}
workflowTypeSwitchDisabled={false}
/>,
)
fireEvent.click(screen.getByText('common.restore'))
expect(handleRestore).toHaveBeenCalled()
})
it('should expose the access control warning when subjects are missing', () => {
render(
<PublisherAccessSection
enabled
isAppAccessSet={false}
isLoading={false}
accessMode={AccessMode.SPECIFIC_GROUPS_MEMBERS}
onClick={vi.fn()}
/>,
)
expect(screen.getByText('publishApp.notSet')).toBeInTheDocument()
expect(screen.getByText('publishApp.notSetDesc')).toBeInTheDocument()
})
it('should render the publish update action when the draft has not been published yet', () => {
render(
<PublisherSummarySection
debugWithMultipleModel={false}
draftUpdatedAt={Date.now()}
formatTimeFromNow={() => '1 minute ago'}
handlePublish={vi.fn()}
handleRestore={vi.fn()}
isChatApp={false}
multipleModelConfigs={[]}
onWorkflowTypeSwitch={vi.fn()}
publishDisabled={false}
published={false}
publishedAt={undefined}
publishShortcut={['ctrl', '⇧', 'P']}
startNodeLimitExceeded={false}
upgradeHighlightStyle={{}}
workflowTypeSwitchDisabled={false}
/>,
)
expect(screen.getByText('common.publishUpdate')).toBeInTheDocument()
})
it('should render multiple-model publishing', () => {
const handlePublish = vi.fn()
render(
<PublisherSummarySection
debugWithMultipleModel
draftUpdatedAt={Date.now()}
formatTimeFromNow={() => '1 minute ago'}
handlePublish={handlePublish}
handleRestore={vi.fn()}
isChatApp={false}
multipleModelConfigs={[{ id: '1' } as any]}
onWorkflowTypeSwitch={vi.fn()}
publishDisabled={false}
published={false}
publishedAt={undefined}
publishShortcut={['ctrl', '⇧', 'P']}
startNodeLimitExceeded={false}
upgradeHighlightStyle={{}}
workflowTypeSwitchDisabled={false}
/>,
)
fireEvent.click(screen.getByText('publish-multiple-model'))
expect(handlePublish).toHaveBeenCalledWith({ model: 'gpt-4o' })
})
it('should render the upgrade hint when the start node limit is exceeded', () => {
render(
<PublisherSummarySection
debugWithMultipleModel={false}
draftUpdatedAt={Date.now()}
formatTimeFromNow={() => '1 minute ago'}
handlePublish={vi.fn()}
handleRestore={vi.fn()}
isChatApp={false}
multipleModelConfigs={[]}
onWorkflowTypeSwitch={vi.fn()}
publishDisabled={false}
published={false}
publishedAt={undefined}
publishShortcut={['ctrl', '⇧', 'P']}
startNodeLimitExceeded
upgradeHighlightStyle={{}}
workflowTypeSwitchDisabled={false}
/>,
)
expect(screen.getByText('publishLimit.startNodeDesc')).toBeInTheDocument()
})
it('should render workflow type switch action and call switch handler', () => {
const onWorkflowTypeSwitch = vi.fn()
render(
<PublisherSummarySection
debugWithMultipleModel={false}
draftUpdatedAt={Date.now()}
formatTimeFromNow={() => '1 minute ago'}
handlePublish={vi.fn()}
handleRestore={vi.fn()}
isChatApp={false}
multipleModelConfigs={[]}
onWorkflowTypeSwitch={onWorkflowTypeSwitch}
publishDisabled={false}
published={false}
publishedAt={undefined}
publishShortcut={['ctrl', '⇧', 'P']}
startNodeLimitExceeded={false}
upgradeHighlightStyle={{}}
workflowTypeSwitchConfig={{
targetType: 'evaluation',
publishLabelKey: 'common.publishAsEvaluationWorkflow',
switchLabelKey: 'common.switchToEvaluationWorkflow',
tipKey: 'common.switchToEvaluationWorkflowTip',
}}
workflowTypeSwitchDisabled={false}
/>,
)
fireEvent.click(screen.getByText('common.publishAsEvaluationWorkflow'))
expect(onWorkflowTypeSwitch).toHaveBeenCalledTimes(1)
})
it('should render loading access state and access mode labels when enabled', () => {
const { rerender } = render(
<PublisherAccessSection
enabled
isAppAccessSet
isLoading
accessMode={AccessMode.PUBLIC}
onClick={vi.fn()}
/>,
)
expect(document.querySelector('.spin-animation')).toBeInTheDocument()
rerender(
<PublisherAccessSection
enabled
isAppAccessSet
isLoading={false}
accessMode={AccessMode.PUBLIC}
onClick={vi.fn()}
/>,
)
expect(screen.getByText('accessControlDialog.accessItems.anyone')).toBeInTheDocument()
expect(render(<AccessModeDisplay />).container).toBeEmptyDOMElement()
})
it('should render workflow actions, batch run links, and workflow tool configuration', () => {
const handleOpenInExplore = vi.fn()
const handleEmbed = vi.fn()
const { rerender } = render(
<PublisherActionsSection
appDetail={{
id: 'workflow-app',
mode: AppModeEnum.WORKFLOW,
icon: '⚙️',
icon_type: 'emoji',
icon_background: '#fff',
name: 'Workflow App',
description: 'Workflow description',
}}
appURL="https://example.com/app"
disabledFunctionButton={false}
disabledFunctionTooltip="disabled"
handleEmbed={handleEmbed}
handleOpenInExplore={handleOpenInExplore}
handlePublish={vi.fn()}
hasHumanInputNode={false}
hasTriggerNode={false}
inputs={[]}
missingStartNode={false}
onRefreshData={vi.fn()}
outputs={[]}
published={true}
publishedAt={Date.now()}
toolPublished
workflowToolAvailable={false}
workflowToolMessage="workflow-disabled"
/>,
)
expect(screen.getByText('common.batchRunApp')).toHaveAttribute('data-link', 'https://example.com/app?mode=batch')
fireEvent.click(screen.getByText('common.openInExplore'))
expect(handleOpenInExplore).toHaveBeenCalled()
expect(screen.getByText('workflow-tool-configure')).toBeInTheDocument()
expect(screen.getByText('workflow-disabled')).toBeInTheDocument()
rerender(
<PublisherActionsSection
appDetail={{
id: 'chat-app',
mode: AppModeEnum.CHAT,
name: 'Chat App',
}}
appURL="https://example.com/app?foo=bar"
disabledFunctionButton
disabledFunctionTooltip="disabled"
handleEmbed={handleEmbed}
handleOpenInExplore={handleOpenInExplore}
handlePublish={vi.fn()}
hasHumanInputNode={false}
hasTriggerNode={false}
inputs={[]}
missingStartNode
onRefreshData={vi.fn()}
outputs={[]}
published={false}
publishedAt={Date.now()}
toolPublished={false}
workflowToolAvailable
/>,
)
fireEvent.click(screen.getByText('common.embedIntoSite'))
expect(handleEmbed).toHaveBeenCalled()
expect(screen.getByText('common.accessAPIReference')).toBeDisabled()
rerender(
<PublisherActionsSection
appDetail={{ id: 'trigger-app', mode: AppModeEnum.WORKFLOW }}
appURL="https://example.com/app"
disabledFunctionButton={false}
handleEmbed={handleEmbed}
handleOpenInExplore={handleOpenInExplore}
handlePublish={vi.fn()}
hasHumanInputNode={false}
hasTriggerNode
inputs={[]}
missingStartNode={false}
outputs={[]}
published={false}
publishedAt={undefined}
toolPublished={false}
workflowToolAvailable
/>,
)
expect(screen.queryByText('common.runApp')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,49 @@
import type { MouseEvent as ReactMouseEvent } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import SuggestedAction from '../suggested-action'
describe('SuggestedAction', () => {
it('should render an enabled external link', () => {
render(
<SuggestedAction link="https://example.com/docs">
Open docs
</SuggestedAction>,
)
const link = screen.getByRole('link', { name: 'Open docs' })
expect(link).toHaveAttribute('href', 'https://example.com/docs')
expect(link).toHaveAttribute('target', '_blank')
})
it('should block clicks when disabled', () => {
const handleClick = vi.fn()
render(
<SuggestedAction link="https://example.com/docs" disabled onClick={handleClick}>
Disabled action
</SuggestedAction>,
)
const link = screen.getByText('Disabled action').closest('a') as HTMLAnchorElement
fireEvent.click(link)
expect(link).not.toHaveAttribute('href')
expect(handleClick).not.toHaveBeenCalled()
})
it('should forward click events when enabled', () => {
const handleClick = vi.fn((event: ReactMouseEvent<HTMLAnchorElement>) => {
event.preventDefault()
})
render(
<SuggestedAction link="https://example.com/docs" onClick={handleClick}>
Enabled action
</SuggestedAction>,
)
fireEvent.click(screen.getByRole('link', { name: 'Enabled action' }))
expect(handleClick).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,70 @@
import type { TFunction } from 'i18next'
import { AccessMode } from '@/models/access-control'
import { AppModeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
import {
getDisabledFunctionTooltip,
getPublisherAppMode,
getPublisherAppUrl,
isPublisherAccessConfigured,
} from '../utils'
describe('app-publisher utils', () => {
describe('getPublisherAppMode', () => {
it('should normalize chat-like apps to chat mode', () => {
expect(getPublisherAppMode(AppModeEnum.AGENT_CHAT)).toBe(AppModeEnum.CHAT)
})
it('should keep completion mode unchanged', () => {
expect(getPublisherAppMode(AppModeEnum.COMPLETION)).toBe(AppModeEnum.COMPLETION)
})
})
describe('getPublisherAppUrl', () => {
it('should build the published app url from site info', () => {
expect(getPublisherAppUrl({
appBaseUrl: 'https://example.com',
accessToken: 'token-1',
mode: AppModeEnum.CHAT,
})).toBe(`https://example.com${basePath}/chat/token-1`)
})
})
describe('isPublisherAccessConfigured', () => {
it('should require members or groups for specific access mode', () => {
expect(isPublisherAccessConfigured(
{ access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS },
{ groups: [], members: [] },
)).toBe(false)
})
it('should treat public access as configured', () => {
expect(isPublisherAccessConfigured(
{ access_mode: AccessMode.PUBLIC },
{ groups: [], members: [] },
)).toBe(true)
})
})
describe('getDisabledFunctionTooltip', () => {
const t = ((key: string) => key) as unknown as TFunction
it('should prioritize the unpublished hint', () => {
expect(getDisabledFunctionTooltip({
t,
publishedAt: undefined,
missingStartNode: false,
noAccessPermission: false,
})).toBe('notPublishedYet')
})
it('should return the access error when the app is published but blocked', () => {
expect(getDisabledFunctionTooltip({
t,
publishedAt: Date.now(),
missingStartNode: false,
noAccessPermission: true,
})).toBe('noAccessPermission')
})
})
})

View File

@@ -0,0 +1,128 @@
/* eslint-disable ts/no-explicit-any */
import { fireEvent, render, screen } from '@testing-library/react'
import { toast } from '@/app/components/base/ui/toast'
import VersionInfoModal from '../version-info-modal'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
error: vi.fn(),
},
}))
describe('VersionInfoModal', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should prefill the fields from the current version info', () => {
render(
<VersionInfoModal
isOpen
versionInfo={{
id: 'version-1',
marked_name: 'Release 1',
marked_comment: 'Initial release',
} as any}
onClose={vi.fn()}
onPublish={vi.fn()}
/>,
)
expect(screen.getByDisplayValue('Release 1')).toBeInTheDocument()
expect(screen.getByDisplayValue('Initial release')).toBeInTheDocument()
})
it('should reject overlong titles', () => {
const handlePublish = vi.fn()
render(
<VersionInfoModal
isOpen
onClose={vi.fn()}
onPublish={handlePublish}
/>,
)
const [titleInput] = screen.getAllByRole('textbox')
fireEvent.change(titleInput, { target: { value: 'a'.repeat(16) } })
fireEvent.click(screen.getByRole('button', { name: 'common.publish' }))
expect(toast.error).toHaveBeenCalledWith('versionHistory.editField.titleLengthLimit')
expect(handlePublish).not.toHaveBeenCalled()
})
it('should publish valid values and close the modal', () => {
const handlePublish = vi.fn()
const handleClose = vi.fn()
render(
<VersionInfoModal
isOpen
versionInfo={{
id: 'version-2',
marked_name: 'Old title',
marked_comment: 'Old notes',
} as any}
onClose={handleClose}
onPublish={handlePublish}
/>,
)
const [titleInput, notesInput] = screen.getAllByRole('textbox')
fireEvent.change(titleInput, { target: { value: 'Release 2' } })
fireEvent.change(notesInput, { target: { value: 'Updated notes' } })
fireEvent.click(screen.getByRole('button', { name: 'common.publish' }))
expect(handlePublish).toHaveBeenCalledWith({
title: 'Release 2',
releaseNotes: 'Updated notes',
id: 'version-2',
})
expect(handleClose).toHaveBeenCalledTimes(1)
})
it('should validate release note length and clear previous errors before publishing', () => {
const handlePublish = vi.fn()
const handleClose = vi.fn()
render(
<VersionInfoModal
isOpen
versionInfo={{
id: 'version-3',
marked_name: 'Old title',
marked_comment: 'Old notes',
} as any}
onClose={handleClose}
onPublish={handlePublish}
/>,
)
const [titleInput, notesInput] = screen.getAllByRole('textbox')
fireEvent.change(titleInput, { target: { value: 'a'.repeat(16) } })
fireEvent.click(screen.getByRole('button', { name: 'common.publish' }))
expect(toast.error).toHaveBeenCalledWith('versionHistory.editField.titleLengthLimit')
fireEvent.change(titleInput, { target: { value: 'Release 3' } })
fireEvent.change(notesInput, { target: { value: 'b'.repeat(101) } })
fireEvent.click(screen.getByRole('button', { name: 'common.publish' }))
expect(toast.error).toHaveBeenCalledWith('versionHistory.editField.releaseNotesLengthLimit')
fireEvent.change(notesInput, { target: { value: 'Stable release notes' } })
fireEvent.click(screen.getByRole('button', { name: 'common.publish' }))
expect(handlePublish).toHaveBeenCalledWith({
title: 'Release 3',
releaseNotes: 'Stable release notes',
id: 'version-3',
})
expect(handleClose).toHaveBeenCalledTimes(1)
})
})

View File

@@ -1,7 +1,7 @@
import type { ModelAndParameter } from '../configuration/debug/types'
import type { InputVar, Variable } from '@/app/components/workflow/types'
import type { I18nKeysByPrefix } from '@/types/i18n'
import type { PublishWorkflowParams } from '@/types/workflow'
import type { I18nKeysWithPrefix } from '@/types/i18n'
import type { PublishWorkflowParams, WorkflowTypeConversionTarget } from '@/types/workflow'
import { useKeyPress } from 'ahooks'
import {
memo,
@@ -15,15 +15,11 @@ import EmbeddedModal from '@/app/components/app/overview/embedded'
import { useStore as useAppStore } from '@/app/components/app/store'
import { trackEvent } from '@/app/components/base/amplitude'
import Button from '@/app/components/base/button'
import { CodeBrowser } from '@/app/components/base/icons/src/vender/line/development'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/configure-button'
import { appDefaultIconBackground } from '@/config'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
@@ -31,56 +27,22 @@ import { AccessMode } from '@/models/access-control'
import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control'
import { fetchAppDetailDirect } from '@/service/apps'
import { fetchInstalledAppList } from '@/service/explore'
import { AppModeEnum } from '@/types/app'
import { useConvertWorkflowTypeMutation } from '@/service/use-apps'
import { AppModeEnum, AppTypeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
import Divider from '../../base/divider'
import Loading from '../../base/loading'
import Tooltip from '../../base/tooltip'
import { toast } from '../../base/ui/toast'
import ShortcutsName from '../../workflow/shortcuts-name'
import { getKeyboardKeyCodeBySystem } from '../../workflow/utils'
import AccessControl from '../app-access-control'
import PublishWithMultipleModel from './publish-with-multiple-model'
import SuggestedAction from './suggested-action'
type AccessModeLabel = I18nKeysByPrefix<'app', 'accessControlDialog.accessItems.'>
const ACCESS_MODE_MAP: Record<AccessMode, { label: AccessModeLabel, icon: string }> = {
[AccessMode.ORGANIZATION]: {
label: 'organization',
icon: 'i-ri-building-line',
},
[AccessMode.SPECIFIC_GROUPS_MEMBERS]: {
label: 'specific',
icon: 'i-ri-lock-line',
},
[AccessMode.PUBLIC]: {
label: 'anyone',
icon: 'i-ri-global-line',
},
[AccessMode.EXTERNAL_MEMBERS]: {
label: 'external',
icon: 'i-ri-verified-badge-line',
},
}
const AccessModeDisplay: React.FC<{ mode?: AccessMode }> = ({ mode }) => {
const { t } = useTranslation()
if (!mode || !ACCESS_MODE_MAP[mode])
return null
const { icon, label } = ACCESS_MODE_MAP[mode]
return (
<>
<span className={`${icon} h-4 w-4 shrink-0 text-text-secondary`} />
<div className="grow truncate">
<span className="text-text-secondary system-sm-medium">{t(`accessControlDialog.accessItems.${label}`, { ns: 'app' })}</span>
</div>
</>
)
}
import {
PublisherAccessSection,
PublisherActionsSection,
PublisherSummarySection,
} from './sections'
import {
getDisabledFunctionTooltip,
getPublisherAppUrl,
isPublisherAccessConfigured,
} from './utils'
export type AppPublisherProps = {
disabled?: boolean
@@ -108,6 +70,32 @@ export type AppPublisherProps = {
const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P']
type WorkflowTypeSwitchLabelKey = I18nKeysWithPrefix<'workflow', 'common.'>
const WORKFLOW_TYPE_SWITCH_CONFIG: Record<WorkflowTypeConversionTarget, {
targetType: WorkflowTypeConversionTarget
publishLabelKey: WorkflowTypeSwitchLabelKey
switchLabelKey: WorkflowTypeSwitchLabelKey
tipKey: WorkflowTypeSwitchLabelKey
}> = {
workflow: {
targetType: 'evaluation',
publishLabelKey: 'common.publishAsEvaluationWorkflow',
switchLabelKey: 'common.switchToEvaluationWorkflow',
tipKey: 'common.switchToEvaluationWorkflowTip',
},
evaluation: {
targetType: 'workflow',
publishLabelKey: 'common.publishAsStandardWorkflow',
switchLabelKey: 'common.switchToStandardWorkflow',
tipKey: 'common.switchToStandardWorkflowTip',
},
} as const
const isWorkflowTypeConversionTarget = (type?: AppTypeEnum): type is WorkflowTypeConversionTarget => {
return type === 'workflow' || type === 'evaluation'
}
const AppPublisher = ({
disabled = false,
publishDisabled = false,
@@ -142,33 +130,34 @@ const AppPublisher = ({
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const { formatTimeFromNow } = useFormatTimeFromNow()
const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {}
const { mutateAsync: convertWorkflowType, isPending: isConvertingWorkflowType } = useConvertWorkflowTypeMutation()
const appMode = (appDetail?.mode !== AppModeEnum.COMPLETION && appDetail?.mode !== AppModeEnum.WORKFLOW) ? AppModeEnum.CHAT : appDetail.mode
const appURL = `${appBaseURL}${basePath}/${appMode}/${accessToken}`
const appURL = getPublisherAppUrl({ appBaseUrl: appBaseURL, accessToken, mode: appDetail?.mode })
const isChatApp = [AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.COMPLETION].includes(appDetail?.mode || AppModeEnum.CHAT)
const workflowTypeSwitchConfig = isWorkflowTypeConversionTarget(appDetail?.type)
? WORKFLOW_TYPE_SWITCH_CONFIG[appDetail.type]
: undefined
const isEvaluationWorkflowType = appDetail?.type === AppTypeEnum.EVALUATION
const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp, refetch } = useGetUserCanAccessApp({ appId: appDetail?.id, enabled: false })
const { data: appAccessSubjects, isLoading: isGettingAppWhiteListSubjects } = useAppWhiteListSubjects(appDetail?.id, open && systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS)
const openAsyncWindow = useAsyncWindowOpen()
const isAppAccessSet = useMemo(() => {
if (appDetail && appAccessSubjects) {
return !(appDetail.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && appAccessSubjects.groups?.length === 0 && appAccessSubjects.members?.length === 0)
}
return true
}, [appAccessSubjects, appDetail])
const isAppAccessSet = useMemo(() => isPublisherAccessConfigured(appDetail, appAccessSubjects), [appAccessSubjects, appDetail])
const noAccessPermission = useMemo(() => systemFeatures.webapp_auth.enabled && appDetail && appDetail.access_mode !== AccessMode.EXTERNAL_MEMBERS && !userCanAccessApp?.result, [systemFeatures, appDetail, userCanAccessApp])
const noAccessPermission = useMemo(() => Boolean(
systemFeatures.webapp_auth.enabled
&& appDetail
&& appDetail.access_mode !== AccessMode.EXTERNAL_MEMBERS
&& !userCanAccessApp?.result,
), [systemFeatures, appDetail, userCanAccessApp])
const disabledFunctionButton = useMemo(() => (!publishedAt || missingStartNode || noAccessPermission), [publishedAt, missingStartNode, noAccessPermission])
const disabledFunctionTooltip = useMemo(() => {
if (!publishedAt)
return t('notPublishedYet', { ns: 'app' })
if (missingStartNode)
return t('noUserInputNode', { ns: 'app' })
if (noAccessPermission)
return t('noAccessPermission', { ns: 'app' })
}, [missingStartNode, noAccessPermission, publishedAt, t])
const disabledFunctionTooltip = useMemo(() => getDisabledFunctionTooltip({
t,
publishedAt,
missingStartNode,
noAccessPermission,
}), [missingStartNode, noAccessPermission, publishedAt, t])
useEffect(() => {
if (systemFeatures.webapp_auth.enabled && open && appDetail)
@@ -236,6 +225,35 @@ const AppPublisher = ({
}
}, [appDetail, setAppDetail])
const handleWorkflowTypeSwitch = useCallback(async () => {
if (!appDetail?.id || !workflowTypeSwitchConfig)
return
try {
await convertWorkflowType({
params: {
appId: appDetail.id,
},
query: {
target_type: workflowTypeSwitchConfig.targetType,
},
})
if (!publishedAt)
await handlePublish()
const latestAppDetail = await fetchAppDetailDirect({
url: '/apps',
id: appDetail.id,
})
setAppDetail(latestAppDetail)
if (publishedAt)
setOpen(false)
}
catch { }
}, [appDetail?.id, convertWorkflowType, handlePublish, publishedAt, setAppDetail, workflowTypeSwitchConfig])
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => {
e.preventDefault()
if (publishDisabled || published)
@@ -244,9 +262,9 @@ const AppPublisher = ({
}, { exactMatch: true, useCapture: true })
const hasPublishedVersion = !!publishedAt
const workflowToolDisabled = !hasPublishedVersion || !workflowToolAvailable
const workflowToolMessage = workflowToolDisabled ? t('common.workflowAsToolDisabledHint', { ns: 'workflow' }) : undefined
const showStartNodeLimitHint = Boolean(startNodeLimitExceeded)
const workflowToolMessage = !hasPublishedVersion || !workflowToolAvailable
? t('common.workflowAsToolDisabledHint', { ns: 'workflow' })
: undefined
const upgradeHighlightStyle = useMemo(() => ({
background: 'linear-gradient(97deg, var(--components-input-border-active-prompt-1, rgba(11, 165, 236, 0.95)) -3.64%, var(--components-input-border-active-prompt-2, rgba(21, 90, 239, 0.95)) 45.14%)',
WebkitBackgroundClip: 'text',
@@ -268,7 +286,7 @@ const AppPublisher = ({
<PortalToFollowElemTrigger onClick={handleTrigger}>
<Button
variant="primary"
className="py-2 pl-3 pr-2"
className="py-2 pr-2 pl-3"
disabled={disabled}
>
{t('common.publish', { ns: 'workflow' })}
@@ -277,199 +295,58 @@ const AppPublisher = ({
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-11">
<div className="w-[320px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-5">
<div className="p-4 pt-3">
<div className="flex h-6 items-center text-text-tertiary system-xs-medium-uppercase">
{publishedAt ? t('common.latestPublished', { ns: 'workflow' }) : t('common.currentDraftUnpublished', { ns: 'workflow' })}
</div>
{publishedAt
? (
<div className="flex items-center justify-between">
<div className="flex items-center text-text-secondary system-sm-medium">
{t('common.publishedAt', { ns: 'workflow' })}
{' '}
{formatTimeFromNow(publishedAt)}
</div>
{isChatApp && (
<Button
variant="secondary-accent"
size="small"
onClick={handleRestore}
disabled={published}
>
{t('common.restore', { ns: 'workflow' })}
</Button>
)}
</div>
)
: (
<div className="flex items-center text-text-secondary system-sm-medium">
{t('common.autoSaved', { ns: 'workflow' })}
{' '}
·
{Boolean(draftUpdatedAt) && formatTimeFromNow(draftUpdatedAt!)}
</div>
)}
{debugWithMultipleModel
? (
<PublishWithMultipleModel
multipleModelConfigs={multipleModelConfigs}
onSelect={item => handlePublish(item)}
// textGenerationModelList={textGenerationModelList}
/>
)
: (
<>
<Button
variant="primary"
className="mt-3 w-full"
onClick={() => handlePublish()}
disabled={publishDisabled || published}
>
{
published
? t('common.published', { ns: 'workflow' })
: (
<div className="flex gap-1">
<span>{t('common.publishUpdate', { ns: 'workflow' })}</span>
<ShortcutsName keys={PUBLISH_SHORTCUT} bgColor="white" />
</div>
)
}
</Button>
{showStartNodeLimitHint && (
<div className="mt-3 flex flex-col items-stretch">
<p
className="text-sm font-semibold leading-5 text-transparent"
style={upgradeHighlightStyle}
>
<span className="block">{t('publishLimit.startNodeTitlePrefix', { ns: 'workflow' })}</span>
<span className="block">{t('publishLimit.startNodeTitleSuffix', { ns: 'workflow' })}</span>
</p>
<p className="mt-1 text-xs leading-4 text-text-secondary">
{t('publishLimit.startNodeDesc', { ns: 'workflow' })}
</p>
<UpgradeBtn
isShort
className="mb-[12px] mt-[9px] h-[32px] w-[93px] self-start"
/>
</div>
)}
</>
)}
</div>
{(systemFeatures.webapp_auth.enabled && (isGettingUserCanAccessApp || isGettingAppWhiteListSubjects))
? <div className="py-2"><Loading /></div>
: (
<>
<Divider className="my-0" />
{systemFeatures.webapp_auth.enabled && (
<div className="p-4 pt-3">
<div className="flex h-6 items-center">
<p className="text-text-tertiary system-xs-medium">{t('publishApp.title', { ns: 'app' })}</p>
</div>
<div
className="flex h-8 cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal py-1 pl-2.5 pr-2 hover:bg-primary-50 hover:text-text-accent"
onClick={() => {
setShowAppAccessControl(true)
}}
>
<div className="flex grow items-center gap-x-1.5 overflow-hidden pr-1">
<AccessModeDisplay mode={appDetail?.access_mode} />
</div>
{!isAppAccessSet && <p className="shrink-0 text-text-tertiary system-xs-regular">{t('publishApp.notSet', { ns: 'app' })}</p>}
<div className="flex h-4 w-4 shrink-0 items-center justify-center">
<span className="i-ri-arrow-right-s-line h-4 w-4 text-text-quaternary" />
</div>
</div>
{!isAppAccessSet && <p className="mt-1 text-text-warning system-xs-regular">{t('publishApp.notSetDesc', { ns: 'app' })}</p>}
</div>
)}
{
// Hide run/batch run app buttons when there is a trigger node.
!hasTriggerNode && (
<div className="flex flex-col gap-y-1 border-t-[0.5px] border-t-divider-regular p-4 pt-3">
<Tooltip triggerClassName="flex" disabled={!disabledFunctionButton} popupContent={disabledFunctionTooltip} asChild={false}>
<SuggestedAction
className="flex-1"
disabled={disabledFunctionButton}
link={appURL}
icon={<span className="i-ri-play-circle-line h-4 w-4" />}
>
{t('common.runApp', { ns: 'workflow' })}
</SuggestedAction>
</Tooltip>
{appDetail?.mode === AppModeEnum.WORKFLOW || appDetail?.mode === AppModeEnum.COMPLETION
? (
<Tooltip triggerClassName="flex" disabled={!disabledFunctionButton} popupContent={disabledFunctionTooltip} asChild={false}>
<SuggestedAction
className="flex-1"
disabled={disabledFunctionButton}
link={`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`}
icon={<span className="i-ri-play-list-2-line h-4 w-4" />}
>
{t('common.batchRunApp', { ns: 'workflow' })}
</SuggestedAction>
</Tooltip>
)
: (
<SuggestedAction
onClick={() => {
setEmbeddingModalOpen(true)
handleTrigger()
}}
disabled={!publishedAt}
icon={<CodeBrowser className="h-4 w-4" />}
>
{t('common.embedIntoSite', { ns: 'workflow' })}
</SuggestedAction>
)}
<Tooltip triggerClassName="flex" disabled={!disabledFunctionButton} popupContent={disabledFunctionTooltip} asChild={false}>
<SuggestedAction
className="flex-1"
onClick={() => {
if (publishedAt)
handleOpenInExplore()
}}
disabled={disabledFunctionButton}
icon={<span className="i-ri-planet-line h-4 w-4" />}
>
{t('common.openInExplore', { ns: 'workflow' })}
</SuggestedAction>
</Tooltip>
<Tooltip triggerClassName="flex" disabled={!!publishedAt && !missingStartNode} popupContent={!publishedAt ? t('notPublishedYet', { ns: 'app' }) : t('noUserInputNode', { ns: 'app' })} asChild={false}>
<SuggestedAction
className="flex-1"
disabled={!publishedAt || missingStartNode}
link="./develop"
icon={<span className="i-ri-terminal-box-line h-4 w-4" />}
>
{t('common.accessAPIReference', { ns: 'workflow' })}
</SuggestedAction>
</Tooltip>
{appDetail?.mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && (
<WorkflowToolConfigureButton
disabled={workflowToolDisabled}
published={!!toolPublished}
detailNeedUpdate={!!toolPublished && published}
workflowAppId={appDetail?.id}
icon={{
content: (appDetail.icon_type === 'image' ? '🤖' : appDetail?.icon) || '🤖',
background: (appDetail.icon_type === 'image' ? appDefaultIconBackground : appDetail?.icon_background) || appDefaultIconBackground,
}}
name={appDetail?.name}
description={appDetail?.description}
inputs={inputs}
outputs={outputs}
handlePublish={handlePublish}
onRefreshData={onRefreshData}
disabledReason={workflowToolMessage}
/>
)}
</div>
)
}
</>
)}
<PublisherSummarySection
debugWithMultipleModel={debugWithMultipleModel}
draftUpdatedAt={draftUpdatedAt}
formatTimeFromNow={formatTimeFromNow}
handlePublish={handlePublish}
handleRestore={handleRestore}
isChatApp={isChatApp}
multipleModelConfigs={multipleModelConfigs}
publishDisabled={publishDisabled}
published={published}
publishedAt={publishedAt}
publishShortcut={PUBLISH_SHORTCUT}
startNodeLimitExceeded={startNodeLimitExceeded}
upgradeHighlightStyle={upgradeHighlightStyle}
workflowTypeSwitchConfig={workflowTypeSwitchConfig}
workflowTypeSwitchDisabled={publishDisabled || published || isConvertingWorkflowType}
onWorkflowTypeSwitch={handleWorkflowTypeSwitch}
/>
{!isEvaluationWorkflowType && (
<>
<PublisherAccessSection
enabled={systemFeatures.webapp_auth.enabled}
isAppAccessSet={isAppAccessSet}
isLoading={Boolean(systemFeatures.webapp_auth.enabled && (isGettingUserCanAccessApp || isGettingAppWhiteListSubjects))}
accessMode={appDetail?.access_mode}
onClick={() => setShowAppAccessControl(true)}
/>
<PublisherActionsSection
appDetail={appDetail}
appURL={appURL}
disabledFunctionButton={disabledFunctionButton}
disabledFunctionTooltip={disabledFunctionTooltip}
handleEmbed={() => {
setEmbeddingModalOpen(true)
handleTrigger()
}}
handleOpenInExplore={handleOpenInExplore}
handlePublish={handlePublish}
hasHumanInputNode={hasHumanInputNode}
hasTriggerNode={hasTriggerNode}
inputs={inputs}
missingStartNode={missingStartNode}
onRefreshData={onRefreshData}
outputs={outputs}
published={published}
publishedAt={publishedAt}
toolPublished={toolPublished}
workflowToolAvailable={workflowToolAvailable}
workflowToolMessage={workflowToolMessage}
/>
</>
)}
</div>
</PortalToFollowElemContent>
<EmbeddedModal

View File

@@ -0,0 +1,412 @@
import type { CSSProperties, ReactNode } from 'react'
import type { ModelAndParameter } from '../configuration/debug/types'
import type { AppPublisherProps } from './index'
import type { I18nKeysWithPrefix } from '@/types/i18n'
import type { PublishWorkflowParams, WorkflowTypeConversionTarget } from '@/types/workflow'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Divider from '@/app/components/base/divider'
import Loading from '@/app/components/base/loading'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/app/components/base/ui/tooltip'
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/configure-button'
import { appDefaultIconBackground } from '@/config'
import { AppModeEnum } from '@/types/app'
import ShortcutsName from '../../workflow/shortcuts-name'
import PublishWithMultipleModel from './publish-with-multiple-model'
import SuggestedAction from './suggested-action'
import { ACCESS_MODE_MAP } from './utils'
type WorkflowTypeSwitchLabelKey = I18nKeysWithPrefix<'workflow', 'common.'>
type SummarySectionProps = Pick<AppPublisherProps, | 'debugWithMultipleModel'
| 'draftUpdatedAt'
| 'multipleModelConfigs'
| 'publishDisabled'
| 'publishedAt'
| 'startNodeLimitExceeded'> & {
formatTimeFromNow: (value: number) => string
handlePublish: (params?: ModelAndParameter | PublishWorkflowParams) => Promise<void>
handleRestore: () => Promise<void>
isChatApp: boolean
onWorkflowTypeSwitch: () => Promise<void>
published: boolean
publishShortcut: string[]
upgradeHighlightStyle: CSSProperties
workflowTypeSwitchConfig?: {
targetType: WorkflowTypeConversionTarget
publishLabelKey: WorkflowTypeSwitchLabelKey
switchLabelKey: WorkflowTypeSwitchLabelKey
tipKey: WorkflowTypeSwitchLabelKey
}
workflowTypeSwitchDisabled: boolean
}
type AccessSectionProps = {
enabled: boolean
isAppAccessSet: boolean
isLoading: boolean
accessMode?: keyof typeof ACCESS_MODE_MAP
onClick: () => void
}
type ActionsSectionProps = Pick<AppPublisherProps, | 'hasHumanInputNode'
| 'hasTriggerNode'
| 'inputs'
| 'missingStartNode'
| 'onRefreshData'
| 'toolPublished'
| 'outputs'
| 'publishedAt'
| 'workflowToolAvailable'> & {
appDetail: {
id?: string
icon?: string
icon_type?: string | null
icon_background?: string | null
description?: string
mode?: AppModeEnum
name?: string
} | null | undefined
appURL: string
disabledFunctionButton: boolean
disabledFunctionTooltip?: string
handleEmbed: () => void
handleOpenInExplore: () => void
handlePublish: (params?: ModelAndParameter | PublishWorkflowParams) => Promise<void>
published: boolean
workflowToolMessage?: string
}
export const AccessModeDisplay = ({ mode }: { mode?: keyof typeof ACCESS_MODE_MAP }) => {
const { t } = useTranslation()
if (!mode || !ACCESS_MODE_MAP[mode])
return null
const { icon, label } = ACCESS_MODE_MAP[mode]
return (
<>
<span className={`${icon} h-4 w-4 shrink-0 text-text-secondary`} />
<div className="grow truncate">
<span className="system-sm-medium text-text-secondary">{t(`accessControlDialog.accessItems.${label}`, { ns: 'app' })}</span>
</div>
</>
)
}
export const PublisherSummarySection = ({
debugWithMultipleModel = false,
draftUpdatedAt,
formatTimeFromNow,
handlePublish,
handleRestore,
isChatApp,
multipleModelConfigs = [],
onWorkflowTypeSwitch,
publishDisabled = false,
published,
publishedAt,
publishShortcut,
startNodeLimitExceeded = false,
upgradeHighlightStyle,
workflowTypeSwitchConfig,
workflowTypeSwitchDisabled,
}: SummarySectionProps) => {
const { t } = useTranslation()
return (
<div className="p-4 pt-3">
<div className="flex h-6 items-center system-xs-medium-uppercase text-text-tertiary">
{publishedAt ? t('common.latestPublished', { ns: 'workflow' }) : t('common.currentDraftUnpublished', { ns: 'workflow' })}
</div>
{publishedAt
? (
<div className="flex items-center justify-between">
<div className="flex items-center system-sm-medium text-text-secondary">
{t('common.publishedAt', { ns: 'workflow' })}
{' '}
{formatTimeFromNow(publishedAt)}
</div>
{isChatApp && (
<Button
variant="secondary-accent"
size="small"
onClick={handleRestore}
disabled={published}
>
{t('common.restore', { ns: 'workflow' })}
</Button>
)}
</div>
)
: (
<div className="flex items-center system-sm-medium text-text-secondary">
{t('common.autoSaved', { ns: 'workflow' })}
{' '}
·
{Boolean(draftUpdatedAt) && formatTimeFromNow(draftUpdatedAt!)}
</div>
)}
{debugWithMultipleModel
? (
<PublishWithMultipleModel
multipleModelConfigs={multipleModelConfigs}
onSelect={item => handlePublish(item)}
/>
)
: (
<>
<Button
variant="primary"
className="mt-3 w-full"
onClick={() => handlePublish()}
disabled={publishDisabled || published}
>
{published
? t('common.published', { ns: 'workflow' })
: (
<div className="flex gap-1">
<span>{t('common.publishUpdate', { ns: 'workflow' })}</span>
<ShortcutsName keys={publishShortcut} bgColor="white" />
</div>
)}
</Button>
{workflowTypeSwitchConfig && (
<button
type="button"
className="flex h-8 w-full items-center justify-center gap-0.5 rounded-lg px-3 py-2 system-sm-medium text-text-tertiary hover:bg-state-base-hover disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => void onWorkflowTypeSwitch()}
disabled={workflowTypeSwitchDisabled}
>
<span className="px-0.5">
{t(
publishedAt
? workflowTypeSwitchConfig.switchLabelKey
: workflowTypeSwitchConfig.publishLabelKey,
{ ns: 'workflow' },
)}
</span>
<Tooltip>
<TooltipTrigger
render={(
<span
className="flex h-4 w-4 items-center justify-center text-text-quaternary hover:text-text-tertiary"
aria-label={t(workflowTypeSwitchConfig.tipKey, { ns: 'workflow' })}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
>
<span className="i-ri-question-line h-3.5 w-3.5" />
</span>
)}
/>
<TooltipContent
placement="top"
popupClassName="w-[180px]"
>
{t(workflowTypeSwitchConfig.tipKey, { ns: 'workflow' })}
</TooltipContent>
</Tooltip>
</button>
)}
{startNodeLimitExceeded && (
<div className="mt-3 flex flex-col items-stretch">
<p
className="text-sm leading-5 font-semibold text-transparent"
style={upgradeHighlightStyle}
>
<span className="block">{t('publishLimit.startNodeTitlePrefix', { ns: 'workflow' })}</span>
<span className="block">{t('publishLimit.startNodeTitleSuffix', { ns: 'workflow' })}</span>
</p>
<p className="mt-1 text-xs leading-4 text-text-secondary">
{t('publishLimit.startNodeDesc', { ns: 'workflow' })}
</p>
<UpgradeBtn
isShort
className="mt-[9px] mb-[12px] h-[32px] w-[93px] self-start"
/>
</div>
)}
</>
)}
</div>
)
}
export const PublisherAccessSection = ({
enabled,
isAppAccessSet,
isLoading,
accessMode,
onClick,
}: AccessSectionProps) => {
const { t } = useTranslation()
if (isLoading)
return <div className="py-2"><Loading /></div>
return (
<>
<Divider className="my-0" />
{enabled && (
<div className="p-4 pt-3">
<div className="flex h-6 items-center">
<p className="system-xs-medium text-text-tertiary">{t('publishApp.title', { ns: 'app' })}</p>
</div>
<div
className="flex h-8 cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal py-1 pr-2 pl-2.5 hover:bg-primary-50 hover:text-text-accent"
onClick={onClick}
>
<div className="flex grow items-center gap-x-1.5 overflow-hidden pr-1">
<AccessModeDisplay mode={accessMode} />
</div>
{!isAppAccessSet && <p className="shrink-0 system-xs-regular text-text-tertiary">{t('publishApp.notSet', { ns: 'app' })}</p>}
<div className="flex h-4 w-4 shrink-0 items-center justify-center">
<span className="i-ri-arrow-right-s-line h-4 w-4 text-text-quaternary" />
</div>
</div>
{!isAppAccessSet && <p className="mt-1 system-xs-regular text-text-warning">{t('publishApp.notSetDesc', { ns: 'app' })}</p>}
</div>
)}
</>
)
}
const ActionTooltip = ({
disabled,
tooltip,
children,
}: {
disabled: boolean
tooltip?: ReactNode
children: ReactNode
}) => {
if (!disabled || !tooltip)
return <>{children}</>
return (
<Tooltip>
<TooltipTrigger render={<div className="flex">{children}</div>} />
<TooltipContent>
{tooltip}
</TooltipContent>
</Tooltip>
)
}
export const PublisherActionsSection = ({
appDetail,
appURL,
disabledFunctionButton,
disabledFunctionTooltip,
handleEmbed,
handleOpenInExplore,
handlePublish,
hasHumanInputNode = false,
hasTriggerNode = false,
inputs,
missingStartNode = false,
onRefreshData,
outputs,
published,
publishedAt,
toolPublished,
workflowToolAvailable = true,
workflowToolMessage,
}: ActionsSectionProps) => {
const { t } = useTranslation()
if (hasTriggerNode)
return null
const workflowToolDisabled = !publishedAt || !workflowToolAvailable
return (
<div className="flex flex-col gap-y-1 border-t-[0.5px] border-t-divider-regular p-4 pt-3">
<ActionTooltip disabled={disabledFunctionButton} tooltip={disabledFunctionTooltip}>
<SuggestedAction
className="flex-1"
disabled={disabledFunctionButton}
link={appURL}
icon={<span className="i-ri-play-circle-line h-4 w-4" />}
>
{t('common.runApp', { ns: 'workflow' })}
</SuggestedAction>
</ActionTooltip>
{appDetail?.mode === AppModeEnum.WORKFLOW || appDetail?.mode === AppModeEnum.COMPLETION
? (
<ActionTooltip disabled={disabledFunctionButton} tooltip={disabledFunctionTooltip}>
<SuggestedAction
className="flex-1"
disabled={disabledFunctionButton}
link={`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`}
icon={<span className="i-ri-play-list-2-line h-4 w-4" />}
>
{t('common.batchRunApp', { ns: 'workflow' })}
</SuggestedAction>
</ActionTooltip>
)
: (
<SuggestedAction
onClick={handleEmbed}
disabled={!publishedAt}
icon={<span className="i-custom-vender-line-development-code-browser h-4 w-4" />}
>
{t('common.embedIntoSite', { ns: 'workflow' })}
</SuggestedAction>
)}
<ActionTooltip disabled={disabledFunctionButton} tooltip={disabledFunctionTooltip}>
<SuggestedAction
className="flex-1"
onClick={() => {
if (publishedAt)
handleOpenInExplore()
}}
disabled={disabledFunctionButton}
icon={<span className="i-ri-planet-line h-4 w-4" />}
>
{t('common.openInExplore', { ns: 'workflow' })}
</SuggestedAction>
</ActionTooltip>
<ActionTooltip
disabled={!publishedAt || missingStartNode}
tooltip={!publishedAt ? t('notPublishedYet', { ns: 'app' }) : t('noUserInputNode', { ns: 'app' })}
>
<SuggestedAction
className="flex-1"
disabled={!publishedAt || missingStartNode}
link="./develop"
icon={<span className="i-ri-terminal-box-line h-4 w-4" />}
>
{t('common.accessAPIReference', { ns: 'workflow' })}
</SuggestedAction>
</ActionTooltip>
{appDetail?.mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && (
<WorkflowToolConfigureButton
disabled={workflowToolDisabled}
published={!!toolPublished}
detailNeedUpdate={!!toolPublished && published}
workflowAppId={appDetail?.id ?? ''}
icon={{
content: (appDetail.icon_type === 'image' ? '🤖' : appDetail?.icon) || '🤖',
background: (appDetail.icon_type === 'image' ? appDefaultIconBackground : appDetail?.icon_background) || appDefaultIconBackground,
}}
name={appDetail?.name ?? ''}
description={appDetail?.description ?? ''}
inputs={inputs}
outputs={outputs}
handlePublish={handlePublish}
onRefreshData={onRefreshData}
disabledReason={workflowToolMessage}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,84 @@
import type { TFunction } from 'i18next'
import type { I18nKeysByPrefix } from '@/types/i18n'
import { AccessMode } from '@/models/access-control'
import { AppModeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
type AccessSubjectsLike = {
groups?: unknown[]
members?: unknown[]
} | null | undefined
type AppDetailLike = {
access_mode?: AccessMode
mode?: AppModeEnum
}
type AccessModeLabel = I18nKeysByPrefix<'app', 'accessControlDialog.accessItems.'>
export const ACCESS_MODE_MAP: Record<AccessMode, { label: AccessModeLabel, icon: string }> = {
[AccessMode.ORGANIZATION]: {
label: 'organization',
icon: 'i-ri-building-line',
},
[AccessMode.SPECIFIC_GROUPS_MEMBERS]: {
label: 'specific',
icon: 'i-ri-lock-line',
},
[AccessMode.PUBLIC]: {
label: 'anyone',
icon: 'i-ri-global-line',
},
[AccessMode.EXTERNAL_MEMBERS]: {
label: 'external',
icon: 'i-ri-verified-badge-line',
},
}
export const getPublisherAppMode = (mode?: AppModeEnum) => {
if (mode !== AppModeEnum.COMPLETION && mode !== AppModeEnum.WORKFLOW)
return AppModeEnum.CHAT
return mode
}
export const getPublisherAppUrl = ({
appBaseUrl,
accessToken,
mode,
}: {
appBaseUrl: string
accessToken: string
mode?: AppModeEnum
}) => `${appBaseUrl}${basePath}/${getPublisherAppMode(mode)}/${accessToken}`
export const isPublisherAccessConfigured = (appDetail: AppDetailLike | null | undefined, appAccessSubjects: AccessSubjectsLike) => {
if (!appDetail || !appAccessSubjects)
return true
if (appDetail.access_mode !== AccessMode.SPECIFIC_GROUPS_MEMBERS)
return true
return Boolean(appAccessSubjects.groups?.length || appAccessSubjects.members?.length)
}
export const getDisabledFunctionTooltip = ({
t,
publishedAt,
missingStartNode,
noAccessPermission,
}: {
t: TFunction
publishedAt?: number
missingStartNode: boolean
noAccessPermission: boolean
}) => {
if (!publishedAt)
return t('notPublishedYet', { ns: 'app' })
if (missingStartNode)
return t('noUserInputNode', { ns: 'app' })
if (noAccessPermission)
return t('noAccessPermission', { ns: 'app' })
return undefined
}

View File

@@ -0,0 +1,283 @@
import type { ComponentProps } from 'react'
import type { ConfigurationViewModel } from '../hooks/use-configuration'
import type AppPublisher from '@/app/components/app/app-publisher/features-wrapper'
import type ConfigContext from '@/context/debug-configuration'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { AppModeEnum, ModelModeType } from '@/types/app'
import ConfigurationView from '../configuration-view'
vi.mock('@/app/components/app/app-publisher/features-wrapper', () => ({
default: () => <div data-testid="app-publisher" />,
}))
vi.mock('@/app/components/app/configuration/config', () => ({
default: () => <div data-testid="config-panel" />,
}))
vi.mock('@/app/components/app/configuration/debug', () => ({
default: () => <div data-testid="debug-panel" />,
}))
vi.mock('@/app/components/app/configuration/config/agent-setting-button', () => ({
default: () => <div data-testid="agent-setting-button" />,
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({
default: () => <div data-testid="model-parameter-modal" />,
}))
vi.mock('@/app/components/app/configuration/dataset-config/select-dataset', () => ({
default: () => <div data-testid="select-dataset" />,
}))
vi.mock('@/app/components/app/configuration/config-prompt/conversation-history/edit-modal', () => ({
default: () => <div data-testid="history-modal" />,
}))
vi.mock('@/app/components/base/features/new-feature-panel', () => ({
default: () => <div data-testid="feature-panel" />,
}))
vi.mock('@/app/components/workflow/plugin-dependency', () => ({
default: () => <div data-testid="plugin-dependency" />,
}))
const createContextValue = (): ComponentProps<typeof ConfigContext.Provider>['value'] => ({
appId: 'app-1',
isAPIKeySet: true,
isTrailFinished: false,
mode: AppModeEnum.CHAT,
modelModeType: ModelModeType.chat,
promptMode: 'simple' as never,
setPromptMode: vi.fn(),
isAdvancedMode: false,
isAgent: false,
isFunctionCall: false,
isOpenAI: false,
collectionList: [],
canReturnToSimpleMode: false,
setCanReturnToSimpleMode: vi.fn(),
chatPromptConfig: { prompt: [] } as never,
completionPromptConfig: {
prompt: { text: '' },
conversation_histories_role: {
user_prefix: 'user',
assistant_prefix: 'assistant',
},
} as never,
currentAdvancedPrompt: [],
setCurrentAdvancedPrompt: vi.fn(),
showHistoryModal: vi.fn(),
conversationHistoriesRole: {
user_prefix: 'user',
assistant_prefix: 'assistant',
},
setConversationHistoriesRole: vi.fn(),
hasSetBlockStatus: {
context: false,
history: true,
query: true,
},
conversationId: '',
setConversationId: vi.fn(),
introduction: '',
setIntroduction: vi.fn(),
suggestedQuestions: [],
setSuggestedQuestions: vi.fn(),
controlClearChatMessage: 0,
setControlClearChatMessage: vi.fn(),
prevPromptConfig: {
prompt_template: '',
prompt_variables: [],
},
setPrevPromptConfig: vi.fn(),
moreLikeThisConfig: { enabled: false },
setMoreLikeThisConfig: vi.fn(),
suggestedQuestionsAfterAnswerConfig: { enabled: false },
setSuggestedQuestionsAfterAnswerConfig: vi.fn(),
speechToTextConfig: { enabled: false },
setSpeechToTextConfig: vi.fn(),
textToSpeechConfig: { enabled: false, voice: '', language: '' },
setTextToSpeechConfig: vi.fn(),
citationConfig: { enabled: false },
setCitationConfig: vi.fn(),
annotationConfig: {
id: '',
enabled: false,
score_threshold: 0.5,
embedding_model: {
embedding_model_name: '',
embedding_provider_name: '',
},
},
setAnnotationConfig: vi.fn(),
moderationConfig: { enabled: false },
setModerationConfig: vi.fn(),
externalDataToolsConfig: [],
setExternalDataToolsConfig: vi.fn(),
formattingChanged: false,
setFormattingChanged: vi.fn(),
inputs: {},
setInputs: vi.fn(),
query: '',
setQuery: vi.fn(),
completionParams: {},
setCompletionParams: vi.fn(),
modelConfig: {
provider: 'openai',
model_id: 'gpt-4o',
mode: ModelModeType.chat,
configs: {
prompt_template: '',
prompt_variables: [],
},
chat_prompt_config: null,
completion_prompt_config: null,
opening_statement: '',
more_like_this: null,
suggested_questions: [],
suggested_questions_after_answer: null,
speech_to_text: null,
text_to_speech: null,
file_upload: null,
retriever_resource: null,
sensitive_word_avoidance: null,
annotation_reply: null,
external_data_tools: [],
system_parameters: {
audio_file_size_limit: 1,
file_size_limit: 1,
image_file_size_limit: 1,
video_file_size_limit: 1,
workflow_file_upload_limit: 1,
},
dataSets: [],
agentConfig: {
enabled: false,
strategy: 'react',
max_iteration: 1,
tools: [],
},
} as never,
setModelConfig: vi.fn(),
dataSets: [],
setDataSets: vi.fn(),
showSelectDataSet: vi.fn(),
datasetConfigs: {
retrieval_model: 'multiple',
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0.5,
datasets: { datasets: [] },
} as never,
datasetConfigsRef: { current: {} as never },
setDatasetConfigs: vi.fn(),
hasSetContextVar: false,
isShowVisionConfig: false,
visionConfig: {
enabled: false,
number_limits: 1,
detail: 'low',
transfer_methods: ['local_file'],
} as never,
setVisionConfig: vi.fn(),
isAllowVideoUpload: false,
isShowDocumentConfig: false,
isShowAudioConfig: false,
rerankSettingModalOpen: false,
setRerankSettingModalOpen: vi.fn(),
})
const createViewModel = (overrides: Partial<ConfigurationViewModel> = {}): ConfigurationViewModel => ({
appPublisherProps: {
publishDisabled: false,
publishedAt: 0,
debugWithMultipleModel: false,
multipleModelConfigs: [],
onPublish: vi.fn(),
publishedConfig: {
modelConfig: createContextValue().modelConfig,
completionParams: {},
},
resetAppConfig: vi.fn(),
} as ComponentProps<typeof AppPublisher>,
contextValue: createContextValue(),
featuresData: {
moreLikeThis: { enabled: false },
opening: { enabled: false, opening_statement: '', suggested_questions: [] },
moderation: { enabled: false },
speech2text: { enabled: false },
text2speech: { enabled: false, voice: '', language: '' },
file: { enabled: false, image: { enabled: false, detail: 'high', number_limits: 3, transfer_methods: ['local_file'] } } as never,
suggested: { enabled: false },
citation: { enabled: false },
annotationReply: { enabled: false },
},
isAgent: false,
isAdvancedMode: false,
isMobile: false,
isShowDebugPanel: false,
isShowHistoryModal: false,
isShowSelectDataSet: false,
modelConfig: createContextValue().modelConfig,
multipleModelConfigs: [],
onAutoAddPromptVariable: vi.fn(),
onAgentSettingChange: vi.fn(),
onCloseFeaturePanel: vi.fn(),
onCloseHistoryModal: vi.fn(),
onCloseSelectDataSet: vi.fn(),
onCompletionParamsChange: vi.fn(),
onConfirmUseGPT4: vi.fn(),
onEnableMultipleModelDebug: vi.fn(),
onFeaturesChange: vi.fn(),
onHideDebugPanel: vi.fn(),
onModelChange: vi.fn(),
onMultipleModelConfigsChange: vi.fn(),
onOpenAccountSettings: vi.fn(),
onOpenDebugPanel: vi.fn(),
onSaveHistory: vi.fn(),
onSelectDataSets: vi.fn(),
promptVariables: [],
selectedIds: [],
showAppConfigureFeaturesModal: false,
showLoading: false,
showUseGPT4Confirm: false,
setShowUseGPT4Confirm: vi.fn(),
...overrides,
})
describe('ConfigurationView', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render a loading state before configuration data is ready', () => {
render(<ConfigurationView {...createViewModel({ showLoading: true })} />)
expect(screen.getByRole('status', { name: 'appApi.loading' })).toBeInTheDocument()
expect(screen.queryByTestId('app-publisher')).not.toBeInTheDocument()
})
it('should open the mobile debug panel from the header button', () => {
const onOpenDebugPanel = vi.fn()
render(<ConfigurationView {...createViewModel({ isMobile: true, onOpenDebugPanel })} />)
fireEvent.click(screen.getByRole('button', { name: /appDebug.operation.debugConfig/i }))
expect(onOpenDebugPanel).toHaveBeenCalledTimes(1)
})
it('should close the GPT-4 confirmation dialog when cancel is clicked', () => {
const setShowUseGPT4Confirm = vi.fn()
render(<ConfigurationView {...createViewModel({ showUseGPT4Confirm: true, setShowUseGPT4Confirm })} />)
fireEvent.click(screen.getByRole('button', { name: /operation.cancel/i }))
expect(setShowUseGPT4Confirm).toHaveBeenCalledWith(false)
})
})

View File

@@ -0,0 +1,32 @@
import { render } from '@testing-library/react'
import * as React from 'react'
import { useConfiguration } from '../hooks/use-configuration'
import Configuration from '../index'
const mockView = vi.fn((_: unknown) => <div data-testid="configuration-view" />)
vi.mock('../configuration-view', () => ({
default: (props: unknown) => mockView(props),
}))
vi.mock('../hooks/use-configuration', () => ({
useConfiguration: vi.fn(),
}))
describe('Configuration entry', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should pass the hook view model into ConfigurationView', () => {
const viewModel = {
showLoading: true,
}
vi.mocked(useConfiguration).mockReturnValue(viewModel as never)
render(<Configuration />)
expect(useConfiguration).toHaveBeenCalledTimes(1)
expect(mockView).toHaveBeenCalledWith(viewModel)
})
})

View File

@@ -0,0 +1,226 @@
import type { ModelConfig } from '@/models/debug'
import { AppModeEnum, ModelModeType, Resolution, TransferMethod } from '@/types/app'
import { buildConfigurationFeaturesData, getConfigurationPublishingState, withCollectionIconBasePath } from '../utils'
const createModelConfig = (overrides: Partial<ModelConfig> = {}): ModelConfig => ({
provider: 'openai',
model_id: 'gpt-4o',
mode: ModelModeType.chat,
configs: {
prompt_template: 'Hello',
prompt_variables: [],
},
chat_prompt_config: {
prompt: [],
} as ModelConfig['chat_prompt_config'],
completion_prompt_config: {
prompt: { text: '' },
conversation_histories_role: {
user_prefix: 'user',
assistant_prefix: 'assistant',
},
} as ModelConfig['completion_prompt_config'],
opening_statement: '',
more_like_this: { enabled: false },
suggested_questions: [],
suggested_questions_after_answer: { enabled: false },
speech_to_text: { enabled: false },
text_to_speech: { enabled: false, voice: '', language: '' },
file_upload: null,
retriever_resource: { enabled: false },
sensitive_word_avoidance: { enabled: false },
annotation_reply: null,
external_data_tools: [],
system_parameters: {
audio_file_size_limit: 1,
file_size_limit: 1,
image_file_size_limit: 1,
video_file_size_limit: 1,
workflow_file_upload_limit: 1,
},
dataSets: [],
agentConfig: {
enabled: false,
strategy: 'react',
max_iteration: 1,
tools: [],
} as ModelConfig['agentConfig'],
...overrides,
})
describe('configuration utils', () => {
describe('withCollectionIconBasePath', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should prefix relative collection icons with the base path', () => {
const result = withCollectionIconBasePath([
{ id: 'tool-1', icon: '/icons/tool.svg' },
{ id: 'tool-2', icon: '/console/icons/prefixed.svg' },
] as never, '/console')
expect(result[0].icon).toBe('/console/icons/tool.svg')
expect(result[1].icon).toBe('/console/icons/prefixed.svg')
})
})
describe('buildConfigurationFeaturesData', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should derive feature toggles and upload fallbacks from model config', () => {
const result = buildConfigurationFeaturesData(createModelConfig({
opening_statement: 'Welcome',
suggested_questions: ['How are you?'],
file_upload: {
enabled: true,
image: {
enabled: true,
detail: Resolution.low,
number_limits: 2,
transfer_methods: [TransferMethod.local_file],
},
allowed_file_types: ['image'],
allowed_file_extensions: ['.png'],
allowed_file_upload_methods: [TransferMethod.local_file],
number_limits: 2,
},
}), undefined)
expect(result.opening).toEqual({
enabled: true,
opening_statement: 'Welcome',
suggested_questions: ['How are you?'],
})
expect(result.file).toBeDefined()
expect(result.file!.enabled).toBe(true)
expect(result.file!.image!.detail).toBe(Resolution.low)
expect(result.file!.allowed_file_extensions).toEqual(['.png'])
})
})
describe('getConfigurationPublishingState', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should block publish when advanced completion mode is missing required blocks', () => {
const result = getConfigurationPublishingState({
chatPromptConfig: {
prompt: [],
} as never,
completionPromptConfig: {
prompt: { text: 'Answer' },
conversation_histories_role: {
user_prefix: 'user',
assistant_prefix: 'assistant',
},
} as never,
hasSetBlockStatus: {
context: false,
history: false,
query: false,
},
hasSetContextVar: false,
hasSelectedDataSets: false,
isAdvancedMode: true,
mode: AppModeEnum.CHAT,
modelModeType: ModelModeType.completion,
promptTemplate: 'ignored',
})
expect(result.promptEmpty).toBe(false)
expect(result.cannotPublish).toBe(true)
})
it('should require a context variable only for completion apps with selected datasets', () => {
const result = getConfigurationPublishingState({
chatPromptConfig: {
prompt: [],
} as never,
completionPromptConfig: {
prompt: { text: 'Completion prompt' },
conversation_histories_role: {
user_prefix: 'user',
assistant_prefix: 'assistant',
},
} as never,
hasSetBlockStatus: {
context: false,
history: true,
query: true,
},
hasSetContextVar: false,
hasSelectedDataSets: true,
isAdvancedMode: false,
mode: AppModeEnum.COMPLETION,
modelModeType: ModelModeType.completion,
promptTemplate: 'Prompt',
})
expect(result.promptEmpty).toBe(false)
expect(result.cannotPublish).toBe(false)
expect(result.contextVarEmpty).toBe(true)
})
it('should treat advanced completion chat prompts as empty when every segment is blank', () => {
const result = getConfigurationPublishingState({
chatPromptConfig: {
prompt: [{ text: '' }, { text: '' }],
} as never,
completionPromptConfig: {
prompt: { text: 'ignored' },
conversation_histories_role: {
user_prefix: 'user',
assistant_prefix: 'assistant',
},
} as never,
hasSetBlockStatus: {
context: true,
history: true,
query: true,
},
hasSetContextVar: true,
hasSelectedDataSets: false,
isAdvancedMode: true,
mode: AppModeEnum.COMPLETION,
modelModeType: ModelModeType.chat,
promptTemplate: 'ignored',
})
expect(result.promptEmpty).toBe(true)
expect(result.cannotPublish).toBe(true)
})
it('should treat advanced completion text prompts as empty when the completion prompt is missing', () => {
const result = getConfigurationPublishingState({
chatPromptConfig: {
prompt: [{ text: 'ignored' }],
} as never,
completionPromptConfig: {
prompt: { text: '' },
conversation_histories_role: {
user_prefix: 'user',
assistant_prefix: 'assistant',
},
} as never,
hasSetBlockStatus: {
context: true,
history: true,
query: true,
},
hasSetContextVar: true,
hasSelectedDataSets: false,
isAdvancedMode: true,
mode: AppModeEnum.COMPLETION,
modelModeType: ModelModeType.completion,
promptTemplate: 'ignored',
})
expect(result.promptEmpty).toBe(true)
expect(result.cannotPublish).toBe(true)
})
})
})

View File

@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react'
import FeaturePanel from './index'
import FeaturePanel from '../index'
describe('FeaturePanel', () => {
// Rendering behavior for standard layout.

View File

@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react'
import GroupName from './index'
import GroupName from '../index'
describe('GroupName', () => {
beforeEach(() => {

View File

@@ -1,5 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react'
import OperationBtn from './index'
import OperationBtn from '../index'
vi.mock('@remixicon/react', () => ({
RiAddLine: (props: { className?: string }) => (

View File

@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react'
import VarHighlight, { varHighlightHTML } from './index'
import VarHighlight, { varHighlightHTML } from '../index'
describe('VarHighlight', () => {
beforeEach(() => {

View File

@@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import CannotQueryDataset from './cannot-query-dataset'
import CannotQueryDataset from '../cannot-query-dataset'
describe('CannotQueryDataset WarningMask', () => {
it('should render dataset warning copy and action button', () => {

View File

@@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import FormattingChanged from './formatting-changed'
import FormattingChanged from '../formatting-changed'
describe('FormattingChanged WarningMask', () => {
it('should display translation text and both actions', () => {

View File

@@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import HasNotSetAPI from './has-not-set-api'
import HasNotSetAPI from '../has-not-set-api'
describe('HasNotSetAPI', () => {
it('should render the empty state copy', () => {

View File

@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import WarningMask from './index'
import WarningMask from '../index'
describe('WarningMask', () => {
// Rendering of title, description, and footer content

View File

@@ -0,0 +1,228 @@
/* eslint-disable ts/no-explicit-any */
import type { ReactNode } from 'react'
import type { PromptRole } from '@/models/debug'
import { fireEvent, render, screen } from '@testing-library/react'
import { INSERT_VARIABLE_VALUE_BLOCK_COMMAND } from '@/app/components/base/prompt-editor/plugins/variable-block'
import ConfigContext from '@/context/debug-configuration'
import { AppModeEnum } from '@/types/app'
import AdvancedPromptInput from '../advanced-prompt-input'
const mockEmit = vi.fn()
const mockSetShowExternalDataToolModal = vi.fn()
const mockSetModelConfig = vi.fn()
const mockOnTypeChange = vi.fn()
const mockOnChange = vi.fn()
const mockOnDelete = vi.fn()
const mockOnHideContextMissingTip = vi.fn()
const mockCopy = vi.fn()
const mockToastError = vi.fn()
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('copy-to-clipboard', () => ({
default: (...args: unknown[]) => mockCopy(...args),
}))
vi.mock('@remixicon/react', async (importOriginal) => {
const actual = await importOriginal<typeof import('@remixicon/react')>()
return {
...actual,
RiDeleteBinLine: ({ onClick }: { onClick: () => void }) => (
<button onClick={onClick}>delete-prompt</button>
),
RiErrorWarningFill: () => <span>warning-icon</span>,
}
})
vi.mock('@/app/components/base/icons/src/vender/line/files', () => ({
Copy: ({ onClick }: { onClick: () => void }) => (
<button onClick={onClick}>copy-prompt</button>
),
CopyCheck: () => <span>copy-checked</span>,
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
emit: (...args: unknown[]) => mockEmit(...args),
},
}),
}))
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowExternalDataToolModal: mockSetShowExternalDataToolModal,
}),
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
error: (...args: unknown[]) => mockToastError(...args),
},
}))
vi.mock('../message-type-selector', () => ({
default: ({ onChange, value }: { onChange: (value: PromptRole) => void, value: PromptRole }) => (
<button onClick={() => onChange('assistant' as PromptRole)}>{`selector:${value}`}</button>
),
}))
vi.mock('@/app/components/base/prompt-editor', () => ({
default: (props: {
onBlur: () => void
onChange: (value: string) => void
externalToolBlock: { onAddExternalTool: () => void }
}) => (
<div>
<button onClick={() => props.onChange('Updated {{new_var}}')}>change-advanced</button>
<button onClick={props.onBlur}>blur-advanced</button>
<button onClick={props.externalToolBlock.onAddExternalTool}>open-advanced-tool-modal</button>
</div>
),
}))
vi.mock('../prompt-editor-height-resize-wrap', () => ({
default: ({ children, footer }: { children: ReactNode, footer: ReactNode }) => (
<div>
{children}
{footer}
</div>
),
}))
const createContextValue = () => ({
mode: AppModeEnum.CHAT,
hasSetBlockStatus: {
context: false,
history: false,
query: false,
},
modelConfig: {
configs: {
prompt_variables: [
{ key: 'existing_var', name: 'Existing', type: 'string', required: true },
],
},
},
setModelConfig: mockSetModelConfig,
conversationHistoriesRole: {
user_prefix: 'user',
assistant_prefix: 'assistant',
},
showHistoryModal: vi.fn(),
dataSets: [],
showSelectDataSet: vi.fn(),
externalDataToolsConfig: [],
}) as any
describe('AdvancedPromptInput', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should delegate prompt text and role changes to the parent callbacks', () => {
render(
<ConfigContext.Provider value={createContextValue()}>
<AdvancedPromptInput
type={'user' as PromptRole}
isChatMode
value="Hello"
onChange={mockOnChange}
onTypeChange={mockOnTypeChange}
canDelete
onDelete={mockOnDelete}
promptVariables={[]}
isContextMissing={false}
onHideContextMissingTip={mockOnHideContextMissingTip}
/>
</ConfigContext.Provider>,
)
fireEvent.click(screen.getByText('change-advanced'))
fireEvent.click(screen.getByText('selector:user'))
fireEvent.click(screen.getByText('copy-prompt'))
fireEvent.click(screen.getByText('delete-prompt'))
expect(mockOnChange).toHaveBeenCalledWith('Updated {{new_var}}')
expect(mockOnTypeChange).toHaveBeenCalledWith('assistant')
expect(mockCopy).toHaveBeenCalledWith('Hello')
expect(mockOnDelete).toHaveBeenCalled()
})
it('should add newly discovered variables after blur confirmation', () => {
render(
<ConfigContext.Provider value={createContextValue()}>
<AdvancedPromptInput
type={'user' as PromptRole}
isChatMode
value="Hello {{new_var}}"
onChange={mockOnChange}
onTypeChange={mockOnTypeChange}
canDelete={false}
onDelete={mockOnDelete}
promptVariables={[]}
isContextMissing={false}
onHideContextMissingTip={mockOnHideContextMissingTip}
/>
</ConfigContext.Provider>,
)
fireEvent.click(screen.getByText('blur-advanced'))
fireEvent.click(screen.getByText('operation.add'))
expect(mockSetModelConfig).toHaveBeenCalledWith(expect.objectContaining({
configs: expect.objectContaining({
prompt_variables: expect.arrayContaining([
expect.objectContaining({
key: 'new_var',
name: 'new_var',
}),
]),
}),
}))
})
it('should open the external data tool modal and validate duplicates', () => {
render(
<ConfigContext.Provider value={createContextValue()}>
<AdvancedPromptInput
type={'user' as PromptRole}
isChatMode
value="Hello"
onChange={mockOnChange}
onTypeChange={mockOnTypeChange}
canDelete={false}
onDelete={mockOnDelete}
promptVariables={[
{ key: 'existing_var', name: 'Existing', type: 'string', required: true },
]}
isContextMissing={false}
onHideContextMissingTip={mockOnHideContextMissingTip}
/>
</ConfigContext.Provider>,
)
fireEvent.click(screen.getByText('open-advanced-tool-modal'))
const modalConfig = mockSetShowExternalDataToolModal.mock.calls[0][0]
expect(modalConfig.onValidateBeforeSaveCallback({ variable: 'existing_var' })).toBe(false)
expect(mockToastError).toHaveBeenCalledWith('varKeyError.keyAlreadyExists')
modalConfig.onSaveCallback({
label: 'Search',
variable: 'search_api',
})
expect(mockEmit).toHaveBeenCalledWith(expect.objectContaining({
type: 'ADD_EXTERNAL_DATA_TOOL',
}))
expect(mockEmit).toHaveBeenCalledWith(expect.objectContaining({
payload: 'search_api',
type: INSERT_VARIABLE_VALUE_BLOCK_COMMAND,
}))
})
})

View File

@@ -1,4 +1,5 @@
import type { IPromptProps } from './index'
/* eslint-disable ts/no-explicit-any */
import type { IPromptProps } from '../index'
import type { PromptItem, PromptVariable } from '@/models/debug'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
@@ -6,7 +7,7 @@ import { MAX_PROMPT_MESSAGE_LENGTH } from '@/config'
import ConfigContext from '@/context/debug-configuration'
import { PromptRole } from '@/models/debug'
import { AppModeEnum, ModelModeType } from '@/types/app'
import Prompt from './index'
import Prompt from '../index'
type DebugConfiguration = {
isAdvancedMode: boolean
@@ -30,7 +31,7 @@ const defaultPromptVariables: PromptVariable[] = [
let mockSimplePromptInputProps: IPromptProps | null = null
vi.mock('./simple-prompt-input', () => ({
vi.mock('../simple-prompt-input', () => ({
default: (props: IPromptProps) => {
mockSimplePromptInputProps = props
return (
@@ -65,7 +66,7 @@ type AdvancedMessageInputProps = {
noResize?: boolean
}
vi.mock('./advanced-prompt-input', () => ({
vi.mock('../advanced-prompt-input', () => ({
default: (props: AdvancedMessageInputProps) => {
return (
<div

View File

@@ -1,7 +1,7 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { PromptRole } from '@/models/debug'
import MessageTypeSelector from './message-type-selector'
import MessageTypeSelector from '../message-type-selector'
describe('MessageTypeSelector', () => {
beforeEach(() => {

View File

@@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import PromptEditorHeightResizeWrap from './prompt-editor-height-resize-wrap'
import PromptEditorHeightResizeWrap from '../prompt-editor-height-resize-wrap'
describe('PromptEditorHeightResizeWrap', () => {
beforeEach(() => {

View File

@@ -0,0 +1,320 @@
/* eslint-disable ts/no-explicit-any */
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { INSERT_VARIABLE_VALUE_BLOCK_COMMAND } from '@/app/components/base/prompt-editor/plugins/variable-block'
import ConfigContext from '@/context/debug-configuration'
import { AppModeEnum } from '@/types/app'
import Prompt from '../simple-prompt-input'
const mockEmit = vi.fn()
const mockSetFeatures = vi.fn()
const mockSetShowExternalDataToolModal = vi.fn()
const mockSetModelConfig = vi.fn()
const mockSetPrevPromptConfig = vi.fn()
const mockSetIntroduction = vi.fn()
const mockOnChange = vi.fn()
const mockToastError = vi.fn()
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/hooks/use-breakpoints', () => ({
__esModule: true,
default: () => 'desktop',
MediaType: {
mobile: 'mobile',
},
}))
vi.mock('@/app/components/base/features/hooks', () => ({
useFeaturesStore: () => ({
getState: () => ({
features: {
opening: {
enabled: false,
opening_statement: '',
},
},
setFeatures: mockSetFeatures,
}),
}),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
emit: (...args: unknown[]) => mockEmit(...args),
},
}),
}))
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowExternalDataToolModal: mockSetShowExternalDataToolModal,
}),
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
error: (...args: unknown[]) => mockToastError(...args),
},
}))
vi.mock('@/app/components/app/configuration/config/automatic/automatic-btn', () => ({
default: ({ onClick }: { onClick: () => void }) => <button onClick={onClick}>automatic-btn</button>,
}))
vi.mock('@/app/components/app/configuration/config/automatic/get-automatic-res', () => ({
default: ({ onFinished }: { onFinished: (value: Record<string, unknown>) => void }) => (
<button onClick={() => onFinished({ modified: 'auto prompt', variables: ['city'], opening_statement: 'hello there' })}>
finish-automatic
</button>
),
}))
vi.mock('@/app/components/base/prompt-editor', () => ({
default: (props: {
onBlur: () => void
onChange: (value: string) => void
contextBlock: { datasets: Array<{ id: string, name: string, type: string }> }
variableBlock: { variables: Array<{ name: string, value: string }> }
queryBlock: { selectable: boolean }
externalToolBlock: {
onAddExternalTool: () => void
externalTools: Array<{ name: string, variableName: string }>
}
}) => (
<div>
<div>{`datasets:${props.contextBlock.datasets.map(item => item.name).join(',')}`}</div>
<div>{`variables:${props.variableBlock.variables.map(item => item.value).join(',')}`}</div>
<div>{`external-tools:${props.externalToolBlock.externalTools.map(item => item.variableName).join(',')}`}</div>
<div>{`query-selectable:${String(props.queryBlock.selectable)}`}</div>
<button onClick={() => props.onChange('Hello {{new_var}}')}>change-prompt</button>
<button onClick={props.onBlur}>blur-prompt</button>
<button onClick={props.externalToolBlock.onAddExternalTool}>open-tool-modal</button>
</div>
),
}))
vi.mock('../prompt-editor-height-resize-wrap', () => ({
default: ({ children, footer }: { children: ReactNode, footer: ReactNode }) => (
<div>
{children}
{footer}
</div>
),
}))
const createContextValue = (overrides: Record<string, unknown> = {}) => ({
appId: 'app-1',
modelConfig: {
configs: {
prompt_template: 'Hello {{new_var}}',
prompt_variables: [
{ key: 'existing_var', name: 'Existing', type: 'string', required: true },
],
},
},
dataSets: [],
setModelConfig: mockSetModelConfig,
setPrevPromptConfig: mockSetPrevPromptConfig,
setIntroduction: mockSetIntroduction,
hasSetBlockStatus: {
context: false,
history: false,
query: false,
},
showSelectDataSet: vi.fn(),
externalDataToolsConfig: [],
...overrides,
}) as any
describe('SimplePromptInput', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should prompt to add new variables discovered from the prompt template', () => {
render(
<ConfigContext.Provider value={createContextValue()}>
<Prompt
mode={AppModeEnum.CHAT}
promptTemplate="Hello {{new_var}}"
promptVariables={[]}
onChange={mockOnChange}
/>
</ConfigContext.Provider>,
)
fireEvent.click(screen.getByText('blur-prompt'))
expect(screen.getByText('autoAddVar')).toBeInTheDocument()
fireEvent.click(screen.getByText('operation.add'))
expect(mockOnChange).toHaveBeenCalledWith('Hello {{new_var}}', [
expect.objectContaining({
key: 'new_var',
name: 'new_var',
}),
])
})
it('should open the external data tool modal and emit insert events after save', () => {
render(
<ConfigContext.Provider value={createContextValue()}>
<Prompt
mode={AppModeEnum.CHAT}
promptTemplate="Hello"
promptVariables={[
{ key: 'existing_var', name: 'Existing', type: 'string', required: true },
]}
onChange={mockOnChange}
/>
</ConfigContext.Provider>,
)
fireEvent.click(screen.getByText('open-tool-modal'))
expect(mockSetShowExternalDataToolModal).toHaveBeenCalledTimes(1)
const modalConfig = mockSetShowExternalDataToolModal.mock.calls[0][0]
expect(modalConfig.onValidateBeforeSaveCallback({ variable: 'existing_var' })).toBe(false)
expect(mockToastError).toHaveBeenCalledWith('varKeyError.keyAlreadyExists')
expect(modalConfig.onValidateBeforeSaveCallback({ variable: 'fresh_var' })).toBe(true)
modalConfig.onSaveCallback(undefined)
expect(mockEmit).not.toHaveBeenCalled()
modalConfig.onSaveCallback({
label: 'Search',
variable: 'search_api',
})
expect(mockEmit).toHaveBeenCalledWith(expect.objectContaining({
type: 'ADD_EXTERNAL_DATA_TOOL',
}))
expect(mockEmit).toHaveBeenCalledWith(expect.objectContaining({
payload: 'search_api',
type: INSERT_VARIABLE_VALUE_BLOCK_COMMAND,
}))
})
it('should apply automatic generation results to prompt and opening statement', () => {
render(
<ConfigContext.Provider value={createContextValue()}>
<Prompt
mode={AppModeEnum.CHAT}
promptTemplate="Hello"
promptVariables={[]}
onChange={mockOnChange}
/>
</ConfigContext.Provider>,
)
fireEvent.click(screen.getByText('automatic-btn'))
fireEvent.click(screen.getByText('finish-automatic'))
expect(mockEmit).toHaveBeenCalledWith(expect.objectContaining({
payload: 'auto prompt',
type: 'PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER',
}))
expect(mockSetModelConfig).toHaveBeenCalledWith(expect.objectContaining({
configs: expect.objectContaining({
prompt_template: 'auto prompt',
prompt_variables: [
expect.objectContaining({ key: 'city', name: 'city' }),
],
}),
}))
expect(mockSetPrevPromptConfig).toHaveBeenCalled()
expect(mockSetIntroduction).toHaveBeenCalledWith('hello there')
expect(mockSetFeatures).toHaveBeenCalled()
})
it('should expose dataset and external tool metadata to the editor', () => {
render(
<ConfigContext.Provider value={createContextValue({
dataSets: [{ id: 'dataset-1', name: 'Knowledge Base', data_source_type: 'file' }],
hasSetBlockStatus: {
context: false,
history: false,
query: true,
},
modelConfig: {
configs: {
prompt_template: 'Hello {{existing_var}}',
prompt_variables: [
{ key: 'existing_var', name: 'Existing', type: 'string', required: true },
{ key: 'search_api', name: 'Search API', type: 'api', required: false, icon: 'search', icon_background: '#fff' },
],
},
},
})}
>
<Prompt
mode={AppModeEnum.CHAT}
promptTemplate="Hello {{existing_var}}"
promptVariables={[
{ key: 'existing_var', name: 'Existing', type: 'string', required: true },
{ key: 'search_api', name: 'Search API', type: 'api', required: false },
]}
onChange={mockOnChange}
/>
</ConfigContext.Provider>,
)
expect(screen.getByText('datasets:Knowledge Base')).toBeInTheDocument()
expect(screen.getByText('variables:existing_var')).toBeInTheDocument()
expect(screen.getByText('external-tools:search_api')).toBeInTheDocument()
expect(screen.getByText('query-selectable:false')).toBeInTheDocument()
})
it('should skip external tool variables and incomplete prompt variables when deciding whether to auto add', () => {
render(
<ConfigContext.Provider value={createContextValue({
externalDataToolsConfig: [{ variable: 'search_api' }],
})}
>
<Prompt
mode={AppModeEnum.CHAT}
promptTemplate="Hello {{search_api}} {{existing_var}}"
promptVariables={[
{ key: 'existing_var', name: 'Existing', type: 'string', required: true },
]}
onChange={mockOnChange}
/>
</ConfigContext.Provider>,
)
fireEvent.click(screen.getByText('change-prompt'))
expect(mockOnChange).toHaveBeenCalledWith('Hello {{new_var}}', [])
fireEvent.click(screen.getByText('blur-prompt'))
expect(mockOnChange).toHaveBeenLastCalledWith('Hello {{search_api}} {{existing_var}}', [])
})
it('should keep invalid prompt variables in the confirmation flow', () => {
render(
<ConfigContext.Provider value={createContextValue()}>
<Prompt
mode={AppModeEnum.CHAT}
promptTemplate="Hello {{existing_var}}"
promptVariables={[
{ key: 'existing_var', name: '', type: 'string', required: true },
]}
onChange={mockOnChange}
/>
</ConfigContext.Provider>,
)
fireEvent.click(screen.getByText('blur-prompt'))
expect(screen.getByText('autoAddVar')).toBeInTheDocument()
fireEvent.click(screen.getByText('operation.cancel'))
expect(mockOnChange).toHaveBeenCalledWith('Hello {{existing_var}}', [])
})
})

View File

@@ -1,8 +1,8 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import ConfirmAddVar from './index'
import ConfirmAddVar from '../index'
vi.mock('../../base/var-highlight', () => ({
vi.mock('../../../base/var-highlight', () => ({
default: ({ name }: { name: string }) => <span data-testid="var-highlight">{name}</span>,
}))

View File

@@ -1,7 +1,7 @@
import type { ConversationHistoriesRole } from '@/models/debug'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import EditModal from './edit-modal'
import EditModal from '../edit-modal'
vi.mock('@/app/components/base/modal', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,

View File

@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react'
import HistoryPanel from './history-panel'
import HistoryPanel from '../history-panel'
vi.mock('@/app/components/app/configuration/base/operation-btn', () => ({
default: ({ onClick }: { onClick: () => void }) => (

View File

@@ -1,5 +1,5 @@
import type { ReactNode } from 'react'
import type { IConfigVarProps } from './index'
import type { IConfigVarProps } from '../index'
import type { ExternalDataTool } from '@/models/common'
import type { PromptVariable } from '@/models/debug'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
@@ -9,7 +9,7 @@ import { toast } from '@/app/components/base/ui/toast'
import DebugConfigurationContext from '@/context/debug-configuration'
import { AppModeEnum } from '@/types/app'
import ConfigVar, { ADD_EXTERNAL_DATA_TOOL } from './index'
import ConfigVar, { ADD_EXTERNAL_DATA_TOOL } from '../index'
const toastErrorSpy = vi.spyOn(toast, 'error').mockReturnValue('toast-error')
@@ -393,5 +393,94 @@ describe('ConfigVar', () => {
}),
])
})
it('should update an api variable with the modal save callback', () => {
const onPromptVariablesChange = vi.fn()
const apiVar = createPromptVariable({
key: 'api_var',
name: 'API Var',
type: 'api',
})
renderConfigVar({
promptVariables: [apiVar],
onPromptVariablesChange,
})
const item = screen.getByTitle('api_var · API Var')
const itemContainer = item.closest('div.group')
expect(itemContainer).not.toBeNull()
const actionButtons = itemContainer!.querySelectorAll('div.h-6.w-6')
fireEvent.click(actionButtons[0])
const modalState = setShowExternalDataToolModal.mock.calls.at(-1)?.[0]
act(() => {
modalState.onSaveCallback?.({
variable: 'next_api_var',
label: 'Next API Var',
enabled: true,
type: 'api',
config: { endpoint: '/search' },
icon: 'tool-icon',
icon_background: '#fff',
})
})
expect(onPromptVariablesChange).toHaveBeenCalledWith([
expect.objectContaining({
key: 'next_api_var',
name: 'Next API Var',
type: 'api',
icon: 'tool-icon',
}),
])
})
it('should ignore empty external tool saves and reject duplicate variable names during validation', () => {
const onPromptVariablesChange = vi.fn()
const firstVar = createPromptVariable({
key: 'api_var',
name: 'API Var',
type: 'api',
})
const secondVar = createPromptVariable({
key: 'existing_var',
name: 'Existing Var',
type: 'string',
})
renderConfigVar({
promptVariables: [firstVar, secondVar],
onPromptVariablesChange,
})
const item = screen.getByTitle('api_var · API Var')
const itemContainer = item.closest('div.group')
expect(itemContainer).not.toBeNull()
const actionButtons = itemContainer!.querySelectorAll('div.h-6.w-6')
fireEvent.click(actionButtons[0])
const modalState = setShowExternalDataToolModal.mock.calls.at(-1)?.[0]
act(() => {
modalState.onSaveCallback?.(undefined)
})
expect(onPromptVariablesChange).not.toHaveBeenCalled()
const isValid = modalState.onValidateBeforeSaveCallback?.({
variable: 'existing_var',
label: 'Duplicated',
enabled: true,
type: 'api',
config: {},
})
expect(isValid).toBe(false)
expect(toastErrorSpy).toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,207 @@
/* eslint-disable ts/no-explicit-any */
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { InputVarType } from '@/app/components/workflow/types'
import ConfigModalFormFields from '../form-fields'
vi.mock('@/app/components/base/file-uploader', () => ({
FileUploaderInAttachmentWrapper: ({ onChange }: { onChange: (files: Array<Record<string, unknown>>) => void }) => (
<button
type="button"
onClick={() => onChange([
{ fileId: 'file-1', type: 'local_file', url: 'https://example.com/file.png' },
{ fileId: 'file-2', type: 'remote_url', url: 'https://example.com/file-2.png' },
])}
>
upload-file
</button>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/file-upload-setting', () => ({
default: ({ onChange, isMultiple }: { onChange: (payload: Record<string, unknown>) => void, isMultiple: boolean }) => (
<button type="button" onClick={() => onChange({ number_limits: isMultiple ? 3 : 1 })}>
{isMultiple ? 'multi-file-setting' : 'single-file-setting'}
</button>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
default: ({ onChange }: { onChange: (value: string) => void }) => (
<button type="button" onClick={() => onChange('{\n "type": "object"\n}')}>json-editor</button>
),
}))
vi.mock('@/app/components/base/checkbox', () => ({
default: ({ onCheck, checked }: { onCheck: () => void, checked: boolean }) => (
<button type="button" onClick={onCheck}>{checked ? 'checked' : 'unchecked'}</button>
),
}))
vi.mock('@/app/components/base/select', () => ({
default: ({ onSelect }: { onSelect: (item: { value: string }) => void }) => (
<button type="button" onClick={() => onSelect({ value: 'beta' })}>legacy-select</button>
),
}))
vi.mock('@/app/components/base/ui/select', () => ({
Select: ({ value, onValueChange, children }: { value: string, onValueChange: (value: string) => void, children: ReactNode }) => (
<div>
<button type="button" onClick={() => onValueChange(value === 'true' ? 'false' : 'beta')}>{`ui-select:${value}`}</button>
{children}
</div>
),
SelectTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectValue: () => <span>select-value</span>,
SelectContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}))
vi.mock('../field', () => ({
default: ({ children, title }: { children: ReactNode, title: string }) => (
<div>
<span>{title}</span>
{children}
</div>
),
}))
vi.mock('../type-select', () => ({
default: ({ onSelect }: { onSelect: (item: { value: InputVarType }) => void }) => (
<button type="button" onClick={() => onSelect({ value: InputVarType.select })}>type-selector</button>
),
}))
vi.mock('../../config-select', () => ({
default: ({ onChange }: { onChange: (value: string[]) => void }) => (
<button type="button" onClick={() => onChange(['alpha', 'beta'])}>config-select</button>
),
}))
vi.mock('../../config-string', () => ({
default: ({ onChange }: { onChange: (value: number) => void }) => (
<button type="button" onClick={() => onChange(64)}>config-string</button>
),
}))
const t = (key: string) => key
const createPayloadChangeHandler = () => vi.fn<(value: unknown) => void>()
const createBaseProps = () => {
const payloadChangeHandlers: Record<string, ReturnType<typeof createPayloadChangeHandler>> = {
default: createPayloadChangeHandler(),
hide: createPayloadChangeHandler(),
label: createPayloadChangeHandler(),
max_length: createPayloadChangeHandler(),
options: createPayloadChangeHandler(),
required: createPayloadChangeHandler(),
}
return {
checkboxDefaultSelectValue: 'false',
isStringInput: false,
jsonSchemaStr: '',
maxLength: 32,
modelId: 'gpt-4o',
onFilePayloadChange: vi.fn(),
onJSONSchemaChange: vi.fn(),
onPayloadChange: (key: string) => {
if (!payloadChangeHandlers[key])
payloadChangeHandlers[key] = createPayloadChangeHandler()
return payloadChangeHandlers[key]
},
onTypeChange: vi.fn(),
onVarKeyBlur: vi.fn(),
onVarNameChange: vi.fn(),
options: undefined as string[] | undefined,
selectOptions: [],
tempPayload: {
type: InputVarType.textInput,
label: 'Question',
variable: 'question',
required: false,
hide: false,
} as any,
t,
payloadChangeHandlers,
}
}
describe('ConfigModalFormFields', () => {
it('should update paragraph, number, checkbox, and select defaults', () => {
const paragraphProps = createBaseProps()
paragraphProps.tempPayload = { ...paragraphProps.tempPayload, type: InputVarType.paragraph, default: 'hello' }
render(<ConfigModalFormFields {...paragraphProps} />)
fireEvent.change(screen.getByDisplayValue('hello'), { target: { value: 'updated paragraph' } })
expect(paragraphProps.payloadChangeHandlers.default).toHaveBeenCalledWith('updated paragraph')
const numberProps = createBaseProps()
numberProps.tempPayload = { ...numberProps.tempPayload, type: InputVarType.number, default: '1' }
render(<ConfigModalFormFields {...numberProps} />)
fireEvent.change(screen.getByDisplayValue('1'), { target: { value: '2' } })
expect(numberProps.payloadChangeHandlers.default).toHaveBeenCalledWith('2')
const checkboxProps = createBaseProps()
checkboxProps.tempPayload = { ...checkboxProps.tempPayload, type: InputVarType.checkbox, default: false }
checkboxProps.checkboxDefaultSelectValue = 'true'
render(<ConfigModalFormFields {...checkboxProps} />)
fireEvent.click(screen.getByText('ui-select:true'))
expect(checkboxProps.payloadChangeHandlers.default).toHaveBeenCalledWith(false)
const selectProps = createBaseProps()
selectProps.tempPayload = { ...selectProps.tempPayload, type: InputVarType.select, default: 'alpha' }
selectProps.options = ['alpha', 'beta']
render(<ConfigModalFormFields {...selectProps} />)
fireEvent.click(screen.getByText('config-select'))
fireEvent.click(screen.getByText('ui-select:alpha'))
expect(selectProps.payloadChangeHandlers.options).toHaveBeenCalledWith(['alpha', 'beta'])
expect(selectProps.payloadChangeHandlers.default).toHaveBeenCalledWith('beta')
})
it('should wire file, json schema, and visibility controls', () => {
const singleFileProps = createBaseProps()
singleFileProps.tempPayload = {
...singleFileProps.tempPayload,
type: InputVarType.singleFile,
allowed_file_types: ['document'],
allowed_file_extensions: [],
allowed_file_upload_methods: ['remote_url'],
}
render(<ConfigModalFormFields {...singleFileProps} />)
fireEvent.click(screen.getByText('single-file-setting'))
fireEvent.click(screen.getByText('upload-file'))
fireEvent.click(screen.getAllByText('unchecked')[0])
fireEvent.click(screen.getAllByText('unchecked')[1])
expect(singleFileProps.onFilePayloadChange).toHaveBeenCalledWith({ number_limits: 1 })
expect(singleFileProps.payloadChangeHandlers.default).toHaveBeenCalledWith(expect.objectContaining({
fileId: 'file-1',
}))
expect(singleFileProps.payloadChangeHandlers.required).toHaveBeenCalledWith(true)
expect(singleFileProps.payloadChangeHandlers.hide).toHaveBeenCalledWith(true)
const multiFileProps = createBaseProps()
multiFileProps.tempPayload = {
...multiFileProps.tempPayload,
type: InputVarType.multiFiles,
allowed_file_types: ['document'],
allowed_file_extensions: [],
allowed_file_upload_methods: ['remote_url'],
}
render(<ConfigModalFormFields {...multiFileProps} />)
fireEvent.click(screen.getByText('multi-file-setting'))
fireEvent.click(screen.getAllByText('upload-file')[1])
expect(multiFileProps.onFilePayloadChange).toHaveBeenCalledWith({ number_limits: 3 })
expect(multiFileProps.payloadChangeHandlers.default).toHaveBeenCalledWith([
expect.objectContaining({ fileId: 'file-1' }),
expect.objectContaining({ fileId: 'file-2' }),
])
const jsonProps = createBaseProps()
jsonProps.tempPayload = { ...jsonProps.tempPayload, type: InputVarType.jsonObject }
render(<ConfigModalFormFields {...jsonProps} />)
fireEvent.click(screen.getByText('json-editor'))
expect(jsonProps.onJSONSchemaChange).toHaveBeenCalledWith('{\n "type": "object"\n}')
})
})

View File

@@ -0,0 +1,150 @@
/* eslint-disable ts/no-explicit-any */
import type { InputVar } from '@/app/components/workflow/types'
import type { App, AppSSO } from '@/types/app'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { useStore } from '@/app/components/app/store'
import { toast } from '@/app/components/base/ui/toast'
import { InputVarType } from '@/app/components/workflow/types'
import DebugConfigurationContext from '@/context/debug-configuration'
import { AppModeEnum } from '@/types/app'
import ConfigModal from '../index'
const toastErrorSpy = vi.spyOn(toast, 'error').mockReturnValue('toast-error')
let latestFormProps: Record<string, any> | null = null
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('../form-fields', () => ({
default: (props: Record<string, any>) => {
latestFormProps = props
return (
<div data-testid="config-form-fields">
<div data-testid="payload-type">{String(props.tempPayload.type)}</div>
<div data-testid="payload-label">{String(props.tempPayload.label ?? '')}</div>
<div data-testid="payload-schema">{String(props.tempPayload.json_schema ?? '')}</div>
<div data-testid="payload-default">{String(props.tempPayload.default ?? '')}</div>
<button data-testid="invalid-key-blur" onClick={() => props.onVarKeyBlur({ target: { value: 'invalid key' } })}>invalid-key-blur</button>
<button data-testid="valid-key-blur" onClick={() => props.onVarKeyBlur({ target: { value: 'auto_label' } })}>valid-key-blur</button>
<button
data-testid="invalid-name-change"
onClick={() => props.onVarNameChange({
target: {
value: 'invalid-key!',
selectionStart: 0,
selectionEnd: 0,
setSelectionRange: vi.fn(),
},
})}
>
invalid-name-change
</button>
<button data-testid="valid-json-change" onClick={() => props.onJSONSchemaChange('{\n \"foo\": \"bar\"\n}')}>valid-json-change</button>
<button data-testid="empty-json-change" onClick={() => props.onJSONSchemaChange(' ')}>empty-json-change</button>
<button data-testid="invalid-json-change" onClick={() => props.onJSONSchemaChange('{invalid-json}')}>invalid-json-change</button>
<button data-testid="type-change" onClick={() => props.onTypeChange({ value: InputVarType.singleFile })}>type-change</button>
<button data-testid="file-payload-change" onClick={() => props.onFilePayloadChange({ ...props.tempPayload, default: 'file-default' })}>file-payload-change</button>
</div>
)
},
}))
const createPayload = (overrides: Partial<InputVar> = {}): InputVar => ({
type: InputVarType.textInput,
label: '',
variable: 'question',
required: false,
hide: false,
options: [],
default: 'hello',
max_length: 32,
...overrides,
})
const renderConfigModal = (payload: InputVar = createPayload()) => render(
<DebugConfigurationContext.Provider value={{
mode: AppModeEnum.CHAT,
dataSets: [],
modelConfig: { model_id: 'model-1' },
} as any}
>
<ConfigModal
isCreate
isShow
payload={payload}
onClose={vi.fn()}
onConfirm={vi.fn()}
/>
</DebugConfigurationContext.Provider>,
)
describe('ConfigModal logic', () => {
beforeEach(() => {
vi.clearAllMocks()
latestFormProps = null
useStore.setState({
appDetail: {
mode: AppModeEnum.CHAT,
} as App & Partial<AppSSO>,
})
})
it('should surface validation errors from invalid variable name callbacks', async () => {
renderConfigModal()
fireEvent.click(screen.getByTestId('invalid-key-blur'))
fireEvent.click(screen.getByTestId('invalid-name-change'))
await waitFor(() => {
expect(toastErrorSpy).toHaveBeenCalledTimes(2)
})
})
it('should keep the existing label when blur runs on a payload that already has one', async () => {
renderConfigModal(createPayload({ label: 'Existing label' }))
fireEvent.click(screen.getByTestId('valid-key-blur'))
await waitFor(() => {
expect(screen.getByTestId('payload-label')).toHaveTextContent('Existing label')
})
})
it('should derive payload fields from mocked form-field callbacks', async () => {
renderConfigModal()
fireEvent.click(screen.getByTestId('valid-key-blur'))
await waitFor(() => {
expect(screen.getByTestId('payload-label')).toHaveTextContent('auto_label')
})
fireEvent.click(screen.getByTestId('valid-json-change'))
await waitFor(() => {
expect(screen.getByTestId('payload-schema')).toHaveTextContent(/"foo": "bar"/)
})
fireEvent.click(screen.getByTestId('invalid-json-change'))
expect(screen.getByTestId('payload-schema')).toHaveTextContent(/"foo": "bar"/)
fireEvent.click(screen.getByTestId('empty-json-change'))
await waitFor(() => {
expect(screen.getByTestId('payload-schema')).toHaveTextContent('')
})
fireEvent.click(screen.getByTestId('type-change'))
await waitFor(() => {
expect(screen.getByTestId('payload-type')).toHaveTextContent(InputVarType.singleFile)
})
fireEvent.click(screen.getByTestId('file-payload-change'))
await waitFor(() => {
expect(screen.getByTestId('payload-default')).toHaveTextContent('file-default')
})
expect(latestFormProps?.modelId).toBe('model-1')
})
})

View File

@@ -0,0 +1,89 @@
import type { InputVar } from '@/app/components/workflow/types'
import type { App, AppSSO } from '@/types/app'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { useStore } from '@/app/components/app/store'
import { toast } from '@/app/components/base/ui/toast'
import { InputVarType } from '@/app/components/workflow/types'
import { AppModeEnum } from '@/types/app'
import ConfigModal from '../index'
const toastErrorSpy = vi.spyOn(toast, 'error').mockReturnValue('toast-error')
const createPayload = (overrides: Partial<InputVar> = {}): InputVar => ({
type: InputVarType.textInput,
label: '',
variable: 'question',
required: false,
hide: false,
options: [],
default: 'hello',
max_length: 32,
...overrides,
})
describe('ConfigModal', () => {
beforeEach(() => {
vi.clearAllMocks()
useStore.setState({
appDetail: {
mode: AppModeEnum.CHAT,
} as App & Partial<AppSSO>,
})
})
it('should copy the variable name into the label when the label is empty', () => {
render(
<ConfigModal
isCreate
isShow
payload={createPayload()}
onClose={vi.fn()}
onConfirm={vi.fn()}
/>,
)
const textboxes = screen.getAllByRole('textbox')
fireEvent.blur(textboxes[0], { target: { value: 'question' } })
expect(textboxes[1]).toHaveValue('question')
})
it('should submit the edited payload when the form is valid', () => {
const onConfirm = vi.fn()
render(
<ConfigModal
isCreate
isShow
payload={createPayload({ label: 'Question' })}
onClose={vi.fn()}
onConfirm={onConfirm}
/>,
)
fireEvent.change(screen.getByDisplayValue('hello'), { target: { value: 'updated default' } })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({
default: 'updated default',
label: 'Question',
variable: 'question',
}), undefined)
})
it('should block save when the label is missing', () => {
render(
<ConfigModal
isCreate
isShow
payload={createPayload({ label: '' })}
onClose={vi.fn()}
onConfirm={vi.fn()}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(toastErrorSpy).toHaveBeenCalledWith('appDebug.variableConfig.errorMsg.labelNameRequired')
})
})

View File

@@ -0,0 +1,37 @@
/* eslint-disable ts/no-explicit-any */
import { fireEvent, render, screen } from '@testing-library/react'
import TypeSelector from '../type-select'
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => (
<button type="button" onClick={onClick}>{children}</button>
),
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/input-var-type-icon', () => ({
default: ({ type }: { type: string }) => <span>{type}</span>,
}))
describe('TypeSelector', () => {
it('should toggle open state and select a new variable type', () => {
const onSelect = vi.fn()
render(
<TypeSelector
value="text-input"
onSelect={onSelect}
items={[
{ value: 'text-input' as any, name: 'Text' },
{ value: 'number' as any, name: 'Number' },
]}
/>,
)
fireEvent.click(screen.getByRole('button'))
fireEvent.click(screen.getByText('Number'))
expect(onSelect).toHaveBeenCalledWith({ value: 'number', name: 'Number' })
})
})

View File

@@ -0,0 +1,267 @@
import type { InputVar } from '@/app/components/workflow/types'
import { DEFAULT_FILE_UPLOAD_SETTING } from '@/app/components/workflow/constants'
import { ChangeType, InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
import {
buildSelectOptions,
createPayloadForType,
getCheckboxDefaultSelectValue,
getJsonSchemaEditorValue,
isJsonSchemaEmpty,
isStringInputType,
normalizeSelectDefaultValue,
parseCheckboxSelectValue,
updatePayloadField,
validateConfigModalPayload,
} from '../utils'
const t = (key: string) => key
const createInputVar = (overrides: Partial<InputVar> = {}): InputVar => ({
type: InputVarType.textInput,
label: 'Question',
variable: 'question',
required: false,
options: [],
hide: false,
...overrides,
})
describe('config-modal utils', () => {
describe('payload helpers', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should clear the default value when options no longer include it', () => {
const payload = createInputVar({
type: InputVarType.select,
default: 'beta',
options: ['alpha', 'beta'],
})
const nextPayload = updatePayloadField(payload, 'options', ['alpha'])
expect(nextPayload.default).toBeUndefined()
expect(nextPayload.options).toEqual(['alpha'])
})
it('should seed upload defaults when switching to multi-file input', () => {
const payload = createInputVar({
type: InputVarType.textInput,
default: 'hello',
})
const nextPayload = createPayloadForType(payload, InputVarType.multiFiles)
expect(nextPayload.type).toBe(InputVarType.multiFiles)
expect(nextPayload.max_length).toBe(DEFAULT_FILE_UPLOAD_SETTING.max_length)
expect(nextPayload.allowed_file_types).toEqual(DEFAULT_FILE_UPLOAD_SETTING.allowed_file_types)
expect(nextPayload.default).toBe('hello')
})
it('should clear the default value when switching to a select input type', () => {
const payload = createInputVar({
type: InputVarType.textInput,
default: 'hello',
})
const nextPayload = createPayloadForType(payload, InputVarType.select)
expect(nextPayload.type).toBe(InputVarType.select)
expect(nextPayload.default).toBeUndefined()
})
it('should normalize empty select defaults to undefined', () => {
const nextPayload = normalizeSelectDefaultValue(createInputVar({
type: InputVarType.select,
default: '',
}))
expect(nextPayload.default).toBeUndefined()
})
it('should parse checkbox default values and normalize json schema editor content', () => {
expect(parseCheckboxSelectValue('true')).toBe(true)
expect(parseCheckboxSelectValue('false')).toBe(false)
expect(getJsonSchemaEditorValue(InputVarType.jsonObject, { type: 'object' } as never)).toBe(JSON.stringify({ type: 'object' }, null, 2))
expect(getJsonSchemaEditorValue(InputVarType.textInput, '{"type":"object"}')).toBe('')
expect(getJsonSchemaEditorValue(InputVarType.jsonObject, '{"type":"object"}')).toBe('{"type":"object"}')
})
it('should fall back to an empty editor value when json schema serialization fails', () => {
const circular: Record<string, unknown> = {}
circular.self = circular
expect(getJsonSchemaEditorValue(InputVarType.jsonObject, circular as never)).toBe('')
})
})
describe('derived values', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should expose upload and json options only when supported', () => {
const options = buildSelectOptions({
isBasicApp: false,
supportFile: true,
t,
})
expect(options.map(option => option.value)).toEqual(expect.arrayContaining([
InputVarType.singleFile,
InputVarType.multiFiles,
InputVarType.jsonObject,
]))
})
it('should derive checkbox defaults from boolean and string values', () => {
expect(getCheckboxDefaultSelectValue(true)).toBe('true')
expect(getCheckboxDefaultSelectValue('TRUE')).toBe('true')
expect(getCheckboxDefaultSelectValue(undefined)).toBe('false')
})
it('should detect blank json schema values', () => {
expect(isJsonSchemaEmpty(undefined)).toBe(true)
expect(isJsonSchemaEmpty(' ')).toBe(true)
expect(isJsonSchemaEmpty('{}')).toBe(false)
expect(isJsonSchemaEmpty({ type: 'object' } as never)).toBe(false)
expect(isStringInputType(InputVarType.textInput)).toBe(true)
expect(isStringInputType(InputVarType.paragraph)).toBe(true)
expect(isStringInputType(InputVarType.number)).toBe(false)
})
})
describe('validation', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should reject duplicate select options', () => {
const checkVariableName = vi.fn(() => true)
const result = validateConfigModalPayload({
tempPayload: createInputVar({
type: InputVarType.select,
options: ['alpha', 'alpha'],
}),
checkVariableName,
payload: createInputVar({
variable: 'question',
}),
t,
})
expect(result.errorMessage).toBe('variableConfig.errorMsg.optionRepeat')
expect(checkVariableName).toHaveBeenCalledWith('question')
})
it('should require custom extensions when custom file types are enabled', () => {
const result = validateConfigModalPayload({
tempPayload: createInputVar({
type: InputVarType.singleFile,
allowed_file_types: [SupportUploadFileTypes.custom],
allowed_file_extensions: [],
}),
checkVariableName: () => true,
payload: createInputVar(),
t,
})
expect(result.errorMessage).toBe('errorMsg.fieldRequired')
})
it('should require at least one select option and supported file types', () => {
const selectResult = validateConfigModalPayload({
tempPayload: createInputVar({
type: InputVarType.select,
options: [],
}),
checkVariableName: () => true,
payload: createInputVar(),
t,
})
const fileResult = validateConfigModalPayload({
tempPayload: createInputVar({
type: InputVarType.singleFile,
allowed_file_types: [],
}),
checkVariableName: () => true,
payload: createInputVar(),
t,
})
expect(selectResult.errorMessage).toBe('variableConfig.errorMsg.atLeastOneOption')
expect(fileResult.errorMessage).toBe('errorMsg.fieldRequired')
})
it('should reject invalid json schema definitions', () => {
const invalidResult = validateConfigModalPayload({
tempPayload: createInputVar({
type: InputVarType.jsonObject,
json_schema: '{',
}),
payload: createInputVar(),
checkVariableName: () => true,
t,
})
const nonObjectResult = validateConfigModalPayload({
tempPayload: createInputVar({
type: InputVarType.jsonObject,
json_schema: JSON.stringify({ type: 'string' }),
}),
payload: createInputVar(),
checkVariableName: () => true,
t,
})
expect(invalidResult.errorMessage).toBe('variableConfig.errorMsg.jsonSchemaInvalid')
expect(nonObjectResult.errorMessage).toBe('variableConfig.errorMsg.jsonSchemaMustBeObject')
})
it('should normalize blank json schema and return rename metadata', () => {
const result = validateConfigModalPayload({
tempPayload: createInputVar({
type: InputVarType.jsonObject,
variable: 'question_new',
json_schema: ' ',
}),
payload: createInputVar({
variable: 'question_old',
}),
checkVariableName: () => true,
t,
})
expect(result.errorMessage).toBeUndefined()
expect(result.payloadToSave).toEqual(expect.objectContaining({
json_schema: undefined,
variable: 'question_new',
}))
expect(result.moreInfo).toEqual({
type: ChangeType.changeVarName,
payload: {
beforeKey: 'question_old',
afterKey: 'question_new',
},
})
})
it('should stop validation when the variable name checker rejects the payload', () => {
const result = validateConfigModalPayload({
tempPayload: createInputVar({
variable: 'invalid_name',
}),
payload: createInputVar({
variable: 'question',
}),
checkVariableName: () => false,
t,
})
expect(result).toEqual({})
})
})
})

View File

@@ -0,0 +1,228 @@
'use client'
import type { ChangeEvent, FC } from 'react'
import type { Item as SelectOptionItem } from './type-select'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { InputVar, UploadFileSetting } from '@/app/components/workflow/types'
import * as React from 'react'
import Checkbox from '@/app/components/base/checkbox'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/app/components/base/ui/select'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import FileUploadSetting from '@/app/components/workflow/nodes/_base/components/file-upload-setting'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import ConfigSelect from '../config-select'
import ConfigString from '../config-string'
import { jsonConfigPlaceHolder } from './config'
import Field from './field'
import TypeSelector from './type-select'
import { CHECKBOX_DEFAULT_FALSE_VALUE, CHECKBOX_DEFAULT_TRUE_VALUE, TEXT_MAX_LENGTH } from './utils'
type Translate = (key: string, options?: Record<string, unknown>) => string
const EMPTY_SELECT_VALUE = '__empty__'
type ConfigModalFormFieldsProps = {
checkboxDefaultSelectValue: string
isStringInput: boolean
jsonSchemaStr: string
maxLength?: number
modelId: string
onFilePayloadChange: (payload: UploadFileSetting) => void
onJSONSchemaChange: (value: string) => void
onPayloadChange: (key: string) => (value: unknown) => void
onTypeChange: (item: SelectOptionItem) => void
onVarKeyBlur: (event: ChangeEvent<HTMLInputElement>) => void
onVarNameChange: (event: ChangeEvent<HTMLInputElement>) => void
options?: string[]
selectOptions: SelectOptionItem[]
tempPayload: InputVar
t: Translate
}
const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
checkboxDefaultSelectValue,
isStringInput,
jsonSchemaStr,
maxLength,
modelId,
onFilePayloadChange,
onJSONSchemaChange,
onPayloadChange,
onTypeChange,
onVarKeyBlur,
onVarNameChange,
options,
selectOptions,
tempPayload,
t,
}) => {
const { type, label, variable } = tempPayload
return (
<div className="space-y-2">
<Field title={t('variableConfig.fieldType', { ns: 'appDebug' })}>
<TypeSelector value={type} items={selectOptions} onSelect={onTypeChange} />
</Field>
<Field title={t('variableConfig.varName', { ns: 'appDebug' })}>
<Input
value={variable}
onChange={onVarNameChange}
onBlur={onVarKeyBlur}
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })}
/>
</Field>
<Field title={t('variableConfig.labelName', { ns: 'appDebug' })}>
<Input
value={label as string}
onChange={e => onPayloadChange('label')(e.target.value)}
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })}
/>
</Field>
{isStringInput && (
<Field title={t('variableConfig.maxLength', { ns: 'appDebug' })}>
<ConfigString
maxLength={type === InputVarType.textInput ? TEXT_MAX_LENGTH : Infinity}
modelId={modelId}
value={maxLength}
onChange={onPayloadChange('max_length')}
/>
</Field>
)}
{type === InputVarType.textInput && (
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
<Input
value={tempPayload.default || ''}
onChange={e => onPayloadChange('default')(e.target.value || undefined)}
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })}
/>
</Field>
)}
{type === InputVarType.paragraph && (
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
<Textarea
value={String(tempPayload.default ?? '')}
onChange={e => onPayloadChange('default')(e.target.value || undefined)}
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })}
/>
</Field>
)}
{type === InputVarType.number && (
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
<Input
type="number"
value={tempPayload.default || ''}
onChange={e => onPayloadChange('default')(e.target.value || undefined)}
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })}
/>
</Field>
)}
{type === InputVarType.checkbox && (
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
<Select value={checkboxDefaultSelectValue} onValueChange={value => onPayloadChange('default')(value === CHECKBOX_DEFAULT_TRUE_VALUE)}>
<SelectTrigger size="large" className="w-full">
<SelectValue placeholder={t('variableConfig.selectDefaultValue', { ns: 'appDebug' })} />
</SelectTrigger>
<SelectContent listClassName="max-h-[140px] overflow-y-auto">
<SelectItem value={CHECKBOX_DEFAULT_TRUE_VALUE}>{t('variableConfig.startChecked', { ns: 'appDebug' })}</SelectItem>
<SelectItem value={CHECKBOX_DEFAULT_FALSE_VALUE}>{t('variableConfig.noDefaultSelected', { ns: 'appDebug' })}</SelectItem>
</SelectContent>
</Select>
</Field>
)}
{type === InputVarType.select && (
<>
<Field title={t('variableConfig.options', { ns: 'appDebug' })}>
<ConfigSelect options={options || []} onChange={onPayloadChange('options')} />
</Field>
{options && options.length > 0 && (
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
<Select
key={`default-select-${options.join('-')}`}
value={tempPayload.default ? String(tempPayload.default) : EMPTY_SELECT_VALUE}
onValueChange={value => onPayloadChange('default')(value === EMPTY_SELECT_VALUE ? undefined : value)}
>
<SelectTrigger size="large" className="w-full">
<SelectValue placeholder={t('variableConfig.selectDefaultValue', { ns: 'appDebug' })} />
</SelectTrigger>
<SelectContent listClassName="max-h-[140px] overflow-y-auto">
<SelectItem value={EMPTY_SELECT_VALUE}>{t('variableConfig.noDefaultValue', { ns: 'appDebug' })}</SelectItem>
{options.filter(option => option.trim() !== '').map(option => (
<SelectItem key={option} value={option}>{option}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)}
</>
)}
{[InputVarType.singleFile, InputVarType.multiFiles].includes(type) && (
<>
<FileUploadSetting
payload={tempPayload as UploadFileSetting}
onChange={onFilePayloadChange}
isMultiple={type === InputVarType.multiFiles}
/>
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
<FileUploaderInAttachmentWrapper
value={(type === InputVarType.singleFile ? (tempPayload.default ? [tempPayload.default] : []) : (tempPayload.default || [])) as unknown as FileEntity[]}
onChange={(files) => {
if (type === InputVarType.singleFile)
onPayloadChange('default')(files?.[0] || undefined)
else
onPayloadChange('default')(files || undefined)
}}
fileConfig={{
allowed_file_types: tempPayload.allowed_file_types || [SupportUploadFileTypes.document],
allowed_file_extensions: tempPayload.allowed_file_extensions || [],
allowed_file_upload_methods: tempPayload.allowed_file_upload_methods || [TransferMethod.remote_url],
number_limits: type === InputVarType.singleFile ? 1 : tempPayload.max_length || 5,
}}
/>
</Field>
</>
)}
{type === InputVarType.jsonObject && (
<Field title={t('variableConfig.jsonSchema', { ns: 'appDebug' })} isOptional>
<CodeEditor
language={CodeLanguage.json}
value={jsonSchemaStr}
onChange={onJSONSchemaChange}
noWrapper
className="h-[80px] overflow-y-auto radius-lg bg-components-input-bg-normal p-1"
placeholder={<div className="whitespace-pre">{jsonConfigPlaceHolder}</div>}
/>
</Field>
)}
<div className="mt-5! flex h-6 items-center space-x-2">
<Checkbox checked={tempPayload.required} disabled={tempPayload.hide} onCheck={() => onPayloadChange('required')(!tempPayload.required)} />
<span className="system-sm-semibold text-text-secondary">{t('variableConfig.required', { ns: 'appDebug' })}</span>
</div>
<div className="mt-5! flex h-6 items-center space-x-2">
<Checkbox checked={tempPayload.hide} disabled={tempPayload.required} onCheck={() => onPayloadChange('hide')(!tempPayload.hide)} />
<span className="system-sm-semibold text-text-secondary">{t('variableConfig.hide', { ns: 'appDebug' })}</span>
</div>
</div>
)
}
export default React.memo(ConfigModalFormFields)

View File

@@ -1,56 +1,29 @@
'use client'
import type { ChangeEvent, FC } from 'react'
import type { Item as SelectItem } from './type-select'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { InputVar, MoreInfo, UploadFileSetting } from '@/app/components/workflow/types'
import { produce } from 'immer'
import type { InputVar, InputVarType, MoreInfo } from '@/app/components/workflow/types'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { useStore as useAppStore } from '@/app/components/app/store'
import Checkbox from '@/app/components/base/checkbox'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
import Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal'
import { SimpleSelect } from '@/app/components/base/select'
import Textarea from '@/app/components/base/textarea'
import { toast } from '@/app/components/base/ui/toast'
import { DEFAULT_FILE_UPLOAD_SETTING } from '@/app/components/workflow/constants'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import FileUploadSetting from '@/app/components/workflow/nodes/_base/components/file-upload-setting'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import { ChangeType, InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
import ConfigContext from '@/context/debug-configuration'
import { AppModeEnum, TransferMethod } from '@/types/app'
import { AppModeEnum } from '@/types/app'
import { checkKeys, getNewVarInWorkflow, replaceSpaceWithUnderscoreInVarNameInput } from '@/utils/var'
import ConfigSelect from '../config-select'
import ConfigString from '../config-string'
import ModalFoot from '../modal-foot'
import { jsonConfigPlaceHolder } from './config'
import Field from './field'
import TypeSelector from './type-select'
const TEXT_MAX_LENGTH = 256
const CHECKBOX_DEFAULT_TRUE_VALUE = 'true'
const CHECKBOX_DEFAULT_FALSE_VALUE = 'false'
const getCheckboxDefaultSelectValue = (value: InputVar['default']) => {
if (typeof value === 'boolean')
return value ? CHECKBOX_DEFAULT_TRUE_VALUE : CHECKBOX_DEFAULT_FALSE_VALUE
if (typeof value === 'string')
return value.toLowerCase() === CHECKBOX_DEFAULT_TRUE_VALUE ? CHECKBOX_DEFAULT_TRUE_VALUE : CHECKBOX_DEFAULT_FALSE_VALUE
return CHECKBOX_DEFAULT_FALSE_VALUE
}
const parseCheckboxSelectValue = (value: string) =>
value === CHECKBOX_DEFAULT_TRUE_VALUE
const normalizeSelectDefaultValue = (inputVar: InputVar) => {
if (inputVar.type === InputVarType.select && inputVar.default === '')
return { ...inputVar, default: undefined }
return inputVar
}
import ConfigModalFormFields from './form-fields'
import {
buildSelectOptions,
createPayloadForType,
getCheckboxDefaultSelectValue,
getJsonSchemaEditorValue,
isStringInputType,
normalizeSelectDefaultValue,
updatePayloadField,
validateConfigModalPayload,
} from './utils'
type IConfigModalProps = {
isCreate?: boolean
@@ -73,28 +46,18 @@ const ConfigModal: FC<IConfigModalProps> = ({
const { modelConfig } = useContext(ConfigContext)
const { t } = useTranslation()
const [tempPayload, setTempPayload] = useState<InputVar>(() => normalizeSelectDefaultValue(payload || getNewVarInWorkflow('') as any))
const { type, label, variable, options, max_length } = tempPayload
const { type, options, max_length } = tempPayload
const modalRef = useRef<HTMLDivElement>(null)
const appDetail = useAppStore(state => state.appDetail)
const isBasicApp = appDetail?.mode !== AppModeEnum.ADVANCED_CHAT && appDetail?.mode !== AppModeEnum.WORKFLOW
const jsonSchemaStr = useMemo(() => {
const isJsonObject = type === InputVarType.jsonObject
if (!isJsonObject || !tempPayload.json_schema)
return ''
try {
return tempPayload.json_schema
}
catch {
return ''
}
}, [tempPayload.json_schema])
const jsonSchemaStr = useMemo(() => getJsonSchemaEditorValue(type, tempPayload.json_schema), [tempPayload.json_schema, type])
useEffect(() => {
// To fix the first input element auto focus, then directly close modal will raise error
if (isShow)
modalRef.current?.focus()
}, [isShow])
const isStringInput = type === InputVarType.textInput || type === InputVarType.paragraph
const isStringInput = isStringInputType(type)
const checkVariableName = useCallback((value: string, canBeEmpty?: boolean) => {
const { isValid, errorMessageKey } = checkKeys([value], canBeEmpty)
if (!isValid) {
@@ -105,21 +68,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
}, [t])
const handlePayloadChange = useCallback((key: string) => {
return (value: any) => {
setTempPayload((prev) => {
const newPayload = {
...prev,
[key]: value,
}
// Clear default value if modified options no longer include current default
if (key === 'options' && prev.default) {
const optionsArray = Array.isArray(value) ? value : []
if (!optionsArray.includes(prev.default))
newPayload.default = undefined
}
return newPayload
})
setTempPayload(prev => updatePayloadField(prev, key, value))
}
}, [])
@@ -138,65 +87,15 @@ const ConfigModal: FC<IConfigModalProps> = ({
}
}, [handlePayloadChange])
const selectOptions: SelectItem[] = [
{
name: t('variableConfig.text-input', { ns: 'appDebug' }),
value: InputVarType.textInput,
},
{
name: t('variableConfig.paragraph', { ns: 'appDebug' }),
value: InputVarType.paragraph,
},
{
name: t('variableConfig.select', { ns: 'appDebug' }),
value: InputVarType.select,
},
{
name: t('variableConfig.number', { ns: 'appDebug' }),
value: InputVarType.number,
},
{
name: t('variableConfig.checkbox', { ns: 'appDebug' }),
value: InputVarType.checkbox,
},
...(supportFile
? [
{
name: t('variableConfig.single-file', { ns: 'appDebug' }),
value: InputVarType.singleFile,
},
{
name: t('variableConfig.multi-files', { ns: 'appDebug' }),
value: InputVarType.multiFiles,
},
]
: []),
...((!isBasicApp)
? [{
name: t('variableConfig.json', { ns: 'appDebug' }),
value: InputVarType.jsonObject,
}]
: []),
]
const selectOptions: SelectItem[] = useMemo(() => buildSelectOptions({
isBasicApp,
supportFile,
t,
}), [isBasicApp, supportFile, t])
const handleTypeChange = useCallback((item: SelectItem) => {
const type = item.value as InputVarType
const newPayload = produce(tempPayload, (draft) => {
draft.type = type
if (type === InputVarType.select)
draft.default = undefined
if ([InputVarType.singleFile, InputVarType.multiFiles].includes(type)) {
(Object.keys(DEFAULT_FILE_UPLOAD_SETTING)).forEach((key) => {
if (key !== 'max_length')
(draft as any)[key] = (DEFAULT_FILE_UPLOAD_SETTING as any)[key]
})
if (type === InputVarType.multiFiles)
draft.max_length = DEFAULT_FILE_UPLOAD_SETTING.max_length
}
})
setTempPayload(newPayload)
}, [tempPayload])
setTempPayload(prev => createPayloadForType(prev, item.value as InputVarType))
}, [])
const handleVarKeyBlur = useCallback((e: any) => {
const varName = e.target.value
@@ -224,98 +123,21 @@ const ConfigModal: FC<IConfigModalProps> = ({
const checkboxDefaultSelectValue = useMemo(() => getCheckboxDefaultSelectValue(tempPayload.default), [tempPayload.default])
const isJsonSchemaEmpty = (value: InputVar['json_schema']) => {
if (value === null || value === undefined) {
return true
}
if (typeof value !== 'string') {
return false
}
const trimmed = value.trim()
return trimmed === ''
}
const handleConfirm = () => {
const jsonSchemaValue = tempPayload.json_schema
const isSchemaEmpty = isJsonSchemaEmpty(jsonSchemaValue)
const normalizedJsonSchema = isSchemaEmpty ? undefined : jsonSchemaValue
const { errorMessage, moreInfo, payloadToSave } = validateConfigModalPayload({
tempPayload,
payload,
checkVariableName,
t,
})
// if the input type is jsonObject and the schema is empty as determined by `isJsonSchemaEmpty`,
// remove the `json_schema` field from the payload by setting its value to `undefined`.
const payloadToSave = tempPayload.type === InputVarType.jsonObject && isSchemaEmpty
? { ...tempPayload, json_schema: undefined }
: tempPayload
const moreInfo = tempPayload.variable === payload?.variable
? undefined
: {
type: ChangeType.changeVarName,
payload: { beforeKey: payload?.variable || '', afterKey: tempPayload.variable },
}
const isVariableNameValid = checkVariableName(tempPayload.variable)
if (!isVariableNameValid)
return
if (!tempPayload.label) {
toast.error(t('variableConfig.errorMsg.labelNameRequired', { ns: 'appDebug' }))
if (errorMessage) {
toast.error(errorMessage)
return
}
if (isStringInput || type === InputVarType.number) {
if (payloadToSave)
onConfirm(payloadToSave, moreInfo)
}
else if (type === InputVarType.select) {
if (options?.length === 0) {
toast.error(t('variableConfig.errorMsg.atLeastOneOption', { ns: 'appDebug' }))
return
}
const obj: Record<string, boolean> = {}
let hasRepeatedItem = false
options?.forEach((o) => {
if (obj[o]) {
hasRepeatedItem = true
return
}
obj[o] = true
})
if (hasRepeatedItem) {
toast.error(t('variableConfig.errorMsg.optionRepeat', { ns: 'appDebug' }))
return
}
onConfirm(payloadToSave, moreInfo)
}
else if ([InputVarType.singleFile, InputVarType.multiFiles].includes(type)) {
if (tempPayload.allowed_file_types?.length === 0) {
const errorMessages = t('errorMsg.fieldRequired', { ns: 'workflow', field: t('variableConfig.file.supportFileTypes', { ns: 'appDebug' }) })
toast.error(errorMessages)
return
}
if (tempPayload.allowed_file_types?.includes(SupportUploadFileTypes.custom) && !tempPayload.allowed_file_extensions?.length) {
const errorMessages = t('errorMsg.fieldRequired', { ns: 'workflow', field: t('variableConfig.file.custom.name', { ns: 'appDebug' }) })
toast.error(errorMessages)
return
}
onConfirm(payloadToSave, moreInfo)
}
else if (type === InputVarType.jsonObject) {
if (!isSchemaEmpty && typeof normalizedJsonSchema === 'string') {
try {
const schema = JSON.parse(normalizedJsonSchema)
if (schema?.type !== 'object') {
toast.error(t('variableConfig.errorMsg.jsonSchemaMustBeObject', { ns: 'appDebug' }))
return
}
}
catch {
toast.error(t('variableConfig.errorMsg.jsonSchemaInvalid', { ns: 'appDebug' }))
return
}
}
onConfirm(payloadToSave, moreInfo)
}
else {
onConfirm(payloadToSave, moreInfo)
}
}
return (
@@ -325,165 +147,23 @@ const ConfigModal: FC<IConfigModalProps> = ({
onClose={onClose}
>
<div className="mb-8" ref={modalRef} tabIndex={-1}>
<div className="space-y-2">
<Field title={t('variableConfig.fieldType', { ns: 'appDebug' })}>
<TypeSelector value={type} items={selectOptions} onSelect={handleTypeChange} />
</Field>
<Field title={t('variableConfig.varName', { ns: 'appDebug' })}>
<Input
value={variable}
onChange={handleVarNameChange}
onBlur={handleVarKeyBlur}
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })!}
/>
</Field>
<Field title={t('variableConfig.labelName', { ns: 'appDebug' })}>
<Input
value={label as string}
onChange={e => handlePayloadChange('label')(e.target.value)}
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })!}
/>
</Field>
{isStringInput && (
<Field title={t('variableConfig.maxLength', { ns: 'appDebug' })}>
<ConfigString maxLength={type === InputVarType.textInput ? TEXT_MAX_LENGTH : Infinity} modelId={modelConfig.model_id} value={max_length} onChange={handlePayloadChange('max_length')} />
</Field>
)}
{/* Default value for text input */}
{type === InputVarType.textInput && (
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
<Input
value={tempPayload.default || ''}
onChange={e => handlePayloadChange('default')(e.target.value || undefined)}
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })!}
/>
</Field>
)}
{/* Default value for paragraph */}
{type === InputVarType.paragraph && (
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
<Textarea
value={String(tempPayload.default ?? '')}
onChange={e => handlePayloadChange('default')(e.target.value || undefined)}
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })!}
/>
</Field>
)}
{/* Default value for number input */}
{type === InputVarType.number && (
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
<Input
type="number"
value={tempPayload.default || ''}
onChange={e => handlePayloadChange('default')(e.target.value || undefined)}
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })!}
/>
</Field>
)}
{type === InputVarType.checkbox && (
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
<SimpleSelect
className="w-full"
optionWrapClassName="max-h-[140px] overflow-y-auto"
items={[
{ value: CHECKBOX_DEFAULT_TRUE_VALUE, name: t('variableConfig.startChecked', { ns: 'appDebug' }) },
{ value: CHECKBOX_DEFAULT_FALSE_VALUE, name: t('variableConfig.noDefaultSelected', { ns: 'appDebug' }) },
]}
defaultValue={checkboxDefaultSelectValue}
onSelect={item => handlePayloadChange('default')(parseCheckboxSelectValue(String(item.value)))}
placeholder={t('variableConfig.selectDefaultValue', { ns: 'appDebug' })}
allowSearch={false}
/>
</Field>
)}
{type === InputVarType.select && (
<>
<Field title={t('variableConfig.options', { ns: 'appDebug' })}>
<ConfigSelect options={options || []} onChange={handlePayloadChange('options')} />
</Field>
{options && options.length > 0 && (
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
<SimpleSelect
key={`default-select-${options.join('-')}`}
className="w-full"
optionWrapClassName="max-h-[140px] overflow-y-auto"
items={[
{ value: '', name: t('variableConfig.noDefaultValue', { ns: 'appDebug' }) },
...options.filter(opt => opt.trim() !== '').map(option => ({
value: option,
name: option,
})),
]}
defaultValue={tempPayload.default || ''}
onSelect={item => handlePayloadChange('default')(item.value === '' ? undefined : item.value)}
placeholder={t('variableConfig.selectDefaultValue', { ns: 'appDebug' })}
allowSearch={false}
/>
</Field>
)}
</>
)}
{[InputVarType.singleFile, InputVarType.multiFiles].includes(type) && (
<>
<FileUploadSetting
payload={tempPayload as UploadFileSetting}
onChange={(p: UploadFileSetting) => setTempPayload(p as InputVar)}
isMultiple={type === InputVarType.multiFiles}
/>
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
<FileUploaderInAttachmentWrapper
value={(type === InputVarType.singleFile ? (tempPayload.default ? [tempPayload.default] : []) : (tempPayload.default || [])) as unknown as FileEntity[]}
onChange={(files) => {
if (type === InputVarType.singleFile)
handlePayloadChange('default')(files?.[0] || undefined)
else
handlePayloadChange('default')(files || undefined)
}}
fileConfig={{
allowed_file_types: tempPayload.allowed_file_types || [SupportUploadFileTypes.document],
allowed_file_extensions: tempPayload.allowed_file_extensions || [],
allowed_file_upload_methods: tempPayload.allowed_file_upload_methods || [TransferMethod.remote_url],
number_limits: type === InputVarType.singleFile ? 1 : tempPayload.max_length || 5,
}}
/>
</Field>
</>
)}
{type === InputVarType.jsonObject && (
<Field title={t('variableConfig.jsonSchema', { ns: 'appDebug' })} isOptional>
<CodeEditor
language={CodeLanguage.json}
value={jsonSchemaStr}
onChange={handleJSONSchemaChange}
noWrapper
className="bg h-[80px] overflow-y-auto radius-lg bg-components-input-bg-normal p-1"
placeholder={
<div className="whitespace-pre">{jsonConfigPlaceHolder}</div>
}
/>
</Field>
)}
<div className="mt-5! flex h-6 items-center space-x-2">
<Checkbox checked={tempPayload.required} disabled={tempPayload.hide} onCheck={() => handlePayloadChange('required')(!tempPayload.required)} />
<span className="system-sm-semibold text-text-secondary">{t('variableConfig.required', { ns: 'appDebug' })}</span>
</div>
<div className="mt-5! flex h-6 items-center space-x-2">
<Checkbox checked={tempPayload.hide} disabled={tempPayload.required} onCheck={() => handlePayloadChange('hide')(!tempPayload.hide)} />
<span className="system-sm-semibold text-text-secondary">{t('variableConfig.hide', { ns: 'appDebug' })}</span>
</div>
</div>
<ConfigModalFormFields
checkboxDefaultSelectValue={checkboxDefaultSelectValue}
isStringInput={isStringInput}
jsonSchemaStr={jsonSchemaStr}
maxLength={max_length}
modelId={modelConfig.model_id}
onFilePayloadChange={payload => setTempPayload(payload as InputVar)}
onJSONSchemaChange={handleJSONSchemaChange}
onPayloadChange={handlePayloadChange}
onTypeChange={handleTypeChange}
onVarKeyBlur={handleVarKeyBlur}
onVarNameChange={handleVarNameChange}
options={options}
selectOptions={selectOptions}
tempPayload={tempPayload}
t={t}
/>
</div>
<ModalFoot
onConfirm={handleConfirm}

View File

@@ -0,0 +1,247 @@
import type { Item as SelectItem } from './type-select'
import type { InputVar, MoreInfo } from '@/app/components/workflow/types'
import { produce } from 'immer'
import { DEFAULT_FILE_UPLOAD_SETTING } from '@/app/components/workflow/constants'
import { ChangeType, InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
export const TEXT_MAX_LENGTH = 256
export const CHECKBOX_DEFAULT_TRUE_VALUE = 'true'
export const CHECKBOX_DEFAULT_FALSE_VALUE = 'false'
type Translate = (key: string, options?: Record<string, unknown>) => string
type ValidateConfigModalPayloadOptions = {
tempPayload: InputVar
payload?: InputVar
checkVariableName: (value: string, canBeEmpty?: boolean) => boolean
t: Translate
}
type ValidateConfigModalPayloadResult = {
payloadToSave?: InputVar
moreInfo?: MoreInfo
errorMessage?: string
}
export const isStringInputType = (type: InputVarType) =>
type === InputVarType.textInput || type === InputVarType.paragraph
export const getCheckboxDefaultSelectValue = (value: InputVar['default'] | boolean) => {
if (typeof value === 'boolean')
return value ? CHECKBOX_DEFAULT_TRUE_VALUE : CHECKBOX_DEFAULT_FALSE_VALUE
if (typeof value === 'string')
return value.toLowerCase() === CHECKBOX_DEFAULT_TRUE_VALUE ? CHECKBOX_DEFAULT_TRUE_VALUE : CHECKBOX_DEFAULT_FALSE_VALUE
return CHECKBOX_DEFAULT_FALSE_VALUE
}
export const parseCheckboxSelectValue = (value: string) =>
value === CHECKBOX_DEFAULT_TRUE_VALUE
export const normalizeSelectDefaultValue = (inputVar: InputVar) => {
if (inputVar.type === InputVarType.select && inputVar.default === '')
return { ...inputVar, default: undefined }
return inputVar
}
export const getJsonSchemaEditorValue = (type: InputVarType, jsonSchema?: InputVar['json_schema']) => {
if (type !== InputVarType.jsonObject || !jsonSchema)
return ''
try {
if (typeof jsonSchema !== 'string')
return JSON.stringify(jsonSchema, null, 2)
return jsonSchema
}
catch {
return ''
}
}
export const isJsonSchemaEmpty = (value: InputVar['json_schema']) => {
if (value === null || value === undefined)
return true
if (typeof value !== 'string')
return false
return value.trim() === ''
}
export const updatePayloadField = (payload: InputVar, key: string, value: unknown) => {
const nextPayload = {
...payload,
[key]: value,
} as InputVar
if (key === 'options' && payload.default) {
const options = Array.isArray(value) ? value : []
if (!options.includes(payload.default))
nextPayload.default = undefined
}
return nextPayload
}
export const createPayloadForType = (payload: InputVar, type: InputVarType) => {
return produce(payload, (draft) => {
draft.type = type
if (type === InputVarType.select)
draft.default = undefined
if ([InputVarType.singleFile, InputVarType.multiFiles].includes(type)) {
(Object.keys(DEFAULT_FILE_UPLOAD_SETTING) as Array<keyof typeof DEFAULT_FILE_UPLOAD_SETTING>).forEach((key) => {
if (key !== 'max_length')
draft[key] = DEFAULT_FILE_UPLOAD_SETTING[key] as never
})
if (type === InputVarType.multiFiles)
draft.max_length = DEFAULT_FILE_UPLOAD_SETTING.max_length
}
})
}
export const buildSelectOptions = ({
isBasicApp,
supportFile,
t,
}: {
isBasicApp: boolean
supportFile?: boolean
t: Translate
}): SelectItem[] => {
return [
{
name: t('variableConfig.text-input', { ns: 'appDebug' }),
value: InputVarType.textInput,
},
{
name: t('variableConfig.paragraph', { ns: 'appDebug' }),
value: InputVarType.paragraph,
},
{
name: t('variableConfig.select', { ns: 'appDebug' }),
value: InputVarType.select,
},
{
name: t('variableConfig.number', { ns: 'appDebug' }),
value: InputVarType.number,
},
{
name: t('variableConfig.checkbox', { ns: 'appDebug' }),
value: InputVarType.checkbox,
},
...(supportFile
? [
{
name: t('variableConfig.single-file', { ns: 'appDebug' }),
value: InputVarType.singleFile,
},
{
name: t('variableConfig.multi-files', { ns: 'appDebug' }),
value: InputVarType.multiFiles,
},
]
: []),
...(!isBasicApp
? [
{
name: t('variableConfig.json', { ns: 'appDebug' }),
value: InputVarType.jsonObject,
},
]
: []),
]
}
export const validateConfigModalPayload = ({
tempPayload,
payload,
checkVariableName,
t,
}: ValidateConfigModalPayloadOptions): ValidateConfigModalPayloadResult => {
const jsonSchemaValue = tempPayload.json_schema
const schemaEmpty = isJsonSchemaEmpty(jsonSchemaValue)
const normalizedJsonSchema = schemaEmpty ? undefined : jsonSchemaValue
const payloadToSave = tempPayload.type === InputVarType.jsonObject && schemaEmpty
? { ...tempPayload, json_schema: undefined }
: tempPayload
const moreInfo = tempPayload.variable === payload?.variable
? undefined
: {
type: ChangeType.changeVarName,
payload: { beforeKey: payload?.variable || '', afterKey: tempPayload.variable },
}
if (!checkVariableName(tempPayload.variable))
return {}
if (!tempPayload.label) {
return {
errorMessage: t('variableConfig.errorMsg.labelNameRequired', { ns: 'appDebug' }),
}
}
if (tempPayload.type === InputVarType.select) {
if (!tempPayload.options?.length) {
return {
errorMessage: t('variableConfig.errorMsg.atLeastOneOption', { ns: 'appDebug' }),
}
}
const duplicated = new Set<string>()
const hasRepeatedItem = tempPayload.options.some((option) => {
if (duplicated.has(option))
return true
duplicated.add(option)
return false
})
if (hasRepeatedItem) {
return {
errorMessage: t('variableConfig.errorMsg.optionRepeat', { ns: 'appDebug' }),
}
}
}
if ([InputVarType.singleFile, InputVarType.multiFiles].includes(tempPayload.type)) {
if (!tempPayload.allowed_file_types?.length) {
return {
errorMessage: t('errorMsg.fieldRequired', {
ns: 'workflow',
field: t('variableConfig.file.supportFileTypes', { ns: 'appDebug' }),
}),
}
}
if (tempPayload.allowed_file_types.includes(SupportUploadFileTypes.custom) && !tempPayload.allowed_file_extensions?.length) {
return {
errorMessage: t('errorMsg.fieldRequired', {
ns: 'workflow',
field: t('variableConfig.file.custom.name', { ns: 'appDebug' }),
}),
}
}
}
if (tempPayload.type === InputVarType.jsonObject && !schemaEmpty && typeof normalizedJsonSchema === 'string') {
try {
const schema = JSON.parse(normalizedJsonSchema)
if (schema?.type !== 'object') {
return {
errorMessage: t('variableConfig.errorMsg.jsonSchemaMustBeObject', { ns: 'appDebug' }),
}
}
}
catch {
return {
errorMessage: t('variableConfig.errorMsg.jsonSchemaInvalid', { ns: 'appDebug' }),
}
}
}
return {
payloadToSave,
moreInfo,
}
}

View File

@@ -1,8 +1,21 @@
import { fireEvent, render, screen } from '@testing-library/react'
import ConfigSelect from './index'
import ConfigSelect from '../index'
vi.mock('react-sortablejs', () => ({
ReactSortable: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
ReactSortable: ({
children,
list,
setList,
}: {
children: React.ReactNode
list: Array<{ id: number, name: string }>
setList: (list: Array<{ id: number, name: string }>) => void
}) => (
<div>
<button onClick={() => setList([...list].reverse())}>reorder-options</button>
{children}
</div>
),
}))
describe('ConfigSelect Component', () => {
@@ -58,6 +71,18 @@ describe('ConfigSelect Component', () => {
expect(firstInput.closest('div')).toHaveClass('border-components-input-border-active')
})
it('updates option values and clears focus styles on blur', () => {
render(<ConfigSelect {...defaultProps} />)
const firstInput = screen.getByDisplayValue('Option 1')
fireEvent.change(firstInput, { target: { value: 'Updated option' } })
expect(defaultProps.onChange).toHaveBeenCalledWith(['Updated option', 'Option 2'])
fireEvent.focus(firstInput)
fireEvent.blur(firstInput)
expect(firstInput.closest('div')).not.toHaveClass('border-components-input-border-active')
})
it('applies delete hover styles', () => {
render(<ConfigSelect {...defaultProps} />)
const optionContainer = screen.getByDisplayValue('Option 1').closest('div')
@@ -67,6 +92,8 @@ describe('ConfigSelect Component', () => {
return
fireEvent.mouseEnter(deleteButton)
expect(optionContainer).toHaveClass('border-components-input-border-destructive')
fireEvent.mouseLeave(deleteButton)
expect(optionContainer).not.toHaveClass('border-components-input-border-destructive')
})
it('renders empty state correctly', () => {
@@ -75,4 +102,12 @@ describe('ConfigSelect Component', () => {
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
expect(screen.getByText('appDebug.variableConfig.addOption')).toBeInTheDocument()
})
it('reorders options through the sortable callback', () => {
render(<ConfigSelect {...defaultProps} />)
fireEvent.click(screen.getByText('reorder-options'))
expect(defaultProps.onChange).toHaveBeenCalledWith(['Option 2', 'Option 1'])
})
})

View File

@@ -1,6 +1,6 @@
import type { IConfigStringProps } from './index'
import type { IConfigStringProps } from '../index'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import ConfigString from './index'
import ConfigString from '../index'
const renderConfigString = (props?: Partial<IConfigStringProps>) => {
const onChange = vi.fn()

View File

@@ -1,7 +1,7 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { InputVarType } from '@/app/components/workflow/types'
import SelectTypeItem from './index'
import SelectTypeItem from '../index'
describe('SelectTypeItem', () => {
// Rendering pathways based on type and selection state

View File

@@ -1,3 +1,4 @@
/* eslint-disable ts/no-explicit-any */
import type { Mock } from 'vitest'
import type { FeatureStoreState } from '@/app/components/base/features/store'
import type { FileUpload } from '@/app/components/base/features/types'
@@ -6,9 +7,9 @@ import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { Resolution, TransferMethod } from '@/types/app'
import ConfigVision from './index'
import ParamConfig from './param-config'
import ParamConfigContent from './param-config-content'
import ConfigVision from '../index'
import ParamConfig from '../param-config'
import ParamConfigContent from '../param-config-content'
const mockUseContext = vi.fn()
vi.mock('use-context-selector', async (importOriginal) => {

View File

@@ -1,12 +1,13 @@
/* eslint-disable ts/no-explicit-any */
import type { AgentConfig } from '@/models/debug'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { AgentStrategy } from '@/types/app'
import AgentSettingButton from './agent-setting-button'
import AgentSettingButton from '../agent-setting-button'
let latestAgentSettingProps: any
vi.mock('./agent/agent-setting', () => ({
vi.mock('../agent/agent-setting', () => ({
default: (props: any) => {
latestAgentSettingProps = props
return (

View File

@@ -1,9 +1,10 @@
/* eslint-disable ts/no-explicit-any */
import type { FeatureStoreState } from '@/app/components/base/features/store'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import ConfigAudio from './config-audio'
import ConfigAudio from '../config-audio'
const mockUseContext = vi.fn()
vi.mock('use-context-selector', async (importOriginal) => {

View File

@@ -1,9 +1,10 @@
/* eslint-disable ts/no-explicit-any */
import type { FeatureStoreState } from '@/app/components/base/features/store'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import ConfigDocument from './config-document'
import ConfigDocument from '../config-document'
const mockUseContext = vi.fn()
vi.mock('use-context-selector', async (importOriginal) => {

View File

@@ -1,10 +1,11 @@
/* eslint-disable ts/no-explicit-any */
import type { ModelConfig, PromptVariable } from '@/models/debug'
import type { ToolItem } from '@/types/app'
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import * as useContextSelector from 'use-context-selector'
import { AgentStrategy, AppModeEnum, ModelModeType } from '@/types/app'
import Config from './index'
import Config from '../index'
vi.mock('use-context-selector', async (importOriginal) => {
const actual = await importOriginal<typeof import('use-context-selector')>()
@@ -15,7 +16,7 @@ vi.mock('use-context-selector', async (importOriginal) => {
})
const mockFormattingDispatcher = vi.fn()
vi.mock('../debug/hooks', () => ({
vi.mock('../../debug/hooks', () => ({
useFormattingChangedDispatcher: () => mockFormattingDispatcher,
}))
@@ -35,28 +36,28 @@ vi.mock('@/app/components/app/configuration/config-var', () => ({
},
}))
vi.mock('../dataset-config', () => ({
vi.mock('../../dataset-config', () => ({
default: () => <div data-testid="dataset-config" />,
}))
vi.mock('./agent/agent-tools', () => ({
vi.mock('../agent/agent-tools', () => ({
default: () => <div data-testid="agent-tools" />,
}))
vi.mock('../config-vision', () => ({
vi.mock('../../config-vision', () => ({
default: () => <div data-testid="config-vision" />,
}))
vi.mock('./config-document', () => ({
vi.mock('../config-document', () => ({
default: () => <div data-testid="config-document" />,
}))
vi.mock('./config-audio', () => ({
vi.mock('../config-audio', () => ({
default: () => <div data-testid="config-audio" />,
}))
let latestHistoryPanelProps: any
vi.mock('../config-prompt/conversation-history/history-panel', () => ({
vi.mock('../../config-prompt/conversation-history/history-panel', () => ({
default: (props: any) => {
latestHistoryPanelProps = props
return <div data-testid="history-panel" />

View File

@@ -2,7 +2,7 @@ import type { AgentConfig } from '@/models/debug'
import { act, fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { MAX_ITERATIONS_NUM } from '@/config'
import AgentSetting from './index'
import AgentSetting from '../index'
vi.mock('ahooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('ahooks')>()

View File

@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import ItemPanel from './item-panel'
import ItemPanel from '../item-panel'
describe('AgentSetting/ItemPanel', () => {
it('should render icon, name, and children content', () => {

View File

@@ -1,8 +1,9 @@
/* eslint-disable ts/no-explicit-any */
import type {
PropsWithChildren,
} from 'react'
import type { Mock } from 'vitest'
import type SettingBuiltInToolType from './setting-built-in-tool'
import type SettingBuiltInToolType from '../setting-built-in-tool'
import type { Tool, ToolParameter } from '@/app/components/tools/types'
import type ToolPickerType from '@/app/components/workflow/block-selector/tool-picker'
import type { ToolDefaultValue } from '@/app/components/workflow/block-selector/types'
@@ -26,7 +27,7 @@ import {
} from '@/config'
import ConfigContext from '@/context/debug-configuration'
import { ModelModeType } from '@/types/app'
import AgentTools from './index'
import AgentTools from '../index'
const formattingDispatcherMock = vi.fn()
vi.mock('@/app/components/app/configuration/debug/hooks', () => ({
@@ -94,7 +95,7 @@ const SettingBuiltInToolMock = (props: SettingBuiltInToolProps) => {
</div>
)
}
vi.mock('./setting-built-in-tool', () => ({
vi.mock('../setting-built-in-tool', () => ({
default: (props: SettingBuiltInToolProps) => <SettingBuiltInToolMock {...props} />,
}))

View File

@@ -1,9 +1,10 @@
/* eslint-disable ts/no-explicit-any */
import type { Tool, ToolParameter } from '@/app/components/tools/types'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { CollectionType } from '@/app/components/tools/types'
import SettingBuiltInTool from './setting-built-in-tool'
import SettingBuiltInTool from '../setting-built-in-tool'
const fetchModelToolList = vi.fn()
const fetchBuiltInToolList = vi.fn()

View File

@@ -3,7 +3,7 @@ import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { AgentStrategy } from '@/types/app'
import AssistantTypePicker from './index'
import AssistantTypePicker from '../index'
// Test utilities
const defaultAgentConfig: AgentConfig = {

View File

@@ -1,5 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react'
import AutomaticBtn from './automatic-btn'
import AutomaticBtn from '../automatic-btn'
vi.mock('react-i18next', () => ({
useTranslation: () => ({

Some files were not shown because too many files have changed in this diff Show More